Files
offlineu/templates/course_dashboard.html
T
rmsitzandClaude Sonnet 5 0454834e62 Add Notes Hub, transcript search, stale nudges, study guide export, Next Up queue, activity heatmap
Six more dashboard features, all built on the per-course progress-file
scanning infrastructure from earlier sessions:

- Notes Hub (/notes): every lesson note across the library in one place,
  newest first, reusing the existing /recent/open cross-course jump route.
- Transcript search: opt-in checkbox on the Library search box, searching
  inside .srt/.vtt files. Kept cheap by doing a raw substring match as the
  filter step and only extracting a snippet for files that actually match.
- Stale course nudges ("Pick Back Up"): courses with progress that haven't
  been touched in 14+ days.
- Study guide export: compiles a course's notes into one downloadable
  markdown file, section/lesson structure preserved.
- Next Up queue: an ordered, persisted "what to tackle next" list with
  up/down reordering and a queue button on every course row.
- Activity heatmap: a 90-day contribution-style calendar in the Library
  Stats card.

_scan_library_activity() now does one walk over every course's progress
file per dashboard load; library stats, stale courses, and the heatmap all
derive from that single scan instead of three independent ones.

Also refactored the duplicated lesson-progress-key lookup (used in three
places now) into _resolve_lesson_progress_key(), and flagged a pre-existing
bug found along the way (not fixed here, kept out of scope): subtitle files
never actually attach to a Lesson object, so the video player's caption
track has never had anything to render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:04:01 -04:00

1381 lines
48 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;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 12px;
margin-top: 12px;
}
.stats-tile {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 14px 10px;
text-align: center;
}
.stats-tile-value {
font-size: 1.5em;
font-weight: 700;
color: var(--accent);
}
.stats-tile-label {
font-size: 0.8em;
color: var(--text-muted);
margin-top: 2px;
}
.heatmap-scroll {
overflow-x: auto;
margin-top: 15px;
padding-bottom: 4px;
}
.heatmap-grid {
display: flex;
gap: 3px;
width: max-content;
}
.heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.heatmap-day {
width: 11px;
height: 11px;
border-radius: 2px;
background: var(--accent);
opacity: 0.12;
}
.heatmap-day.level-1 { opacity: 0.4; }
.heatmap-day.level-2 { opacity: 0.7; }
.heatmap-day.level-3 { opacity: 1; }
.btn-sm {
padding: 5px 10px;
font-size: 0.8em;
}
.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;
}
.transcript-search-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 0.85em;
color: var(--text-muted);
cursor: pointer;
}
.transcript-result {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 10px 15px;
margin-bottom: 5px;
border-left: 3px solid var(--accent);
cursor: pointer;
}
.transcript-result:hover {
background: var(--bg-tertiary-hover);
}
.transcript-result-title {
font-weight: 500;
}
.transcript-result-course {
font-size: 0.8em;
color: var(--text-muted);
}
.transcript-result-snippet {
font-size: 0.85em;
color: var(--text-muted);
margin-top: 4px;
font-style: italic;
}
.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="text-align: right;">
<div style="color: #007acc; font-size: 1.2em;">
{{ "%.1f"|format(stats.completion_percentage) }}%
</div>
{% if stats.remaining_display %}
<div style="color: var(--text-muted); font-size: 0.85em;">
~{{ stats.remaining_display }} remaining
</div>
{% endif %}
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ stats.completion_percentage }}%;"></div>
</div>
{% if stats.total_lessons %}
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
<button class="btn btn-secondary" onclick="markCourseWatched()">
Mark all as completed
</button>
<button class="btn btn-secondary" id="queue-toggle-btn" onclick="toggleQueued()">
{{ '📌 Remove from Next Up' if is_queued else '📌 Add to Next Up' }}
</button>
{% if has_note %}
<a class="btn btn-secondary" href="/course/study-guide">Download Study Guide</a>
{% endif %}
</div>
{% 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 %}
{% macro render_course_row(item, meta_text) %}
<div class="lesson-item" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
<div class="lesson-title">
{% if item.has_thumbnail %}
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
onerror="this.replaceWith(courseFallbackIcon('🎓'))">
{% else %}
<span class="lesson-icon">🎓</span>
{% endif %}
<span class="lesson-name">{{ item.name }}</span>
</div>
<span class="lesson-meta">{{ meta_text }}</span>
</div>
{% endmacro %}
{% if library_stats and library_stats.total_courses %}
<div class="container">
<div class="card">
<h2>📊 Library Stats</h2>
<div class="stats-grid">
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
<div class="stats-tile-label">Courses</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
<div class="stats-tile-label">Lessons completed</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
<div class="stats-tile-label">Watched</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
<div class="stats-tile-label">Day streak</div>
</div>
</div>
{% if activity_heatmap %}
<div class="heatmap-scroll">
<div class="heatmap-grid">
{% for week in activity_heatmap|batch(7) %}
<div class="heatmap-week">
{% for day in week %}
{% set level = 0 if day.count == 0 else (1 if day.count == 1 else (2 if day.count == 2 else 3)) %}
<div class="heatmap-day level-{{ level }}"
title="{{ day.date }}: {{ day.count }} lesson{{ '' if day.count == 1 else 's' }}"></div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if next_up %}
<div class="container">
<div class="card" id="next-up-card">
<h2>📌 Next Up</h2>
<div id="next-up-list" style="margin-top: 12px;">
{% for item in next_up %}
<div class="lesson-item" data-path="{{ item.path }}">
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
{% if item.has_thumbnail %}
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
onerror="this.replaceWith(courseFallbackIcon('🎓'))">
{% else %}
<span class="lesson-icon">🎓</span>
{% endif %}
<span class="lesson-name">{{ item.name }}</span>
</div>
<div class="lesson-meta">
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); moveNextUp(this, -1)" title="Move up"></button>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); moveNextUp(this, 1)" title="Move down"></button>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); removeNextUp(this)" title="Remove"></button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if stale_courses %}
<div class="container">
<div class="card">
<h2>⏰ Pick Back Up</h2>
<div style="margin-top: 12px;">
{% for item in stale_courses %}
{{ render_course_row(item, 'Last touched ' + item.last_touched_display) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if recently_added %}
<div class="container">
<div class="card">
<h2>🆕 Recently Added</h2>
<div style="margin-top: 12px;">
{% for item in recently_added %}
{{ render_course_row(item, item.added_display) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% 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>
<label class="transcript-search-toggle">
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
Also search transcripts
</label>
<p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
<div id="transcript-results"></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="/notes">📝 Notes</a>
<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 transcriptResults = document.getElementById('transcript-results');
if (transcriptResults) transcriptResults.innerHTML = '';
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>
<div class="lesson-meta">
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">📌</button>
</div>
</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');
const transcriptContainer = document.getElementById('transcript-results');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>';
if (transcriptContainer) transcriptContainer.innerHTML = '';
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>';
});
const searchTranscripts = document.getElementById('search-transcripts-toggle');
if (searchTranscripts && searchTranscripts.checked) {
runTranscriptSearch(query);
}
}
function runTranscriptSearch(query) {
const transcriptContainer = document.getElementById('transcript-results');
if (!transcriptContainer) return;
transcriptContainer.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching transcripts...</p>';
fetch(`/library/search-transcripts?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => {
const results = data.results || [];
if (!results.length) {
transcriptContainer.innerHTML = '';
return;
}
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
results.map(r => `
<div class="transcript-result" onclick="window.location.href='/recent/open?course_path=${encodeURIComponent(r.course_path)}&lesson_path=${encodeURIComponent(r.lesson_path)}'">
<div class="transcript-result-title">${r.lesson_title}</div>
<div class="transcript-result-course">${r.course_name}</div>
<div class="transcript-result-snippet">${r.snippet}</div>
</div>
`).join('');
})
.catch(() => {
transcriptContainer.innerHTML = '';
});
}
function queueCourse(path, btnEl) {
fetch('/api/next-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, queued: true })
})
.then(r => r.json())
.then(data => {
if (data.success && btnEl) {
btnEl.textContent = '✓';
btnEl.disabled = true;
}
})
.catch(() => {});
}
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);
}
});
}
// ---- Next Up queue (dashboard card) ----
function saveNextUpOrder() {
const paths = Array.from(document.querySelectorAll('#next-up-list .lesson-item')).map(row => row.dataset.path);
fetch('/api/next-up/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: paths })
}).catch(() => {});
}
function moveNextUp(btnEl, direction) {
const row = btnEl.closest('.lesson-item');
const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling;
if (!sibling) return;
if (direction < 0) {
row.parentNode.insertBefore(row, sibling);
} else {
row.parentNode.insertBefore(sibling, row);
}
saveNextUpOrder();
}
function removeNextUp(btnEl) {
const row = btnEl.closest('.lesson-item');
const path = row.dataset.path;
fetch('/api/next-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, queued: false })
})
.then(r => r.json())
.then(data => {
if (data.success) {
row.remove();
const list = document.getElementById('next-up-list');
if (list && list.children.length === 0) {
document.getElementById('next-up-card').closest('.container').remove();
}
}
})
.catch(() => {});
}
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'));
}
});
}
{% if course %}
function toggleQueued() {
const btn = document.getElementById('queue-toggle-btn');
const currentlyQueued = btn.textContent.includes('Remove');
fetch('/api/next-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: {{ course.path|tojson }}, queued: !currentlyQueued })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.textContent = currentlyQueued ? '📌 Add to Next Up' : '📌 Remove from Next Up';
}
})
.catch(() => {});
}
{% endif %}
document.addEventListener('DOMContentLoaded', loadLibrary);
</script>
<script src="/static/theme.js"></script>
</body>
</html>