Files
offlineu/templates/course_dashboard.html
rmsitzandClaude Sonnet 5 47e7214bd7 Add progress/recently-added sort to Library browser
The sort dropdown only had Name (A-Z/Z-A) - no way to surface "what
did I just download" or "what am I closest to finishing" without
scrolling. Adds Progress and Recently Added, backed by each course's
completion_percentage and folder mtime (already/newly attached in
list_library_directory and search_library_courses). Folders have
neither, so they naturally sort to the end under these two modes.

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

2183 lines
85 KiB
HTML
Raw Permalink 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 %}
{% import '_course_tree.html' as tree %}
<!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: 18px 0;
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-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 50%;
background: var(--bg-primary);
border: 1px solid var(--accent);
color: var(--accent);
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.header-icon-btn:hover {
background: var(--accent);
color: white;
}
.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;
}
.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: 8px;
}
.tree-container-header {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.expand-all-btn {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.85em;
text-decoration: underline;
padding: 4px 0;
}
.expand-all-btn:hover {
color: var(--accent-hover);
}
.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;
}
/* Course-outline tree: a leaner, flatter look than the boxed rows
above - those stay as-is since directoryRowHtml (the Library
folder browser) reuses the same base .tree-header/.tree-icon/
.lesson-item/etc. classes and isn't part of this redesign.
Scoped under .tree-container (exclusive to the course tree) so
nothing outside it is affected. No per-row background/border; a
small leading chevron doubles as the expand affordance instead
of a separate right-edge button, rotating via :has() so JS
never has to touch the icon directly - toggling is just
add/remove of one class on .tree-content. */
.tree-container .tree-header {
background: none;
border-left: none;
padding: 6px 8px;
gap: 8px;
}
.tree-container .tree-header:hover {
background: var(--bg-tertiary);
}
.tree-container .tree-toggle-icon {
display: inline-block;
width: 14px;
flex-shrink: 0;
color: var(--accent);
font-size: 0.85em;
transition: transform 0.15s ease;
}
.tree-container .tree-header:has(+ .tree-content.expanded) .tree-toggle-icon {
transform: rotate(90deg);
}
.tree-container .tree-name {
font-weight: 600;
}
.tree-container .tree-content {
margin-left: 20px;
margin-top: 2px;
}
.tree-container .lesson-item {
background: none;
border-left: none;
padding: 5px 8px;
margin-bottom: 0;
gap: 8px;
justify-content: flex-start;
transform: none;
}
.tree-container .lesson-item .lesson-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
font-size: 0.95em;
}
.tree-container .lesson-item:hover {
background: var(--bg-tertiary);
transform: none;
}
.tree-container .lesson-item.completed {
background: none;
}
.tree-container .lesson-item.completed:hover {
background: var(--bg-tertiary);
}
.tree-container .lesson-item.current {
background: var(--bg-tertiary);
cursor: default;
}
.tree-container .lesson-item.current .lesson-name {
font-weight: 600;
color: var(--accent);
}
.tree-container .lesson-item .lesson-icon {
font-size: 0.9em;
color: var(--text-muted);
}
.tree-container .lesson-item .lesson-duration {
font-size: 0.8em;
color: var(--text-muted);
flex-shrink: 0;
}
.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);
}
.favorite-toggle-btn.active {
color: #e0a825;
border-color: #e0a825;
}
.favorite-toggle-btn.active svg {
fill: currentColor;
}
#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 {
position: relative;
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-ring {
position: absolute;
bottom: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: conic-gradient(var(--accent) calc(var(--pct) * 1%), rgba(0, 0, 0, 0.4) 0);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.grid-card-progress-ring::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--bg-secondary);
}
.grid-card-progress-ring-check {
position: relative;
color: var(--accent);
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.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; }
.lesson-duration {
font-size: 0.8em;
color: var(--text-muted);
white-space: nowrap;
}
.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;
}
}
/* Visible keyboard-focus ring - buttons/links otherwise give no
indication which element is focused when tabbing through the
page (mouse clicks don't trigger :focus-visible, so this is
keyboard/switch-navigation only, not a click-ring). */
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</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 library_stats and library_stats.total_courses %}
<a href="/random-pick" class="header-icon-btn" title="Surprise Me - random incomplete course">{{ icons.icon('dice', 20) }}</a>
{% 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" id="stats">
<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>
<button class="btn btn-secondary btn-sm{{ ' favorite-toggle-btn active' if is_favorited else ' favorite-toggle-btn' }}" id="favorite-toggle-btn" onclick="toggleFavorited()">
{{ icons.icon('star', 14) }} {{ 'Remove from Favorites' if is_favorited else 'Add to Favorites' }}
</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-header">
<button type="button" class="expand-all-btn" id="dashboard-expand-all-btn" onclick="toggleAllTreeSections('#dashboard-tree-container', this)">Expand all</button>
</div>
<div class="tree-container" id="dashboard-tree-container">
{{ tree.render_tree_node(course.root_node, course.path) }}
</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 %}
<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>
<option value="progress">Progress</option>
<option value="recently-added">Recently Added</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>
{% if library_stats.remaining_display %}
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.remaining_display }}</div>
<div class="stats-tile-label">Remaining</div>
</div>
{% endif %}
<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 favorites %}
<div class="container">
<div class="card" id="favorites-card">
<h2 class="collapsible-header" data-card-id="favorites"><span class="card-header-label">{{ icons.icon('star') }}Favorites</span></h2>
<div class="card-body" id="favorites-list">
{% for item in favorites %}
<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(); removeFavorite(this)" title="Remove from Favorites">{{ icons.icon('x', 14) }}</button>
</div>
</div>
{% endfor %}
</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="/unsorted" class="tab-item {% if active_tab == 'files' %}active{% endif %}">
<span class="tab-icon">{{ icons.icon('folder', 20) }}</span><span class="tab-label">Files</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>',
star: '<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="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
'star-filled': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></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) {
// Find the content div that comes after this header - the
// chevron's rotation is pure CSS (:has(+ .tree-content.expanded)
// in _course_tree.html's consumers), so toggling this one class
// is the entire job here.
const content = element.nextElementSibling;
if (content && content.classList.contains('tree-content')) {
const isExpanded = content.classList.toggle('expanded');
if (content.dataset.sectionPath) saveSectionExpanded(content.dataset.sectionPath, isExpanded);
}
}
// Remembers which course sections a user has manually expanded
// (keyed by the section's absolute path, so it's stable across
// page loads and shared between this tree and the lesson page's
// sidebar - see _course_tree.html) so re-opening a course doesn't
// reset every section back to collapsed.
function getExpandedSectionPaths() {
try {
return new Set(JSON.parse(localStorage.getItem('offlineu-expanded-sections') || '[]'));
} catch (e) {
return new Set();
}
}
function saveSectionExpanded(path, expanded) {
const paths = getExpandedSectionPaths();
if (expanded) paths.add(path); else paths.delete(path);
localStorage.setItem('offlineu-expanded-sections', JSON.stringify([...paths]));
}
function restoreExpandedSections() {
const saved = getExpandedSectionPaths();
if (!saved.size) return;
document.querySelectorAll('.tree-content[data-section-path]').forEach(function(content) {
if (saved.has(content.dataset.sectionPath)) content.classList.add('expanded');
});
}
restoreExpandedSections();
// Expand/collapse every section within a tree at once, toggling
// between the two based on whether everything's already open -
// avoids a one-way "Expand all" with no quick way back.
function toggleAllTreeSections(containerSelector, btnEl) {
const container = document.querySelector(containerSelector);
if (!container) return;
const contents = [...container.querySelectorAll('.tree-content')];
if (!contents.length) return;
const shouldExpand = !contents.every(c => c.classList.contains('expanded'));
contents.forEach(function(content) {
content.classList.toggle('expanded', shouldExpand);
if (content.dataset.sectionPath) saveSectionExpanded(content.dataset.sectionPath, shouldExpand);
});
if (btnEl) btnEl.textContent = shouldExpand ? 'Collapse all' : 'Expand all';
}
(function initExpandAllLabel() {
const btn = document.getElementById('dashboard-expand-all-btn');
const container = document.getElementById('dashboard-tree-container');
if (!btn || !container) return;
const contents = [...container.querySelectorAll('.tree-content')];
if (contents.length && contents.every(c => c.classList.contains('expanded'))) {
btn.textContent = 'Collapse all';
}
})();
// 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 ringHtml = pct
? `<div class="grid-card-progress-ring" style="--pct: ${pct};" title="${pct}% complete">${pct >= 100 ? '<span class="grid-card-progress-ring-check">✓</span>' : ''}</div>`
: '';
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
if (item.duration_display) metaBits.push(item.duration_display);
return `
<div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}${ringHtml}</div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
</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>`
: '';
const favorited = !!item.favorited;
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 favorite-toggle-btn${favorited ? ' active' : ''}" data-path="${escapeAttr(item.path)}" onclick="event.stopPropagation(); favoriteCourse('${safePath}', this)" title="${favorited ? 'Remove from Favorites' : 'Add to Favorites'}">${favorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star}</button>
<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();
// Directories have neither a completion_percentage nor an
// mtime (only courses do), so they naturally fall to the end
// under these two modes rather than needing separate grouping
// logic - falling back to name order among themselves/ties.
if (order === 'progress') {
sorted.sort((a, b) => (b.completion_percentage || 0) - (a.completion_percentage || 0) || a.name.localeCompare(b.name));
} else if (order === 'recently-added') {
sorted.sort((a, b) => (b.mtime || 0) - (a.mtime || 0) || a.name.localeCompare(b.name));
} else {
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 removeFavorite(btnEl) {
const row = btnEl.closest('.lesson-item');
const path = row.dataset.path;
fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, favorited: false })
})
.then(r => r.json())
.then(data => {
if (data.success) {
row.remove();
const list = document.getElementById('favorites-list');
if (list && list.children.length === 0) {
document.getElementById('favorites-card').closest('.container').remove();
}
document.querySelectorAll(`.favorite-toggle-btn[data-path="${CSS.escape(path)}"]`).forEach(btn => {
btn.classList.remove('active');
btn.innerHTML = ICON_SVGS.star;
btn.title = 'Add to Favorites';
});
}
})
.catch(() => {});
}
function favoriteCourse(path, btnEl) {
const nowFavorited = !btnEl.classList.contains('active');
fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, favorited: nowFavorited })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btnEl.classList.toggle('active', nowFavorited);
btnEl.innerHTML = nowFavorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star;
btnEl.title = nowFavorited ? 'Remove from Favorites' : 'Add to Favorites';
}
})
.catch(() => {});
}
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(() => {});
}
function toggleFavorited() {
const btn = document.getElementById('favorite-toggle-btn');
const currentlyFavorited = btn.classList.contains('active');
fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: {{ course.path|tojson }}, favorited: !currentlyFavorited })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.classList.toggle('active', !currentlyFavorited);
btn.innerHTML = ICON_SVGS.star + ' ' + (currentlyFavorited ? 'Add to Favorites' : 'Remove from Favorites');
}
})
.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>