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>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user