Add Resume button, Surprise Me random pick, and bulk select
Three quality-of-life additions to the Library browser and course pages: - A course page shows a "Resume: <lesson>" button when there's a last-visited lesson, using Course.last_accessed_path (already populated by apply_progress_to_tree, just not surfaced before) - no more scrolling the tree to find where you left off. - "Surprise Me" picks a random not-yet-fully-watched course and loads it, for when there's too much library to decide what to watch. Reuses the already-cached get_all_course_dirs() and the same per-course completed/total shape _scan_library_activity() already computes for Stale Courses - no new scanning. - A select-mode toggle in the Library browser adds checkboxes to every row/card (list and grid) with a bulk-action bar to hide or queue several courses/folders at once, via two new endpoints (/api/hidden-paths/bulk, /api/next-up/bulk) that reuse the existing single-item set_path_hidden()/set_path_queued() in a loop. Selection is scoped to the current folder view and clears on navigation. Also fixes runTranscriptSearch() going silently blank on zero results instead of showing a "no matches" message, and updates the Help page to cover all of this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -248,6 +248,25 @@
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.resume-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.surprise-me-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #444;
|
||||
border-radius: 10px;
|
||||
@@ -653,6 +672,32 @@
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
#library-select-btn {
|
||||
border-radius: var(--radius);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.bulk-selected-count {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
margin-right: auto;
|
||||
}
|
||||
.select-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
display: grid;
|
||||
@@ -881,6 +926,9 @@
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2 class="course-name-heading">{{ course.name }}</h2>
|
||||
{% if resume_lesson %}
|
||||
<a class="btn resume-btn" href="/lesson/{{ resume_lesson.url }}">▶ Resume: {{ resume_lesson.title }}</a>
|
||||
{% endif %}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;">
|
||||
<div>
|
||||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||||
@@ -1028,6 +1076,10 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% if library_stats and library_stats.total_courses %}
|
||||
<div class="container">
|
||||
<a class="btn surprise-me-btn" href="/random-pick">🎲 Surprise Me</a>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2>
|
||||
@@ -1160,11 +1212,19 @@
|
||||
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
|
||||
onclick="setLibraryViewMode('grid')" title="Grid view">▦</button>
|
||||
</div>
|
||||
<button type="button" class="view-toggle-btn" id="library-select-btn"
|
||||
onclick="toggleSelectionMode()" title="Select multiple">☑</button>
|
||||
</div>
|
||||
<label class="transcript-search-toggle">
|
||||
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
|
||||
Also search transcripts
|
||||
</label>
|
||||
<div id="bulk-action-bar" class="bulk-action-bar" style="display: none;">
|
||||
<span id="bulk-selected-count" class="bulk-selected-count"></span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkHideSelected()">🚫 Hide Selected</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkQueueSelected()">📌 Add to Next Up</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSelectionMode()">Cancel</button>
|
||||
</div>
|
||||
<p id="library-path-bar" class="library-breadcrumb"></p>
|
||||
<div id="library-groups" style="margin-top: 15px;"></div>
|
||||
<div id="transcript-results"></div>
|
||||
@@ -1268,6 +1328,7 @@
|
||||
|
||||
function fetchLibraryLevel(path) {
|
||||
searchActive = false;
|
||||
clearSelection(); // selection is scoped to the current folder view
|
||||
const searchInput = document.getElementById('library-search-input');
|
||||
if (searchInput) searchInput.value = '';
|
||||
const transcriptResults = document.getElementById('transcript-results');
|
||||
@@ -1379,13 +1440,92 @@
|
||||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||||
}
|
||||
|
||||
// ---- Bulk select (hide / queue several courses or folders at once) ----
|
||||
let selectionMode = false;
|
||||
let selectedPaths = new Set();
|
||||
|
||||
function selectableAttrs(item) {
|
||||
if (!selectionMode) return '';
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const checked = selectedPaths.has(item.path) ? 'checked' : '';
|
||||
return `<input type="checkbox" class="select-checkbox" onclick="event.stopPropagation(); toggleSelection('${safePath}')" ${checked}>`;
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
selectionMode = !selectionMode;
|
||||
if (!selectionMode) selectedPaths.clear();
|
||||
const btn = document.getElementById('library-select-btn');
|
||||
if (btn) btn.classList.toggle('active', selectionMode);
|
||||
updateBulkActionBar();
|
||||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||||
}
|
||||
|
||||
function toggleSelection(path) {
|
||||
if (selectedPaths.has(path)) selectedPaths.delete(path);
|
||||
else selectedPaths.add(path);
|
||||
updateBulkActionBar();
|
||||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectionMode = false;
|
||||
selectedPaths.clear();
|
||||
const btn = document.getElementById('library-select-btn');
|
||||
if (btn) btn.classList.remove('active');
|
||||
updateBulkActionBar();
|
||||
}
|
||||
|
||||
function updateBulkActionBar() {
|
||||
const bar = document.getElementById('bulk-action-bar');
|
||||
const count = document.getElementById('bulk-selected-count');
|
||||
if (!bar) return;
|
||||
bar.style.display = selectedPaths.size > 0 ? 'flex' : 'none';
|
||||
if (count) count.textContent = `${selectedPaths.size} selected`;
|
||||
}
|
||||
|
||||
function bulkHideSelected() {
|
||||
const paths = Array.from(selectedPaths);
|
||||
fetch('/api/hidden-paths/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: paths, hidden: true })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
clearSelection();
|
||||
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function bulkQueueSelected() {
|
||||
const coursePaths = Array.from(selectedPaths).filter(p => {
|
||||
const item = (lastLibraryItems || []).find(i => i.path === p);
|
||||
return item && item.type === 'course';
|
||||
});
|
||||
if (!coursePaths.length) {
|
||||
alert('Select at least one course (not a folder) to add to Next Up.');
|
||||
return;
|
||||
}
|
||||
fetch('/api/next-up/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: coursePaths })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => clearSelection())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function courseCardHtml(item) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const iconHtml = item.has_thumbnail
|
||||
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">`
|
||||
: `<span>🎓</span>`;
|
||||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||||
return `
|
||||
<div class="library-grid-card" onclick="loadCoursePath('${safePath}')">
|
||||
<div class="library-grid-card" onclick="${clickHandler}">
|
||||
${selectableAttrs(item)}
|
||||
<div class="grid-card-thumb-wrap">${iconHtml}</div>
|
||||
<div class="grid-card-name">${item.name}</div>
|
||||
<div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</div>
|
||||
@@ -1396,8 +1536,10 @@
|
||||
function directoryCardHtml(item) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const safeName = item.name.replace(/'/g, "\\'");
|
||||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
|
||||
return `
|
||||
<div class="library-grid-card" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
||||
<div class="library-grid-card" onclick="${clickHandler}">
|
||||
${selectableAttrs(item)}
|
||||
<div class="grid-card-thumb-wrap">📁</div>
|
||||
<div class="grid-card-name">${item.name}</div>
|
||||
</div>
|
||||
@@ -1409,9 +1551,11 @@
|
||||
const iconHtml = item.has_thumbnail
|
||||
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
|
||||
: `<span class="lesson-icon">🎓</span>`;
|
||||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||||
return `
|
||||
<div class="lesson-item" onclick="loadCoursePath('${safePath}')">
|
||||
<div class="lesson-item" onclick="${clickHandler}">
|
||||
<div class="lesson-title">
|
||||
${selectableAttrs(item)}
|
||||
${iconHtml}
|
||||
<span class="lesson-name">${item.name}</span>
|
||||
</div>
|
||||
@@ -1426,9 +1570,11 @@
|
||||
function directoryRowHtml(item) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const safeName = item.name.replace(/'/g, "\\'");
|
||||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
|
||||
return `
|
||||
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
||||
<div class="tree-header directory" onclick="${clickHandler}">
|
||||
<div class="tree-title">
|
||||
${selectableAttrs(item)}
|
||||
<span class="tree-icon">📁</span>
|
||||
<span class="tree-name">${item.name}</span>
|
||||
</div>
|
||||
@@ -1472,6 +1618,7 @@
|
||||
|
||||
function runLibrarySearch(query) {
|
||||
searchActive = true;
|
||||
clearSelection(); // selection is scoped to the current folder view
|
||||
const container = document.getElementById('library-groups');
|
||||
const transcriptContainer = document.getElementById('transcript-results');
|
||||
container.innerHTML = skeletonRowsHtml(2);
|
||||
@@ -1508,7 +1655,7 @@
|
||||
.then(data => {
|
||||
const results = data.results || [];
|
||||
if (!results.length) {
|
||||
transcriptContainer.innerHTML = '';
|
||||
transcriptContainer.innerHTML = '<p style="color:var(--text-muted); padding: 6px 0;">No matching transcripts.</p>';
|
||||
return;
|
||||
}
|
||||
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
|
||||
@@ -1521,7 +1668,7 @@
|
||||
`).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
transcriptContainer.innerHTML = '';
|
||||
transcriptContainer.innerHTML = '<p style="color:var(--error);">Transcript search failed.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+56
-8
@@ -142,22 +142,70 @@
|
||||
<h1>Help</h1>
|
||||
|
||||
<div class="card">
|
||||
<h3>How to Use OfflineU</h3>
|
||||
<h3>Getting Started</h3>
|
||||
<ul>
|
||||
<li><strong>Prepare your course files</strong> in a directory structure</li>
|
||||
<li><strong>Browse your library</strong> from the main page, or enter a path manually</li>
|
||||
<li><strong>Click a course</strong> to load it</li>
|
||||
<li><strong>Start learning!</strong> Your progress will be saved automatically</li>
|
||||
<li><strong>Browse your library</strong> from the main page, or enter a path manually in Settings</li>
|
||||
<li><strong>Click a course</strong> to load it - folders drill down, courses open directly</li>
|
||||
<li><strong>Start learning!</strong> Progress, notes, and completion are all saved automatically</li>
|
||||
<li>Not sure what to watch? <strong>🎲 Surprise Me</strong> at the top of the dashboard picks a random course you haven't finished</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Watching a Lesson</h3>
|
||||
<ul>
|
||||
<li><strong>Space</strong> - play/pause</li>
|
||||
<li><strong>← / →</strong> - seek back/forward 10 seconds</li>
|
||||
<li><strong>↑ / ↓</strong> - volume up/down</li>
|
||||
<li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li>
|
||||
<li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li>
|
||||
<li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li>
|
||||
<li>The <strong>Lessons in this section</strong> list under the player jumps between other lessons in the same folder without leaving the page</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Notes</h3>
|
||||
<ul>
|
||||
<li>Every note is stamped with the video's timestamp when you save it - click a timestamp to jump straight to that moment</li>
|
||||
<li>Press <strong>N</strong> anywhere on a lesson page to capture one without reaching for the mouse</li>
|
||||
<li>Edit or delete any note from the floating panel at the bottom of the lesson page</li>
|
||||
<li><strong>Notes</strong> (in the footer) collects every note across your whole library in one searchable list</li>
|
||||
<li><strong>Download Study Guide</strong> on a loaded course exports every note as one markdown file, organized by section</li>
|
||||
<li>If you use <a href="/settings">Outline</a>, pick a topic on a lesson's notes to push them there automatically when you navigate away</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Finding Things</h3>
|
||||
<ul>
|
||||
<li>Search courses by name from the Library browser on the main page</li>
|
||||
<li>Check <strong>Also search transcripts</strong> to search inside subtitle files (.srt/.vtt) too, if your courses have them</li>
|
||||
<li>Switch between list and grid (poster) view with the toggle next to the search box - handy for browsing a folder with lots of courses by thumbnail</li>
|
||||
<li><strong>Hide</strong> a course or whole folder from Settings → Manage Library without touching anything on disk</li>
|
||||
<li>The ☑ button in the Library browser turns on <strong>select mode</strong> - pick several courses or folders at once to hide or add to Next Up together</li>
|
||||
<li><strong>Bulk Rename</strong> (Settings) finds and replaces text across every course/folder name at once - useful for stripping a release-group suffix off a batch of downloads</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Settings</h3>
|
||||
<ul>
|
||||
<li>Pick a built-in theme or set a custom accent color, corner style, and card style</li>
|
||||
<li>Adjust font, text size, page width, and spacing density</li>
|
||||
<li>Set the default library folder, and use <strong>Refresh Library</strong> if you've just added or removed files directly on disk and don't want to wait for it to notice</li>
|
||||
<li><strong>Download Backup</strong> exports your settings, hidden-path choices, recently-viewed history, and every course's progress/notes as a zip - not the course files themselves</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Supported File Types</h3>
|
||||
<ul>
|
||||
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm</li>
|
||||
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac</li>
|
||||
<li><strong>Documents:</strong> .txt, .md, .html, .pdf</li>
|
||||
<li><strong>Subtitles:</strong> .srt, .vtt</li>
|
||||
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm, .m4v, .flv, .wmv</li>
|
||||
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac, .ogg, .flac</li>
|
||||
<li><strong>Documents:</strong> .txt, .md, .html, .htm, .pdf, .docx, .doc, .rtf</li>
|
||||
<li><strong>Subtitles:</strong> .srt, .vtt, .ass, .sub, .sbv</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user