Files
offlineu/templates/course_dashboard.html
T
rmsitzandClaude Sonnet 5 38963b1d55 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>
2026-08-22 09:06:44 -04:00

1037 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<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;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3d3d3d;
--bg-tertiary-hover: #404040;
--text-primary: #e0e0e0;
--text-muted: #999;
--border-color: #555;
--accent: #007acc;
--accent-hover: #005a9e;
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
--bg-secondary: #ffffff;
--bg-tertiary: #eeeeee;
--bg-tertiary-hover: #e2e2e2;
--text-primary: #222222;
--text-muted: #666666;
--border-color: #cccccc;
}
[data-density="compact"] .container { padding: 10px; }
[data-density="compact"] .card,
[data-density="compact"] .lesson-item,
[data-density="compact"] .tree-header { padding: 10px; margin-bottom: 8px; }
[data-card-style="elevated"] .card,
[data-card-style="elevated"] .lesson-item,
[data-card-style="elevated"] .tree-header {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
[data-card-style="bordered"] .card,
[data-card-style="bordered"] .lesson-item,
[data-card-style="bordered"] .tree-header {
border: 1px solid var(--border-color);
}
html, body {
height: 100%;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.page-content {
flex: 1;
}
.container {
max-width: var(--container-max-width);
width: 95%;
margin: 0 auto;
padding: 20px;
}
.app-header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
padding: 28px 0;
margin-bottom: 30px;
border-bottom: 3px solid var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}
.app-header .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.brand-icon {
font-size: 2.2em;
line-height: 1;
}
.brand h1 {
color: var(--accent);
font-size: 1.9em;
letter-spacing: 0.5px;
}
.brand .tagline {
color: var(--text-muted);
font-size: 0.85em;
margin-top: 2px;
}
.header-course-badge {
background: var(--bg-primary);
border: 1px solid var(--accent);
color: var(--text-primary);
padding: 8px 16px;
border-radius: var(--radius);
font-size: 0.9em;
max-width: 340px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
margin-top: 40px;
padding: 18px 0;
}
.app-footer .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links {
display: flex;
gap: 20px;
}
.footer-links a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.footer-links a:hover {
color: var(--accent);
}
.nav {
background: var(--bg-tertiary);
padding: 10px 0;
margin-bottom: 20px;
}
.nav .container {
display: flex;
align-items: center;
gap: 15px;
}
.nav a {
color: var(--text-primary);
text-decoration: none;
padding: 10px 15px;
border-radius: var(--radius);
transition: background 0.3s;
}
.nav a:hover {
background: var(--accent);
}
.card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
border-left: 4px solid var(--accent);
}
.btn {
background: var(--accent);
color: white;
border: none;
padding: 12px 24px;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s;
font-size: 14px;
}
.btn:hover {
background: var(--accent-hover);
}
.btn:disabled {
background: #666;
cursor: not-allowed;
}
.btn-secondary {
background: #666;
}
.btn-secondary:hover {
background: #555;
}
.progress-bar {
background: #444;
border-radius: 10px;
overflow: hidden;
height: 20px;
margin: 10px 0;
}
.progress-fill {
background: var(--accent);
height: 100%;
transition: width 0.3s ease;
border-radius: 10px;
}
.library-breadcrumb {
color: var(--text-muted);
font-size: 13px;
margin-top: 6px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.library-breadcrumb .crumb {
cursor: pointer;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-breadcrumb .crumb:not(.current):hover {
color: var(--accent);
text-decoration: underline;
}
.library-breadcrumb .crumb.current {
color: var(--text-primary);
font-weight: 600;
cursor: default;
}
.library-breadcrumb .crumb-sep {
opacity: 0.5;
}
/* Dynamic Directory Tree Styles */
.tree-container {
margin-top: 20px;
}
.tree-item {
margin-bottom: 5px;
}
.tree-header {
background: var(--bg-tertiary);
padding: 12px 15px;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.3s;
border-left: 3px solid #666;
}
.tree-header:hover {
background: var(--bg-tertiary-hover);
}
.tree-header.directory {
border-left-color: var(--accent);
}
.tree-header.lesson {
border-left-color: #28a745;
}
.tree-header.completed {
border-left-color: #28a745;
background: #1e3d1e;
}
.tree-header.completed:hover {
background: #2d5a2d;
}
.tree-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.tree-icon {
font-size: 1.2em;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.tree-name {
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-stats {
font-size: 0.85em;
color: var(--text-muted);
margin-left: 10px;
flex-shrink: 0;
white-space: nowrap;
}
.tree-toggle {
background: none;
border: none;
color: var(--text-primary);
font-size: 1.2em;
cursor: pointer;
padding: 5px;
border-radius: 3px;
transition: background 0.3s;
}
.tree-toggle:hover {
background: #555;
}
.tree-content {
margin-left: 25px;
margin-top: 5px;
display: none;
}
.tree-content.expanded {
display: block;
}
.lesson-item {
background: var(--bg-tertiary);
padding: 10px 15px;
border-radius: var(--radius);
margin-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
cursor: pointer;
border-left: 3px solid #666;
position: relative;
overflow: hidden;
}
.lesson-item:hover {
border-left-color: var(--accent);
background: var(--bg-tertiary-hover);
transform: translateX(5px);
}
.lesson-item.completed {
border-left-color: #28a745;
background: #1e3d1e;
}
.lesson-item.completed:hover {
background: #2d5a2d;
}
.lesson-item.in-progress {
border-left-color: var(--accent);
}
.lesson-progress-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.08);
}
.lesson-progress-fill {
height: 100%;
background: var(--accent);
}
.lesson-item.completed .lesson-progress-fill {
background: #28a745;
}
.watched-badge {
font-size: 0.75em;
background: var(--bg-primary);
color: var(--accent);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid var(--accent);
white-space: nowrap;
}
.lesson-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.lesson-icon {
font-size: 1.1em;
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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-meta {
font-size: 0.85em;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
min-width: 0;
max-width: 45%;
}
.lesson-meta-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.lesson-type {
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
}
.lesson-type.video { background: #e74c3c; color: white; }
.lesson-type.audio { background: #9b59b6; color: white; }
.lesson-type.text { background: #34495e; color: white; }
.lesson-type.quiz { background: #f39c12; color: white; }
.lesson-type.mixed { background: #16a085; color: white; }
.status-icon {
font-size: 1.2em;
font-weight: bold;
}
.status-icon.completed {
color: #28a745;
}
.status-icon.pending {
color: #666;
}
.last-accessed {
background: #2d5a2d;
border-left-color: #28a745;
margin-top: 10px;
padding: 10px;
border-radius: 5px;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header h1 {
font-size: 2em;
}
.nav .container {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.tree-content {
margin-left: 15px;
}
}
@media (max-width: 600px) {
.lesson-item {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.lesson-title,
.lesson-meta {
width: 100%;
max-width: 100%;
}
.lesson-name,
.lesson-meta-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
}
</style>
</head>
<body>
<div class="app-header">
<div class="container">
<a href="/reset_course" class="brand">
<span class="brand-icon">📚</span>
<div>
<h1>OfflineU</h1>
<p class="tagline">Your self-hosted course library</p>
</div>
</a>
{% if course %}
<div class="header-course-badge">{{ course.name }}</div>
{% endif %}
</div>
</div>
<div class="page-content">
{% if course %}
<div class="nav">
<div class="container">
<a href="/reset_course">← Select Different Course</a>
<a href="#stats">Progress</a>
<span style="color: #666; margin-left: auto;">
{{ stats.total_lessons }} lessons
</span>
</div>
</div>
<div class="container">
<div class="card">
<h2>{{ course.name }}</h2>
<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
</div>
<div style="color: #007acc; font-size: 1.2em;">
{{ "%.1f"|format(stats.completion_percentage) }}%
</div>
</div>
<div class="progress-bar">
<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 }}
<a href="/lesson/{{ stats.last_accessed_path }}" class="btn" style="margin-left: 10px;">
Continue
</a>
</div>
{% endif %}
</div>
<div class="tree-container">
{% macro render_tree_node(node, depth=0) %}
<div class="tree-item">
<div class="tree-header directory" onclick="toggleTree(this)">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">{{ node.name }}</span>
<span class="tree-stats">
{{ (node.children|length + node.lessons|length) }} items
</span>
</div>
{% if node.children or node.lessons %}
<button class="tree-toggle"></button>
{% endif %}
</div>
{% if node.children or node.lessons %}
<div class="tree-content">
{% for child_name, child_node in node.children.items() %}
{{ render_tree_node(child_node, depth + 1) }}
{% endfor %}
{% for lesson in node.lessons %}
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
{% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
<div class="lesson-title">
<span class="lesson-icon">
{% if lesson.lesson_type == 'video' %}🎥
{% elif lesson.lesson_type == 'audio' %}🎵
{% elif lesson.lesson_type == 'quiz' %}📝
{% elif lesson.lesson_type == 'mixed' %}📦
{% else %}📄{% endif %}
</span>
<span class="lesson-name">{{ lesson.title }}</span>
</div>
<div class="lesson-meta">
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
{% if lesson.completed %}
<span class="status-icon completed"></span>
{% elif percent_watched %}
<span class="watched-badge">{{ percent_watched }}% watched</span>
{% else %}
<span class="status-icon pending"></span>
{% endif %}
</div>
{% if percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{{ render_tree_node(course.root_node) }}
</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 %}
{{ render_view_row(view) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
<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>
</div>
{% endif %}
</div>
<footer class="app-footer">
<div class="container">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/help">❓ Help</a>
<a href="/settings">⚙ Settings</a>
</div>
</div>
</footer>
<script>
function toggleTree(element) {
console.log('Toggle clicked:', element);
// Find the content div that comes after this header
const content = element.nextElementSibling;
const toggle = element.querySelector('.tree-toggle');
console.log('Content element:', content);
console.log('Toggle element:', toggle);
if (content && content.classList.contains('tree-content')) {
const isExpanded = content.classList.contains('expanded');
content.classList.toggle('expanded');
if (toggle) {
toggle.textContent = content.classList.contains('expanded') ? '▼' : '▶';
}
console.log('Toggled tree, expanded:', content.classList.contains('expanded'));
} else {
console.log('No content element found or not tree-content');
}
}
// Single-level drill-down: the Library browser shows one folder's
// contents at a time (replacing the list, not nesting under it) and
// tracks how we got here in libraryTrail so a breadcrumb can jump
// 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');
if (!libraryCard) return;
libraryTrail = [];
fetchLibraryLevel(null);
}
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';
fetch(url)
.then(r => r.json())
.then(data => {
renderLibraryBreadcrumb();
renderLibraryLevel(data);
})
.catch(() => {
container.innerHTML =
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
});
}
function renderLibraryBreadcrumb() {
const bar = document.getElementById('library-path-bar');
const atRoot = libraryTrail.length === 0;
let html = `<span class="crumb ${atRoot ? 'current' : ''}"` +
(atRoot ? '' : ` onclick="goToLibraryCrumb(-1)"`) + `>Library</span>`;
libraryTrail.forEach((crumb, i) => {
const isLast = i === libraryTrail.length - 1;
html += ` <span class="crumb-sep">/</span> ` +
`<span class="crumb ${isLast ? 'current' : ''}"` +
(isLast ? '' : ` onclick="goToLibraryCrumb(${i})"`) +
`>${crumb.name}</span>`;
});
bar.innerHTML = html;
}
function goToLibraryCrumb(index) {
if (index < 0) {
libraryTrail = [];
fetchLibraryLevel(null);
return;
}
const target = libraryTrail[index];
libraryTrail = libraryTrail.slice(0, index + 1);
fetchLibraryLevel(target.path);
}
function renderLibraryLevel(data) {
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.');
container.innerHTML = `<p style="color:#999; padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
return;
}
lastLibraryItems = data.items;
renderItemRows(sortLibraryItems(data.items));
}
function enterLibraryDir(path, name) {
libraryTrail.push({ name: name, path: path });
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',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({course_path: path})
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
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>
<script src="/static/theme.js"></script>
</body>
</html>