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:
+207
-43
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user