Every course-loading path (Library browser, Continue Watching, Next Up, ...) goes through loadCoursePath(), which reloads the page after POSTing to /load_course - a plain reload never touches history, so once you were on the course view, back had nothing of ours to land on and fell straight through to exiting the app. Push a history entry before that reload, and teach the back handler that popping back to the course view means "unload the course and reload" (there's no client-side way to patch back to the library view's markup - it's a distinct server render tied to current_course). Also restore the exact library folder trail on that reload instead of resetting to the root, so back genuinely returns to where you were browsing, not just the top level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1520 lines
54 KiB
HTML
1520 lines
54 KiB
HTML
<!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;
|
||
}
|
||
|
||
.container {
|
||
max-width: var(--container-max-width);
|
||
width: 95%;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.app-header {
|
||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
||
padding: 28px 0;
|
||
margin-bottom: 30px;
|
||
border-bottom: 3px solid var(--accent);
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.app-header .container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.brand-icon {
|
||
font-size: 2.2em;
|
||
line-height: 1;
|
||
}
|
||
|
||
.brand h1 {
|
||
color: var(--accent);
|
||
font-size: 1.9em;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.brand .tagline {
|
||
color: var(--text-muted);
|
||
font-size: 0.85em;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.header-course-badge {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--accent);
|
||
color: var(--text-primary);
|
||
padding: 8px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: 0.9em;
|
||
max-width: 340px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.app-footer {
|
||
background: var(--bg-secondary);
|
||
border-top: 1px solid var(--border-color);
|
||
margin-top: 40px;
|
||
padding: 18px 0;
|
||
}
|
||
|
||
.app-footer .container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.footer-brand {
|
||
color: var(--text-muted);
|
||
font-size: 0.9em;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.footer-links {
|
||
display: flex;
|
||
gap: 20px;
|
||
}
|
||
|
||
.footer-links a {
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
font-size: 0.9em;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.footer-links a:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.nav {
|
||
background: var(--bg-tertiary);
|
||
padding: 10px 0;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.nav .container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.nav a {
|
||
color: var(--text-primary);
|
||
text-decoration: none;
|
||
padding: 10px 15px;
|
||
border-radius: var(--radius);
|
||
transition: background 0.3s;
|
||
}
|
||
|
||
.nav a:hover {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.card {
|
||
background: var(--bg-secondary);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
border-left: 4px solid var(--accent);
|
||
}
|
||
|
||
.btn {
|
||
background: var(--accent);
|
||
color: white;
|
||
border: none;
|
||
padding: 12px 24px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
transition: background 0.3s;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.btn:disabled {
|
||
background: #666;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: #666;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: #555;
|
||
}
|
||
|
||
.progress-bar {
|
||
background: #444;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
height: 20px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.progress-fill {
|
||
background: var(--accent);
|
||
height: 100%;
|
||
transition: width 0.3s ease;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.stats-tile {
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius);
|
||
padding: 14px 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stats-tile-value {
|
||
font-size: 1.5em;
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.stats-tile-label {
|
||
font-size: 0.8em;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.heatmap-scroll {
|
||
overflow-x: auto;
|
||
margin-top: 15px;
|
||
padding-bottom: 4px;
|
||
}
|
||
|
||
.heatmap-grid {
|
||
display: flex;
|
||
gap: 3px;
|
||
width: max-content;
|
||
}
|
||
|
||
.heatmap-week {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.heatmap-day {
|
||
width: 11px;
|
||
height: 11px;
|
||
border-radius: 2px;
|
||
background: var(--accent);
|
||
opacity: 0.12;
|
||
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-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;
|
||
}
|
||
|
||
.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">
|
||
<span class="brand-icon">📚</span>
|
||
<div>
|
||
<h1>OfflineU</h1>
|
||
<p class="tagline">Your self-hosted course library</p>
|
||
</div>
|
||
</a>
|
||
{% if course %}
|
||
<div class="header-course-badge">{{ course.name }}</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-content">
|
||
{% if course %}
|
||
<div class="nav">
|
||
<div class="container">
|
||
<a href="/reset_course">← Select Different Course</a>
|
||
<a href="#stats">Progress</a>
|
||
<span style="color: var(--text-muted); margin-left: auto;">
|
||
{{ stats.total_lessons }} lessons
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2>{{ course.name }}</h2>
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;">
|
||
<div>
|
||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||
</div>
|
||
<div style="text-align: right;">
|
||
<div style="color: 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()">
|
||
✓ Mark all as completed
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
|
||
{{ '📌 Remove from Next Up' if is_queued else '📌 Add to Next Up' }}
|
||
</button>
|
||
{% if has_note %}
|
||
<a class="btn btn-secondary btn-sm" href="/course/study-guide">📄 Download Study Guide</a>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if stats.last_accessed_path %}
|
||
<div class="last-accessed">
|
||
<strong>Last Accessed:</strong> {{ stats.last_accessed_path }}
|
||
<a href="/lesson/{{ stats.last_accessed_path }}" class="btn" style="margin-left: 10px;">
|
||
Continue
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="tree-container">
|
||
{% macro render_tree_node(node, depth=0) %}
|
||
<div class="tree-item">
|
||
<div class="tree-header directory" onclick="toggleTree(this)">
|
||
<div class="tree-title">
|
||
<span class="tree-icon">📁</span>
|
||
<span class="tree-name">{{ node.name }}</span>
|
||
<span class="tree-stats">
|
||
{{ (node.children|length + node.lessons|length) }} items
|
||
</span>
|
||
</div>
|
||
{% if node.children or node.lessons %}
|
||
<button class="tree-toggle">▶</button>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if node.children or node.lessons %}
|
||
<div class="tree-content">
|
||
{% for child_name, child_node in node.children.items() %}
|
||
{{ render_tree_node(child_node, depth + 1) }}
|
||
{% endfor %}
|
||
|
||
{% for lesson in node.lessons %}
|
||
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
|
||
{% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
|
||
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
|
||
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
|
||
<div class="lesson-title">
|
||
<span class="lesson-icon">
|
||
{% if lesson.lesson_type == 'video' %}🎥
|
||
{% elif lesson.lesson_type == 'audio' %}🎵
|
||
{% elif lesson.lesson_type == 'quiz' %}📝
|
||
{% elif lesson.lesson_type == 'mixed' %}📦
|
||
{% else %}📄{% endif %}
|
||
</span>
|
||
<span class="lesson-name">{{ lesson.title }}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||
{% if lesson.completed %}
|
||
<span class="status-icon completed">✓</span>
|
||
{% elif percent_watched %}
|
||
<span class="watched-badge">{{ percent_watched }}% watched</span>
|
||
{% else %}
|
||
<span class="status-icon pending">○</span>
|
||
{% endif %}
|
||
</div>
|
||
{% if percent_watched %}
|
||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{{ render_tree_node(course.root_node) }}
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
{% macro render_view_row(view) %}
|
||
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
|
||
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
|
||
<div class="lesson-title">
|
||
{% if view.has_thumbnail %}
|
||
<img class="lesson-thumb" src="/library/thumbnail?path={{ view.course_path | urlencode }}" alt=""
|
||
onerror="this.replaceWith(courseFallbackIcon('▶️'))">
|
||
{% else %}
|
||
<span class="lesson-icon">▶️</span>
|
||
{% endif %}
|
||
<span class="lesson-name">{{ view.lesson_title }}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
{% if view.completed %}
|
||
<span class="status-icon completed">✓</span>
|
||
{% elif view.percent_watched %}
|
||
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
|
||
{% endif %}
|
||
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
||
</div>
|
||
{% if view.percent_watched %}
|
||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
||
{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{% macro render_course_row(item, meta_text) %}
|
||
<div class="lesson-item" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
|
||
<div class="lesson-title">
|
||
{% if item.has_thumbnail %}
|
||
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
|
||
onerror="this.replaceWith(courseFallbackIcon('🎓'))">
|
||
{% else %}
|
||
<span class="lesson-icon">🎓</span>
|
||
{% endif %}
|
||
<span class="lesson-name">{{ item.name }}</span>
|
||
</div>
|
||
<span class="lesson-meta">{{ meta_text }}</span>
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{% if library_stats and library_stats.total_courses %}
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</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">📌 Next Up</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('🎓'))">
|
||
{% else %}
|
||
<span class="lesson-icon">🎓</span>
|
||
{% endif %}
|
||
<span class="lesson-name">{{ item.name }}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); moveNextUp(this, -1)" title="Move up">▲</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); moveNextUp(this, 1)" title="Move down">▼</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); removeNextUp(this)" title="Remove">✕</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% if stale_courses %}
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2 class="collapsible-header" data-card-id="pick-back-up">⏰ Pick Back Up</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">🆕 Recently Added</h2>
|
||
<div class="card-body">
|
||
{% for item in recently_added %}
|
||
{{ render_course_row(item, item.added_display) }}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% if continue_watching %}
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2 class="collapsible-header" data-card-id="continue-watching">▶ Continue Watching</h2>
|
||
<div class="card-body">
|
||
{% for view in continue_watching %}
|
||
{{ render_view_row(view) }}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% if recent_views %}
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2 class="collapsible-header" data-card-id="recently-viewed">🕐 Recently Viewed</h2>
|
||
<div class="card-body">
|
||
{% for view in recent_views %}
|
||
{{ render_view_row(view) }}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="container">
|
||
<div class="card" id="library-card">
|
||
<h2>Your Courses</h2>
|
||
<div class="library-toolbar">
|
||
<input type="text" id="library-search-input" class="library-search-input"
|
||
placeholder="Search courses…" oninput="handleLibrarySearchInput(this.value)">
|
||
<select id="library-sort" class="library-sort-select" onchange="reapplySort()">
|
||
<option value="name-asc">Name (A→Z)</option>
|
||
<option value="name-desc">Name (Z→A)</option>
|
||
</select>
|
||
</div>
|
||
<label class="transcript-search-toggle">
|
||
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
|
||
Also search transcripts
|
||
</label>
|
||
<p id="library-path-bar" class="library-breadcrumb"></p>
|
||
<div id="library-groups" style="margin-top: 15px;"></div>
|
||
<div id="transcript-results"></div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<footer class="app-footer">
|
||
<div class="container">
|
||
<div class="footer-brand">
|
||
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
|
||
</div>
|
||
<div class="footer-links">
|
||
<a href="/notes">📝 Notes</a>
|
||
<a href="/help">❓ Help</a>
|
||
<a href="/settings">⚙ Settings</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<script>
|
||
function toggleTree(element) {
|
||
console.log('Toggle clicked:', element);
|
||
|
||
// Find the content div that comes after this header
|
||
const content = element.nextElementSibling;
|
||
const toggle = element.querySelector('.tree-toggle');
|
||
|
||
console.log('Content element:', content);
|
||
console.log('Toggle element:', toggle);
|
||
|
||
if (content && content.classList.contains('tree-content')) {
|
||
const isExpanded = content.classList.contains('expanded');
|
||
content.classList.toggle('expanded');
|
||
|
||
if (toggle) {
|
||
toggle.textContent = content.classList.contains('expanded') ? '▼' : '▶';
|
||
}
|
||
|
||
console.log('Toggled tree, expanded:', content.classList.contains('expanded'));
|
||
} else {
|
||
console.log('No content element found or not tree-content');
|
||
}
|
||
}
|
||
|
||
// Single-level drill-down: the Library browser shows one folder's
|
||
// contents at a time (replacing the list, not nesting under it) and
|
||
// tracks how we got here in libraryTrail so a breadcrumb can jump
|
||
// back up. Much less horizontal space wasted than the old
|
||
// indent-per-level accordion, especially on narrow screens.
|
||
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
|
||
let lastLibraryItems = null; // whatever's on screen right now (browse or search), for re-sort without re-fetching
|
||
let searchActive = false;
|
||
let searchDebounceTimer = null;
|
||
|
||
function loadLibrary() {
|
||
const libraryCard = document.getElementById('library-card');
|
||
if (!libraryCard) return;
|
||
// 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;
|
||
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(emoji) {
|
||
const span = document.createElement('span');
|
||
span.className = 'lesson-icon';
|
||
span.textContent = emoji;
|
||
return span;
|
||
}
|
||
|
||
function courseRowHtml(item) {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const iconHtml = item.has_thumbnail
|
||
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
|
||
: `<span class="lesson-icon">🎓</span>`;
|
||
return `
|
||
<div class="lesson-item" onclick="loadCoursePath('${safePath}')">
|
||
<div class="lesson-title">
|
||
${iconHtml}
|
||
<span class="lesson-name">${item.name}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
|
||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">📌</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function directoryRowHtml(item) {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const safeName = item.name.replace(/'/g, "\\'");
|
||
return `
|
||
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
||
<div class="tree-title">
|
||
<span class="tree-icon">📁</span>
|
||
<span class="tree-name">${item.name}</span>
|
||
</div>
|
||
<span class="tree-toggle">›</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderItemRows(items) {
|
||
document.getElementById('library-groups').innerHTML =
|
||
items.map(item => item.type === 'course' ? courseRowHtml(item) : directoryRowHtml(item)).join('');
|
||
}
|
||
|
||
function sortLibraryItems(items) {
|
||
const order = document.getElementById('library-sort').value;
|
||
const sorted = items.slice();
|
||
sorted.sort((a, b) => order === 'name-desc'
|
||
? b.name.localeCompare(a.name)
|
||
: a.name.localeCompare(b.name));
|
||
return sorted;
|
||
}
|
||
|
||
function reapplySort() {
|
||
if (!lastLibraryItems) return;
|
||
renderItemRows(sortLibraryItems(lastLibraryItems));
|
||
}
|
||
|
||
// ---- Search ----
|
||
function handleLibrarySearchInput(value) {
|
||
clearTimeout(searchDebounceTimer);
|
||
const query = value.trim();
|
||
if (!query) {
|
||
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
|
||
return;
|
||
}
|
||
searchDebounceTimer = setTimeout(() => runLibrarySearch(query), 300);
|
||
}
|
||
|
||
function runLibrarySearch(query) {
|
||
searchActive = true;
|
||
const container = document.getElementById('library-groups');
|
||
const transcriptContainer = document.getElementById('transcript-results');
|
||
container.innerHTML = 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 = '';
|
||
return;
|
||
}
|
||
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
|
||
results.map(r => `
|
||
<div class="transcript-result" onclick="window.location.href='/recent/open?course_path=${encodeURIComponent(r.course_path)}&lesson_path=${encodeURIComponent(r.lesson_path)}'">
|
||
<div class="transcript-result-title">${r.lesson_title}</div>
|
||
<div class="transcript-result-course">${r.course_name}</div>
|
||
<div class="transcript-result-snippet">${r.snippet}</div>
|
||
</div>
|
||
`).join('');
|
||
})
|
||
.catch(() => {
|
||
transcriptContainer.innerHTML = '';
|
||
});
|
||
}
|
||
|
||
function queueCourse(path, btnEl) {
|
||
fetch('/api/next-up', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, queued: true })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success && btnEl) {
|
||
btnEl.textContent = '✓';
|
||
btnEl.disabled = true;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function loadCoursePath(path) {
|
||
fetch('/load_course', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({course_path: path})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
// 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 {
|
||
alert('Error: ' + data.error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- Next Up queue (dashboard card) ----
|
||
function saveNextUpOrder() {
|
||
const paths = Array.from(document.querySelectorAll('#next-up-list .lesson-item')).map(row => row.dataset.path);
|
||
fetch('/api/next-up/reorder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paths: paths })
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function moveNextUp(btnEl, direction) {
|
||
const row = btnEl.closest('.lesson-item');
|
||
const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling;
|
||
if (!sibling) return;
|
||
if (direction < 0) {
|
||
row.parentNode.insertBefore(row, sibling);
|
||
} else {
|
||
row.parentNode.insertBefore(sibling, row);
|
||
}
|
||
saveNextUpOrder();
|
||
}
|
||
|
||
function removeNextUp(btnEl) {
|
||
const row = btnEl.closest('.lesson-item');
|
||
const path = row.dataset.path;
|
||
fetch('/api/next-up', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, queued: false })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
row.remove();
|
||
const list = document.getElementById('next-up-list');
|
||
if (list && list.children.length === 0) {
|
||
document.getElementById('next-up-card').closest('.container').remove();
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function markCourseWatched() {
|
||
if (!confirm('Mark every lesson in this course as completed?')) return;
|
||
fetch('/api/course/mark-watched', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
location.reload();
|
||
} else {
|
||
alert('Error: ' + (data.error || 'Unknown error'));
|
||
}
|
||
});
|
||
}
|
||
|
||
{% if course %}
|
||
function toggleQueued() {
|
||
const btn = document.getElementById('queue-toggle-btn');
|
||
const currentlyQueued = btn.textContent.includes('Remove');
|
||
fetch('/api/next-up', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: {{ course.path|tojson }}, queued: !currentlyQueued })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
btn.textContent = currentlyQueued ? '📌 Add to Next Up' : '📌 Remove from Next Up';
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
{% endif %}
|
||
|
||
// ---- 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', initCollapsibleCards);
|
||
|
||
</script>
|
||
<script src="/static/theme.js"></script>
|
||
</body>
|
||
</html> |