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:
+68
-1
@@ -7,6 +7,7 @@ Enhanced version with dynamic subdirectory navigation
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
@@ -1120,6 +1121,19 @@ def _scan_library_activity() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_incomplete_course() -> Optional[Path]:
|
||||||
|
"""A random course for the dashboard's "Surprise Me" pick - excludes
|
||||||
|
courses that are already fully watched where possible, falling back to
|
||||||
|
the whole library if everything's done."""
|
||||||
|
all_dirs = get_all_course_dirs()
|
||||||
|
if not all_dirs:
|
||||||
|
return None
|
||||||
|
scan = _scan_library_activity()
|
||||||
|
fully_completed = {c['path'] for c in scan['courses'] if c['total'] > 0 and c['completed'] >= c['total']}
|
||||||
|
candidates = [d for d in all_dirs if str(d) not in fully_completed]
|
||||||
|
return random.choice(candidates or all_dirs)
|
||||||
|
|
||||||
|
|
||||||
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
|
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
|
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
|
||||||
return {
|
return {
|
||||||
@@ -1827,13 +1841,20 @@ def index():
|
|||||||
if stats.get('total_duration_seconds'):
|
if stats.get('total_duration_seconds'):
|
||||||
stats['remaining_display'] = format_duration(stats['remaining_seconds'])
|
stats['remaining_display'] = format_duration(stats['remaining_seconds'])
|
||||||
|
|
||||||
|
resume_lesson = None
|
||||||
|
if current_course.last_accessed_path:
|
||||||
|
lesson, _ = find_lesson_in_tree(current_course.root_node, current_course.last_accessed_path)
|
||||||
|
if lesson:
|
||||||
|
resume_lesson = {'title': lesson.title, 'url': get_lesson_url(lesson, current_course.path)}
|
||||||
|
|
||||||
return render_template('course_dashboard.html',
|
return render_template('course_dashboard.html',
|
||||||
course=current_course,
|
course=current_course,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
continue_watching=continue_watching,
|
continue_watching=continue_watching,
|
||||||
recent_views=recent_views,
|
recent_views=recent_views,
|
||||||
has_note=bool(course_has_any_notes(current_course)),
|
has_note=bool(course_has_any_notes(current_course)),
|
||||||
is_queued=os.path.abspath(current_course.path) in get_next_up_paths())
|
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(),
|
||||||
|
resume_lesson=resume_lesson)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/browse')
|
@app.route('/browse')
|
||||||
@@ -2037,6 +2058,24 @@ def set_hidden_path_api():
|
|||||||
return jsonify({'success': True, 'hidden_paths': updated})
|
return jsonify({'success': True, 'hidden_paths': updated})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/hidden-paths/bulk', methods=['POST'])
|
||||||
|
def bulk_set_hidden_paths_api():
|
||||||
|
"""Hide or un-hide several courses/directories at once."""
|
||||||
|
data = request.json or {}
|
||||||
|
paths = data.get('paths') or []
|
||||||
|
hidden = bool(data.get('hidden', True))
|
||||||
|
if not isinstance(paths, list) or not paths:
|
||||||
|
return jsonify({'error': 'paths is required'}), 400
|
||||||
|
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
updated = get_hidden_paths()
|
||||||
|
for path in paths:
|
||||||
|
target = os.path.abspath(path)
|
||||||
|
if target == library_root or target.startswith(library_root + os.sep):
|
||||||
|
updated = set_path_hidden(target, hidden)
|
||||||
|
return jsonify({'success': True, 'hidden_paths': updated})
|
||||||
|
|
||||||
|
|
||||||
def validate_new_name(new_name: str) -> Optional[str]:
|
def validate_new_name(new_name: str) -> Optional[str]:
|
||||||
"""Validate a proposed directory name; returns an error message, or None if valid."""
|
"""Validate a proposed directory name; returns an error message, or None if valid."""
|
||||||
if not new_name:
|
if not new_name:
|
||||||
@@ -2302,6 +2341,17 @@ def load_course():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/random-pick')
|
||||||
|
def random_pick():
|
||||||
|
""""Surprise Me" - load a random not-yet-fully-watched course and land on its dashboard."""
|
||||||
|
global current_course
|
||||||
|
course_dir = get_random_incomplete_course()
|
||||||
|
if not course_dir:
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
current_course = get_course_tree(str(course_dir))
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/recent/open')
|
@app.route('/recent/open')
|
||||||
def open_recent():
|
def open_recent():
|
||||||
"""
|
"""
|
||||||
@@ -2383,6 +2433,23 @@ def set_next_up_api():
|
|||||||
return jsonify({'success': True, 'next_up': updated})
|
return jsonify({'success': True, 'next_up': updated})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/next-up/bulk', methods=['POST'])
|
||||||
|
def bulk_set_next_up_api():
|
||||||
|
"""Add several courses to the Next Up queue at once."""
|
||||||
|
data = request.json or {}
|
||||||
|
paths = data.get('paths') or []
|
||||||
|
if not isinstance(paths, list) or not paths:
|
||||||
|
return jsonify({'error': 'paths is required'}), 400
|
||||||
|
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
updated = get_next_up_paths()
|
||||||
|
for path in paths:
|
||||||
|
target = os.path.abspath(path)
|
||||||
|
if target == library_root or target.startswith(library_root + os.sep):
|
||||||
|
updated = set_path_queued(target, True)
|
||||||
|
return jsonify({'success': True, 'next_up': updated})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/next-up/reorder', methods=['POST'])
|
@app.route('/api/next-up/reorder', methods=['POST'])
|
||||||
def reorder_next_up_api():
|
def reorder_next_up_api():
|
||||||
"""Replace the Next Up order wholesale, from the client's up/down-reordered list."""
|
"""Replace the Next Up order wholesale, from the client's up/down-reordered list."""
|
||||||
|
|||||||
@@ -248,6 +248,25 @@
|
|||||||
background: #555;
|
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 {
|
.progress-bar {
|
||||||
background: #444;
|
background: #444;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -653,6 +672,32 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent);
|
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 {
|
.library-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -881,6 +926,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="course-name-heading">{{ course.name }}</h2>
|
<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 style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||||||
@@ -1028,6 +1076,10 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% if library_stats and library_stats.total_courses %}
|
{% 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="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2>
|
<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"
|
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
|
||||||
onclick="setLibraryViewMode('grid')" title="Grid view">▦</button>
|
onclick="setLibraryViewMode('grid')" title="Grid view">▦</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="view-toggle-btn" id="library-select-btn"
|
||||||
|
onclick="toggleSelectionMode()" title="Select multiple">☑</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="transcript-search-toggle">
|
<label class="transcript-search-toggle">
|
||||||
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
|
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
|
||||||
Also search transcripts
|
Also search transcripts
|
||||||
</label>
|
</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>
|
<p id="library-path-bar" class="library-breadcrumb"></p>
|
||||||
<div id="library-groups" style="margin-top: 15px;"></div>
|
<div id="library-groups" style="margin-top: 15px;"></div>
|
||||||
<div id="transcript-results"></div>
|
<div id="transcript-results"></div>
|
||||||
@@ -1268,6 +1328,7 @@
|
|||||||
|
|
||||||
function fetchLibraryLevel(path) {
|
function fetchLibraryLevel(path) {
|
||||||
searchActive = false;
|
searchActive = false;
|
||||||
|
clearSelection(); // selection is scoped to the current folder view
|
||||||
const searchInput = document.getElementById('library-search-input');
|
const searchInput = document.getElementById('library-search-input');
|
||||||
if (searchInput) searchInput.value = '';
|
if (searchInput) searchInput.value = '';
|
||||||
const transcriptResults = document.getElementById('transcript-results');
|
const transcriptResults = document.getElementById('transcript-results');
|
||||||
@@ -1379,13 +1440,92 @@
|
|||||||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
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) {
|
function courseCardHtml(item) {
|
||||||
const safePath = item.path.replace(/'/g, "\\'");
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
const iconHtml = item.has_thumbnail
|
const iconHtml = item.has_thumbnail
|
||||||
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">`
|
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">`
|
||||||
: `<span>🎓</span>`;
|
: `<span>🎓</span>`;
|
||||||
|
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||||||
return `
|
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-thumb-wrap">${iconHtml}</div>
|
||||||
<div class="grid-card-name">${item.name}</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>
|
<div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</div>
|
||||||
@@ -1396,8 +1536,10 @@
|
|||||||
function directoryCardHtml(item) {
|
function directoryCardHtml(item) {
|
||||||
const safePath = item.path.replace(/'/g, "\\'");
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
const safeName = item.name.replace(/'/g, "\\'");
|
const safeName = item.name.replace(/'/g, "\\'");
|
||||||
|
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
|
||||||
return `
|
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-thumb-wrap">📁</div>
|
||||||
<div class="grid-card-name">${item.name}</div>
|
<div class="grid-card-name">${item.name}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1409,9 +1551,11 @@
|
|||||||
const iconHtml = item.has_thumbnail
|
const iconHtml = item.has_thumbnail
|
||||||
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
|
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
|
||||||
: `<span class="lesson-icon">🎓</span>`;
|
: `<span class="lesson-icon">🎓</span>`;
|
||||||
|
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||||||
return `
|
return `
|
||||||
<div class="lesson-item" onclick="loadCoursePath('${safePath}')">
|
<div class="lesson-item" onclick="${clickHandler}">
|
||||||
<div class="lesson-title">
|
<div class="lesson-title">
|
||||||
|
${selectableAttrs(item)}
|
||||||
${iconHtml}
|
${iconHtml}
|
||||||
<span class="lesson-name">${item.name}</span>
|
<span class="lesson-name">${item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1426,9 +1570,11 @@
|
|||||||
function directoryRowHtml(item) {
|
function directoryRowHtml(item) {
|
||||||
const safePath = item.path.replace(/'/g, "\\'");
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
const safeName = item.name.replace(/'/g, "\\'");
|
const safeName = item.name.replace(/'/g, "\\'");
|
||||||
|
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
|
||||||
return `
|
return `
|
||||||
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
<div class="tree-header directory" onclick="${clickHandler}">
|
||||||
<div class="tree-title">
|
<div class="tree-title">
|
||||||
|
${selectableAttrs(item)}
|
||||||
<span class="tree-icon">📁</span>
|
<span class="tree-icon">📁</span>
|
||||||
<span class="tree-name">${item.name}</span>
|
<span class="tree-name">${item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1472,6 +1618,7 @@
|
|||||||
|
|
||||||
function runLibrarySearch(query) {
|
function runLibrarySearch(query) {
|
||||||
searchActive = true;
|
searchActive = true;
|
||||||
|
clearSelection(); // selection is scoped to the current folder view
|
||||||
const container = document.getElementById('library-groups');
|
const container = document.getElementById('library-groups');
|
||||||
const transcriptContainer = document.getElementById('transcript-results');
|
const transcriptContainer = document.getElementById('transcript-results');
|
||||||
container.innerHTML = skeletonRowsHtml(2);
|
container.innerHTML = skeletonRowsHtml(2);
|
||||||
@@ -1508,7 +1655,7 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
const results = data.results || [];
|
const results = data.results || [];
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
transcriptContainer.innerHTML = '';
|
transcriptContainer.innerHTML = '<p style="color:var(--text-muted); padding: 6px 0;">No matching transcripts.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
|
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('');
|
`).join('');
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
transcriptContainer.innerHTML = '';
|
transcriptContainer.innerHTML = '<p style="color:var(--error);">Transcript search failed.</p>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-8
@@ -142,22 +142,70 @@
|
|||||||
<h1>Help</h1>
|
<h1>Help</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>How to Use OfflineU</h3>
|
<h3>Getting Started</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Prepare your course files</strong> in a directory structure</li>
|
<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>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</li>
|
<li><strong>Click a course</strong> to load it - folders drill down, courses open directly</li>
|
||||||
<li><strong>Start learning!</strong> Your progress will be saved automatically</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Supported File Types</h3>
|
<h3>Supported File Types</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm</li>
|
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm, .m4v, .flv, .wmv</li>
|
||||||
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac</li>
|
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac, .ogg, .flac</li>
|
||||||
<li><strong>Documents:</strong> .txt, .md, .html, .pdf</li>
|
<li><strong>Documents:</strong> .txt, .md, .html, .htm, .pdf, .docx, .doc, .rtf</li>
|
||||||
<li><strong>Subtitles:</strong> .srt, .vtt</li>
|
<li><strong>Subtitles:</strong> .srt, .vtt, .ass, .sub, .sbv</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user