Files
offlineu/templates/course_dashboard.html
T
rmsitzandClaude Sonnet 5 8f7808ab46 Show course runtime on library cards via ffprobe
Add ffmpeg to the Docker image so ffprobe can read each video/audio
file's duration server-side. Results are cached to a small persistent
per-course JSON file keyed by (relative path, size, mtime), so a file
only gets probed once - a container restart or the library scan's
in-memory cache expiring never re-runs ffprobe on unchanged files.

Course cards in the library browser (grid, list, and search) now show
total runtime alongside the media-file count, so you know the time
commitment before opening a course. Durations under a minute are
suppressed rather than showing a noisy "0m".

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

1946 lines
75 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.
{% import '_icons.html' as icons %}
<!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;
--success: #28a745;
--error: #ff6b6b;
}
[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;
padding-bottom: 70px;
}
.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-mark {
flex-shrink: 0;
display: block;
}
.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;
}
.course-name-heading {
/* course.name is the raw folder name, unlike lesson titles
(cleaned up via _clean_lesson_name) - real course folders are
commonly dot-separated with no spaces at all, so without this
the browser has no natural break point and widens the whole
page on mobile instead of wrapping. */
overflow-wrap: break-word;
word-break: break-word;
}
.toast-container {
position: fixed;
left: 0;
right: 0;
bottom: calc(60px + env(safe-area-inset-bottom, 0px));
z-index: 9500;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0 16px;
pointer-events: none;
}
.toast {
max-width: 480px;
width: 100%;
background: var(--bg-tertiary);
color: var(--text-primary);
border-left: 4px solid var(--error);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 0.9em;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
}
.toast.toast-visible {
opacity: 1;
transform: translateY(0);
}
.bottom-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
display: flex;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.72em;
transition: color 0.2s;
}
.tab-item .tab-icon {
font-size: 1.3em;
}
.tab-item:hover,
.tab-item.active {
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;
}
#library-card {
/* the one card that's a primary action (browse/search/load a
course) rather than a passive info/list card - every other
card getting the same accent stripe made nothing stand out */
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;
}
.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 {
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;
transition: transform 0.15s ease;
}
.heatmap-day.level-1 { opacity: 0.4; }
.heatmap-day.level-2 { opacity: 0.7; }
.heatmap-day.level-3 { opacity: 1; }
.heatmap-day:hover {
transform: scale(1.4);
}
.btn-sm {
padding: 5px 10px;
font-size: 0.8em;
}
.course-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.skeleton-row {
height: 48px;
border-radius: var(--radius);
margin-bottom: 5px;
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-tertiary-hover) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.collapsible-header {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.collapsible-header::after {
content: '▾';
font-size: 0.8em;
color: var(--text-muted);
transition: transform 0.2s ease;
margin-left: 10px;
}
.collapsible-header.collapsed::after {
transform: rotate(-90deg);
}
.card-header-label {
display: flex;
align-items: center;
gap: 8px;
}
.icon {
display: inline-block;
vertical-align: -3px;
flex-shrink: 0;
}
.btn .icon, .tab-icon .icon {
vertical-align: -2px;
margin-right: 3px;
}
.card-body {
overflow: hidden;
max-height: 3000px;
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.3s ease;
margin-top: 12px;
opacity: 1;
}
.card-body.collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.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: var(--success);
}
.tree-header.completed {
border-left-color: var(--success);
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: var(--success);
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: var(--success);
}
.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;
}
.library-view-toggle {
display: flex;
flex-shrink: 0;
}
.view-toggle-btn {
background: var(--bg-tertiary);
color: var(--text-muted);
border: 1px solid var(--border-color);
padding: 8px 12px;
margin: 0;
font-size: 14px;
cursor: pointer;
}
.view-toggle-btn:first-child {
border-radius: var(--radius) 0 0 var(--radius);
border-right: none;
}
.view-toggle-btn:last-child {
border-radius: 0 var(--radius) var(--radius) 0;
}
.view-toggle-btn.active {
background: var(--accent);
color: white;
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 14px;
}
.library-grid-card {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 10px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
flex-direction: column;
gap: 8px;
}
.library-grid-card:hover {
background: var(--bg-tertiary-hover);
transform: translateY(-2px);
}
.grid-card-thumb-wrap {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.4em;
}
.grid-card-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.grid-card-initial {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.6em;
font-weight: 700;
font-family: var(--font-family);
}
.grid-card-name {
font-size: 0.9em;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.grid-card-meta {
font-size: 0.75em;
color: var(--text-muted);
}
.grid-card-progress-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.grid-card-progress-fill {
height: 100%;
background: var(--accent);
}
.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;
transition: all 0.3s;
}
.transcript-result:hover {
background: var(--bg-tertiary-hover);
transform: translateX(5px);
}
.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: var(--success);
}
.status-icon.pending {
color: var(--text-muted);
}
.last-accessed {
background: #2d5a2d;
border-left-color: var(--success);
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">
<svg class="brand-mark" viewBox="0 0 64 64" width="44" height="44" aria-hidden="true">
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
</svg>
<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: var(--text-muted); margin-left: auto;">
{{ stats.total_lessons }} lessons
</span>
</div>
</div>
<div class="container">
<div class="card">
<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>
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
</div>
<div style="text-align: right;">
<div style="color: var(--accent); 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 class="course-actions">
<button class="btn btn-secondary btn-sm" onclick="markCourseWatched()">
{{ icons.icon('check', 14) }} Mark all as completed
</button>
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
{{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }}
</button>
{% if has_note %}
<a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} 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) %}
{% set stats = section_stats(node) %}
<div class="tree-item">
<div class="tree-header directory" onclick="toggleTree(this)">
<div class="tree-title">
<span class="tree-icon">{{ icons.icon('folder', 16) }}</span>
<span class="tree-name">{{ node.name }}</span>
<span class="tree-stats">
{% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %}
</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' %}{{ icons.icon('video', 16) }}
{% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 16) }}
{% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }}
{% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }}
{% else %}{{ icons.icon('file-text', 16) }}{% 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">{{ icons.icon('check', 14) }}</span>
{% elif percent_watched %}
<span class="watched-badge">{{ percent_watched }}% watched</span>
{% else %}
<span class="status-icon pending">{{ icons.icon('circle', 14) }}</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('play'))">
{% else %}
<span class="lesson-icon">{{ icons.icon('play', 16) }}</span>
{% endif %}
<span class="lesson-name">{{ view.lesson_title }}</span>
</div>
<div class="lesson-meta">
{% if view.completed %}
<span class="status-icon completed">{{ icons.icon('check', 14) }}</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('graduation-cap'))">
{% else %}
<span class="lesson-icon">{{ icons.icon('graduation-cap', 16) }}</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">
<a class="btn surprise-me-btn" href="/random-pick">{{ icons.icon('dice', 16) }} Surprise Me</a>
</div>
{% endif %}
<div class="container">
<div class="card" id="library-card">
<h2><span class="card-header-label">{{ icons.icon('grid') }}Your Courses</span></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 class="library-view-toggle">
<button type="button" class="view-toggle-btn" id="library-view-list-btn"
onclick="setLibraryViewMode('list')" title="List view">{{ icons.icon('list', 16) }}</button>
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
onclick="setLibraryViewMode('grid')" title="Grid view">{{ icons.icon('grid', 16) }}</button>
</div>
<button type="button" class="view-toggle-btn" id="library-select-btn"
onclick="toggleSelectionMode()" title="Select multiple">{{ icons.icon('checkbox', 16) }}</button>
</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>
<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()">{{ icons.icon('ban', 14) }} Hide Selected</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkQueueSelected()">{{ icons.icon('pin', 14) }} 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>
<div id="library-groups" style="margin-top: 15px;"></div>
<div id="transcript-results"></div>
</div>
</div>
{% if library_stats and library_stats.total_courses %}
<div class="container">
<div class="card">
<h2 class="collapsible-header" data-card-id="library-stats"><span class="card-header-label">{{ icons.icon('bar-chart') }}Library Stats</span></h2>
<div class="card-body">
<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>
</div>
{% endif %}
{% if next_up %}
<div class="container">
<div class="card" id="next-up-card">
<h2 class="collapsible-header" data-card-id="next-up"><span class="card-header-label">{{ icons.icon('list-dots') }}Next Up</span></h2>
<div class="card-body" id="next-up-list">
{% 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('graduation-cap'))">
{% else %}
<span class="lesson-icon">{{ icons.icon('graduation-cap', 16) }}</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">{{ icons.icon('x', 14) }}</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if stale_courses %}
<div class="container">
<div class="card">
<h2 class="collapsible-header" data-card-id="pick-back-up"><span class="card-header-label">{{ icons.icon('history') }}Pick Back Up</span></h2>
<div class="card-body">
{% 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 class="collapsible-header" data-card-id="recently-added"><span class="card-header-label">{{ icons.icon('plus-circle') }}Recently Added</span></h2>
<div class="card-body">
{% for item in recently_added %}
{{ render_course_row(item, item.added_display) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if recent_views %}
<div class="container">
<div class="card">
<h2 class="collapsible-header" data-card-id="recently-viewed"><span class="card-header-label">{{ icons.icon('play-circle') }}Recently Viewed</span></h2>
<div class="card-body">
{% for view in recent_views %}
{{ render_view_row(view) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
<nav class="bottom-tab-bar">
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
</a>
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
</a>
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
</a>
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
</a>
</nav>
<div id="toast-container" class="toast-container"></div>
<script>
// Client-rendered mirror of a few templates/_icons.html entries -
// JS template literals can't call the Jinja macro, so the handful
// of icons built dynamically (thumbnail-load fallbacks, toggled
// button states, JS-rendered library rows) are kept here in sync
// with the same path data by hand.
const ICON_SVGS = {
play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>',
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
};
function showToast(message) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('toast-visible'));
setTimeout(() => {
toast.classList.remove('toast-visible');
setTimeout(() => toast.remove(), 250);
}, 3500);
}
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;
// If we landed here via popstate/reload from a course view
// that got unloaded (see the popstate listener below), the
// current history entry may already carry the folder trail
// we were browsing before the course was loaded - restore it
// instead of always resetting to the library root.
const restored = history.state && history.state.libraryTrail;
libraryTrail = restored ? history.state.libraryTrail : [];
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
if (!restored) {
history.replaceState({ libraryTrail: [], path: null }, '');
}
}
// Drilling into a folder only ever swapped #library-groups's
// contents - no history entry, no URL change - so the back
// button/gesture had nothing of ours to step back through once
// you were a few folders deep, and fell straight through to
// exiting the app instead of going up one level. Pushing a state
// per level (and restoring it on popstate, below) makes back
// step back up the library trail one folder at a time instead.
function pushLibraryState() {
history.pushState({ libraryTrail: libraryTrail.slice(), path: libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null }, '');
}
window.addEventListener('popstate', function(event) {
{% if course %}
// The course view is a distinct server-side render tied to
// current_course, not something this page can patch back to
// client-side - unload the course and reload so back actually
// returns to the library/dashboard instead of doing nothing
// (which would otherwise leave a stale course view on screen
// while quietly burning through history entries until there's
// nowhere left to go but out of the app).
fetch('/reset_course').then(function() { location.reload(); });
return;
{% endif %}
const state = event.state;
if (!state || !document.getElementById('library-card')) return;
libraryTrail = state.libraryTrail || [];
fetchLibraryLevel(state.path || null);
});
function fetchLibraryLevel(path) {
searchActive = false;
clearSelection(); // selection is scoped to the current folder view
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 = skeletonRowsHtml(3);
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:var(--error);">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);
pushLibraryState();
return;
}
const target = libraryTrail[index];
libraryTrail = libraryTrail.slice(0, index + 1);
fetchLibraryLevel(target.path);
pushLibraryState();
}
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:var(--text-muted); 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);
pushLibraryState();
}
// A few shimmering placeholder rows for in-flight fetches, instead
// of bare "Loading..." text.
function skeletonRowsHtml(count) {
return Array(count).fill('<div class="skeleton-row"></div>').join('');
}
// 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(iconName) {
const span = document.createElement('span');
span.className = 'lesson-icon';
span.innerHTML = ICON_SVGS[iconName] || '';
return span;
}
// Deterministic color+initial per course name (same idea as
// Slack/Google Photos default avatars) - almost none of this
// library's courses have real cover art, so without this every
// grid card without a thumbnail looked identical, defeating the
// point of browsing by appearance.
function courseColorHue(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
}
function courseInitial(name) {
const match = name.match(/[a-zA-Z0-9]/);
return match ? match[0].toUpperCase() : '?';
}
function courseInitialHtml(name) {
return `<div class="grid-card-initial" style="background: hsl(${courseColorHue(name)}, 40%, 32%);">${courseInitial(name)}</div>`;
}
function gridFallbackInitial(name) {
const div = document.createElement('div');
div.className = 'grid-card-initial';
div.style.background = `hsl(${courseColorHue(name)}, 40%, 32%)`;
div.textContent = courseInitial(name);
return div;
}
// For embedding a name in an HTML attribute (e.g. title="...") -
// grid-card names are clamped to 2 lines and clipped, so the title
// attribute is what lets a hover reveal the full name.
function escapeAttr(text) {
return text.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
// ---- Library list/grid view toggle (client-only, remembered per device) ----
let libraryViewMode = localStorage.getItem('offlineu-library-view-mode') || 'list';
function updateViewModeButtons() {
const listBtn = document.getElementById('library-view-list-btn');
const gridBtn = document.getElementById('library-view-grid-btn');
if (listBtn) listBtn.classList.toggle('active', libraryViewMode === 'list');
if (gridBtn) gridBtn.classList.toggle('active', libraryViewMode === 'grid');
}
function setLibraryViewMode(mode) {
libraryViewMode = mode;
localStorage.setItem('offlineu-library-view-mode', mode);
updateViewModeButtons();
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) {
showToast('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) {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const iconHtml = item.has_thumbnail
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackInitial('${safeName}'))">`
: courseInitialHtml(item.name);
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
const pct = item.completion_percentage;
const progressBar = pct
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
: '';
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
if (item.duration_display) metaBits.push(item.duration_display);
if (pct) metaBits.push(`${pct}% done`);
return `
<div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}</div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
${progressBar}
</div>
`;
}
function directoryCardHtml(item) {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
return `
<div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap"><svg class="icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg></div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
</div>
`;
}
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('graduation-cap'))">`
: `<span class="lesson-icon">${ICON_SVGS['graduation-cap']}</span>`;
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
const pct = item.completion_percentage;
const progressBadge = pct ? `<span class="watched-badge">${pct}% done</span>` : '';
const progressBar = pct
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
: '';
return `
<div class="lesson-item" onclick="${clickHandler}">
<div class="lesson-title">
${selectableAttrs(item)}
${iconHtml}
<span class="lesson-name">${item.name}</span>
</div>
<div class="lesson-meta">
${progressBadge}
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
</div>
${progressBar}
</div>
`;
}
function directoryRowHtml(item) {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
return `
<div class="tree-header directory" onclick="${clickHandler}">
<div class="tree-title">
${selectableAttrs(item)}
<span class="tree-icon">${ICON_SVGS.folder}</span>
<span class="tree-name">${item.name}</span>
</div>
<span class="tree-toggle"></span>
</div>
`;
}
function renderItemRows(items) {
const container = document.getElementById('library-groups');
container.classList.toggle('library-grid', libraryViewMode === 'grid');
container.innerHTML = libraryViewMode === 'grid'
? items.map(item => item.type === 'course' ? courseCardHtml(item) : directoryCardHtml(item)).join('')
: 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;
clearSelection(); // selection is scoped to the current folder view
const container = document.getElementById('library-groups');
const transcriptContainer = document.getElementById('transcript-results');
container.innerHTML = skeletonRowsHtml(2);
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:var(--text-muted); padding: 6px 0;">No courses match "${query}".</p>`;
return;
}
renderItemRows(sortLibraryItems(data.results));
})
.catch(() => {
container.innerHTML = '<p style="color:var(--error);">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 = skeletonRowsHtml(2);
fetch(`/library/search-transcripts?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => {
const results = data.results || [];
if (!results.length) {
transcriptContainer.innerHTML = '<p style="color:var(--text-muted); padding: 6px 0;">No matching transcripts.</p>';
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 = '<p style="color:var(--error);">Transcript search failed.</p>';
});
}
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) {
// A plain reload doesn't touch history, so without this
// push there'd be nothing for back to land on once the
// course view replaces this page - see the popstate
// listener above for how landing back here unloads it.
history.pushState({ courseLoaded: true }, '');
location.reload();
} else {
showToast('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 {
showToast('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.innerHTML = ICON_SVGS.pin + ' ' + (currentlyQueued ? 'Add to Next Up' : 'Remove from Next Up');
}
})
.catch(() => {});
}
{% endif %}
// ---- Collapsible dashboard cards (client-only, remembered per device) ----
function initCollapsibleCards() {
document.querySelectorAll('.collapsible-header').forEach(function(header) {
const cardId = header.dataset.cardId;
const body = header.nextElementSibling;
if (!cardId || !body) return;
if (localStorage.getItem('offlineu-collapsed-' + cardId) === '1') {
header.classList.add('collapsed');
body.classList.add('collapsed');
}
header.addEventListener('click', function() {
const isCollapsed = body.classList.toggle('collapsed');
header.classList.toggle('collapsed', isCollapsed);
localStorage.setItem('offlineu-collapsed-' + cardId, isCollapsed ? '1' : '0');
});
});
}
document.addEventListener('DOMContentLoaded', loadLibrary);
document.addEventListener('DOMContentLoaded', updateViewModeButtons);
document.addEventListener('DOMContentLoaded', initCollapsibleCards);
</script>
<script src="/static/theme.js"></script>
</body>
</html>