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:
2026-08-23 15:06:29 -04:00
co-authored by Claude Sonnet 5
parent 8f6b00280f
commit 9e7dc85558
3 changed files with 277 additions and 15 deletions
+153 -6
View File
@@ -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>';
});
}