Add search, thumbnails, continue watching, playback speed, PWA install, sort, notes, and mark-watched

Eight usability additions on top of the mobile redesign: course cover-image
thumbnails, a debounced library search, a Continue Watching section split
from Recently Viewed, per-lesson playback speed control, an installable
PWA manifest/icons, name sort in the library browser, per-lesson notes, and
a bulk mark-course-as-watched action. Also fixes two real bugs found along
the way: lesson_view.html was missing the viewport meta tag (so lesson
pages weren't actually mobile-responsive), and update_lesson_progress
always overwrote the whole progress entry, which would have silently
deleted a saved note on the next routine autosave.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 09:06:44 -04:00
co-authored by Claude Sonnet 5
parent c92268e04c
commit 38963b1d55
9 changed files with 597 additions and 51 deletions
+207 -43
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %}</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
* {
margin: 0;
@@ -455,6 +458,43 @@
flex-shrink: 0;
}
.lesson-thumb {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
display: block;
}
.library-toolbar {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.library-search-input {
flex: 1;
min-width: 160px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 12px;
font-size: 14px;
font-family: var(--font-family);
}
.library-sort-select {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 12px;
font-size: 14px;
}
.lesson-name {
flex: 1;
min-width: 0;
@@ -603,6 +643,12 @@
<div class="progress-fill" style="width: {{ stats.completion_percentage }}%;"></div>
</div>
{% if stats.total_lessons %}
<button class="btn btn-secondary" style="margin-top: 5px;" onclick="markCourseWatched()">
Mark all as completed
</button>
{% endif %}
{% if stats.last_accessed_path %}
<div class="last-accessed">
<strong>Last Accessed:</strong> {{ stats.last_accessed_path }}
@@ -674,30 +720,51 @@
</div>
</div>
{% else %}
{% macro render_view_row(view) %}
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
<div class="lesson-title">
{% if view.has_thumbnail %}
<img class="lesson-thumb" src="/library/thumbnail?path={{ view.course_path | urlencode }}" alt=""
onerror="this.replaceWith(courseFallbackIcon('▶️'))">
{% else %}
<span class="lesson-icon">▶️</span>
{% endif %}
<span class="lesson-name">{{ view.lesson_title }}</span>
</div>
<div class="lesson-meta">
{% if view.completed %}
<span class="status-icon completed"></span>
{% elif view.percent_watched %}
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
{% endif %}
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
</div>
{% if view.percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endmacro %}
{% if continue_watching %}
<div class="container">
<div class="card">
<h2>▶ Continue Watching</h2>
<div style="margin-top: 12px;">
{% for view in continue_watching %}
{{ render_view_row(view) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if recent_views %}
<div class="container">
<div class="card">
<h2>🕐 Recently Viewed</h2>
<div style="margin-top: 12px;">
{% for view in recent_views %}
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
<div class="lesson-title">
<span class="lesson-icon">▶️</span>
<span class="lesson-name">{{ view.lesson_title }}</span>
</div>
<div class="lesson-meta">
{% if view.completed %}
<span class="status-icon completed"></span>
{% elif view.percent_watched %}
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
{% endif %}
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
</div>
{% if view.percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
{% endif %}
</div>
{{ render_view_row(view) }}
{% endfor %}
</div>
</div>
@@ -706,6 +773,14 @@
<div class="container">
<div class="card" id="library-card">
<h2>Your Courses</h2>
<div class="library-toolbar">
<input type="text" id="library-search-input" class="library-search-input"
placeholder="Search courses…" oninput="handleLibrarySearchInput(this.value)">
<select id="library-sort" class="library-sort-select" onchange="reapplySort()">
<option value="name-asc">Name (A→Z)</option>
<option value="name-desc">Name (Z→A)</option>
</select>
</div>
<p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
</div>
@@ -756,6 +831,9 @@
// back up. Much less horizontal space wasted than the old
// indent-per-level accordion, especially on narrow screens.
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
let lastLibraryItems = null; // whatever's on screen right now (browse or search), for re-sort without re-fetching
let searchActive = false;
let searchDebounceTimer = null;
function loadLibrary() {
const libraryCard = document.getElementById('library-card');
@@ -765,6 +843,10 @@
}
function fetchLibraryLevel(path) {
searchActive = false;
const searchInput = document.getElementById('library-search-input');
if (searchInput) searchInput.value = '';
const container = document.getElementById('library-groups');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
@@ -810,6 +892,7 @@
const container = document.getElementById('library-groups');
const isRoot = libraryTrail.length === 0;
if (!data.items || data.items.length === 0) {
lastLibraryItems = null;
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
@@ -817,31 +900,8 @@
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
if (item.type === 'course') {
return `
<div class="lesson-item"
onclick="loadCoursePath('${safePath}')">
<div class="lesson-title">
<span class="lesson-icon">🎓</span>
<span class="lesson-name">${item.name}</span>
</div>
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
</div>
`;
}
const safeName = item.name.replace(/'/g, "\\'");
return `
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span>
</div>
<span class="tree-toggle"></span>
</div>
`;
}).join('');
lastLibraryItems = data.items;
renderItemRows(sortLibraryItems(data.items));
}
function enterLibraryDir(path, name) {
@@ -849,6 +909,97 @@
fetchLibraryLevel(path);
}
// Fallback if a course's thumbnail image 404s/errors after the row
// already rendered (e.g. the file was removed on disk in between) -
// swaps back to the plain icon rather than showing a broken image.
function courseFallbackIcon(emoji) {
const span = document.createElement('span');
span.className = 'lesson-icon';
span.textContent = emoji;
return span;
}
function courseRowHtml(item) {
const safePath = item.path.replace(/'/g, "\\'");
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>`;
return `
<div class="lesson-item" onclick="loadCoursePath('${safePath}')">
<div class="lesson-title">
${iconHtml}
<span class="lesson-name">${item.name}</span>
</div>
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
</div>
`;
}
function directoryRowHtml(item) {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
return `
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span>
</div>
<span class="tree-toggle"></span>
</div>
`;
}
function renderItemRows(items) {
document.getElementById('library-groups').innerHTML =
items.map(item => item.type === 'course' ? courseRowHtml(item) : directoryRowHtml(item)).join('');
}
function sortLibraryItems(items) {
const order = document.getElementById('library-sort').value;
const sorted = items.slice();
sorted.sort((a, b) => order === 'name-desc'
? b.name.localeCompare(a.name)
: a.name.localeCompare(b.name));
return sorted;
}
function reapplySort() {
if (!lastLibraryItems) return;
renderItemRows(sortLibraryItems(lastLibraryItems));
}
// ---- Search ----
function handleLibrarySearchInput(value) {
clearTimeout(searchDebounceTimer);
const query = value.trim();
if (!query) {
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
return;
}
searchDebounceTimer = setTimeout(() => runLibrarySearch(query), 300);
}
function runLibrarySearch(query) {
searchActive = true;
const container = document.getElementById('library-groups');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>';
fetch(`/library/search?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => {
document.getElementById('library-path-bar').innerHTML =
`<span class="crumb current" style="max-width: none;">Search results for "${query}"</span>`;
lastLibraryItems = data.results;
if (!data.results.length) {
container.innerHTML = `<p style="color:#999; padding: 6px 0;">No courses match "${query}".</p>`;
return;
}
renderItemRows(sortLibraryItems(data.results));
})
.catch(() => {
container.innerHTML = '<p style="color:#ff6b6b;">Search failed.</p>';
});
}
function loadCoursePath(path) {
fetch('/load_course', {
method: 'POST',
@@ -865,6 +1016,19 @@
});
}
function markCourseWatched() {
if (!confirm('Mark every lesson in this course as completed?')) return;
fetch('/api/course/mark-watched', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
});
}
document.addEventListener('DOMContentLoaded', loadLibrary);
</script>
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help - OfflineU</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
:root {
--bg-primary: #1a1a1a;
+137 -3
View File
@@ -1,7 +1,12 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ lesson.title }} - {{ course.name }}</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
:root {
--bg-primary: #1a1a1a;
@@ -149,8 +154,56 @@
color: var(--text-muted);
margin-top: 6px;
}
.content {
margin: 20px 0;
.speed-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.speed-label {
color: var(--text-muted);
font-size: 0.9em;
margin-right: 4px;
}
.speed-btn {
padding: 5px 12px;
margin: 0;
font-size: 0.85em;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.speed-btn:hover {
background: var(--bg-tertiary-hover);
}
.speed-btn.active {
background: var(--accent);
color: white;
}
.content {
margin: 20px 0;
}
.notes-section {
margin: 20px 0;
}
.lesson-note-textarea {
width: 100%;
min-height: 90px;
padding: 12px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-family: var(--font-family);
font-size: 0.95em;
resize: vertical;
}
.note-status {
display: block;
font-size: 0.85em;
color: var(--text-muted);
margin-top: 6px;
min-height: 1.2em;
}
.navigation {
margin: 20px 0;
@@ -271,6 +324,15 @@
</audio>
{% endif %}
{% if lesson.video_file or lesson.audio_file %}
<div class="speed-controls" id="speed-controls">
<span class="speed-label">Speed:</span>
{% for speed in ['0.75', '1', '1.25', '1.5', '1.75', '2'] %}
<button type="button" class="speed-btn" data-speed="{{ speed }}" onclick="setPlaybackSpeed('{{ speed }}')">{{ speed }}x</button>
{% endfor %}
</div>
{% endif %}
{% if lesson.text_files %}
<h3>Content</h3>
{% for text_file in lesson.text_files %}
@@ -287,6 +349,13 @@
{% endif %}
</div>
<div class="notes-section">
<h3>Notes</h3>
<textarea id="lesson-note" class="lesson-note-textarea"
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
<span id="note-status" class="note-status"></span>
</div>
<div class="nav-buttons">
{% if prev_lesson %}
<a href="/lesson/{{ prev_lesson }}">
@@ -425,6 +494,71 @@
}
}
// Restore the persisted playback speed and highlight the active
// button; clicking a button applies it immediately and persists
// it via /api/settings, mirroring the video-size pattern above.
if (activeMedia) {
fetch('/api/settings')
.then(r => r.json())
.then(data => {
const speed = (data.settings || {}).playback_speed || '1';
activeMedia.playbackRate = parseFloat(speed);
highlightSpeedButton(speed);
})
.catch(() => {});
}
function highlightSpeedButton(speed) {
document.querySelectorAll('.speed-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.speed === speed);
});
}
function setPlaybackSpeed(speed) {
if (activeMedia) activeMedia.playbackRate = parseFloat(speed);
highlightSpeedButton(speed);
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playback_speed: speed })
}).catch(() => {});
}
// Lesson notes: debounced autosave on typing, plus an immediate
// save on blur so navigating away doesn't lose the last edit.
const noteField = document.getElementById('lesson-note');
const noteStatus = document.getElementById('note-status');
let noteSaveTimeout = null;
function saveNote() {
if (!noteField) return;
fetch('/api/lesson-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: '{{ lesson_path }}', note: noteField.value })
})
.then(() => {
if (noteStatus) {
noteStatus.textContent = 'Saved';
setTimeout(() => { noteStatus.textContent = ''; }, 2000);
}
})
.catch(() => {
if (noteStatus) noteStatus.textContent = 'Could not save note';
});
}
if (noteField) {
noteField.addEventListener('input', function() {
clearTimeout(noteSaveTimeout);
noteSaveTimeout = setTimeout(saveNote, 1000);
});
noteField.addEventListener('blur', function() {
clearTimeout(noteSaveTimeout);
saveNote();
});
}
if (activeMedia) {
let lastSaveTime = 0;
+13
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - OfflineU</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
:root {
--bg-primary: #1a1a1a;
@@ -453,6 +456,16 @@
</label>
<button class="btn btn-secondary" onclick="resetVideoSize()">Reset size</button>
</div>
<div class="setting-row">
<label for="playback_speed">Default playback speed
<span class="setting-desc">Applied when a lesson loads; still adjustable per-lesson</span>
</label>
<select id="playback_speed" data-setting="playback_speed">
{% for speed in ['0.75', '1', '1.25', '1.5', '1.75', '2'] %}
<option value="{{ speed }}" {% if settings.playback_speed == speed %}selected{% endif %}>{{ speed }}x</option>
{% endfor %}
</select>
</div>
</div>
<div class="actions">