Fix Sort Unsorted picking a wrong destination on words that are common clutter across many categories (a creator's name, generic marketing filler) by ranking matches on a folder's own deliberate name over incidental sibling-course words, and downweighting borrowed words that recur across many categories. Add a folder-picker/create-new/rename UI to override any proposal. Turn /unsorted into a general File Management page: move Refresh Library, Bulk Rename, and Manage Library (hide/rename) here from Settings, add a Move action to Manage Library using the same picker, and add Duplicate Courses and Clean Up Stale References tools. Add a footer link to the page from the dashboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1987 lines
76 KiB
HTML
1987 lines
76 KiB
HTML
{% import '_icons.html' as icons %}
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %}</title>
|
||
<link rel="manifest" href="/static/manifest.json">
|
||
<meta name="theme-color" content="#007acc">
|
||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
:root {
|
||
--bg-primary: #1a1a1a;
|
||
--bg-secondary: #2d2d2d;
|
||
--bg-tertiary: #3d3d3d;
|
||
--bg-tertiary-hover: #404040;
|
||
--text-primary: #e0e0e0;
|
||
--text-muted: #999;
|
||
--border-color: #555;
|
||
--accent: #007acc;
|
||
--accent-hover: #005a9e;
|
||
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
--font-size-base: 16px;
|
||
--container-max-width: 1600px;
|
||
--radius: 8px;
|
||
--success: #28a745;
|
||
--error: #ff6b6b;
|
||
}
|
||
[data-theme="light"] {
|
||
--bg-primary: #f2f2f2;
|
||
--bg-secondary: #ffffff;
|
||
--bg-tertiary: #eeeeee;
|
||
--bg-tertiary-hover: #e2e2e2;
|
||
--text-primary: #222222;
|
||
--text-muted: #666666;
|
||
--border-color: #cccccc;
|
||
}
|
||
[data-density="compact"] .container { padding: 10px; }
|
||
[data-density="compact"] .card,
|
||
[data-density="compact"] .lesson-item,
|
||
[data-density="compact"] .tree-header { padding: 10px; margin-bottom: 8px; }
|
||
[data-card-style="elevated"] .card,
|
||
[data-card-style="elevated"] .lesson-item,
|
||
[data-card-style="elevated"] .tree-header {
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||
}
|
||
[data-card-style="bordered"] .card,
|
||
[data-card-style="bordered"] .lesson-item,
|
||
[data-card-style="bordered"] .tree-header {
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
html, body {
|
||
height: 100%;
|
||
}
|
||
body {
|
||
font-family: var(--font-family);
|
||
font-size: var(--font-size-base);
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
line-height: 1.6;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
}
|
||
.page-content {
|
||
flex: 1;
|
||
padding-bottom: 70px;
|
||
}
|
||
|
||
.container {
|
||
max-width: var(--container-max-width);
|
||
width: 95%;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.app-header {
|
||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
||
padding: 28px 0;
|
||
margin-bottom: 30px;
|
||
border-bottom: 3px solid var(--accent);
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.app-header .container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.brand-mark {
|
||
flex-shrink: 0;
|
||
display: block;
|
||
}
|
||
|
||
.brand h1 {
|
||
color: var(--accent);
|
||
font-size: 1.9em;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.brand .tagline {
|
||
color: var(--text-muted);
|
||
font-size: 0.85em;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.header-course-badge {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--accent);
|
||
color: var(--text-primary);
|
||
padding: 8px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: 0.9em;
|
||
max-width: 340px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.page-footer-link {
|
||
text-align: center;
|
||
padding: 24px 0 8px;
|
||
}
|
||
.page-footer-link a {
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 0.85em;
|
||
}
|
||
.page-footer-link a:hover {
|
||
color: var(--accent);
|
||
}
|
||
.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: 20px;
|
||
}
|
||
|
||
.tree-item {
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.tree-header {
|
||
background: var(--bg-tertiary);
|
||
padding: 12px 15px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
transition: background 0.3s;
|
||
border-left: 3px solid #666;
|
||
}
|
||
|
||
.tree-header:hover {
|
||
background: var(--bg-tertiary-hover);
|
||
}
|
||
|
||
.tree-header.directory {
|
||
border-left-color: var(--accent);
|
||
}
|
||
|
||
.tree-header.lesson {
|
||
border-left-color: var(--success);
|
||
}
|
||
|
||
.tree-header.completed {
|
||
border-left-color: var(--success);
|
||
background: #1e3d1e;
|
||
}
|
||
|
||
.tree-header.completed:hover {
|
||
background: #2d5a2d;
|
||
}
|
||
|
||
.tree-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.tree-icon {
|
||
font-size: 1.2em;
|
||
width: 20px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tree-name {
|
||
font-weight: 500;
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tree-stats {
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
margin-left: 10px;
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tree-toggle {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-primary);
|
||
font-size: 1.2em;
|
||
cursor: pointer;
|
||
padding: 5px;
|
||
border-radius: 3px;
|
||
transition: background 0.3s;
|
||
}
|
||
|
||
.tree-toggle:hover {
|
||
background: #555;
|
||
}
|
||
|
||
.tree-content {
|
||
margin-left: 25px;
|
||
margin-top: 5px;
|
||
display: none;
|
||
}
|
||
|
||
.tree-content.expanded {
|
||
display: block;
|
||
}
|
||
|
||
.lesson-item {
|
||
background: var(--bg-tertiary);
|
||
padding: 10px 15px;
|
||
border-radius: var(--radius);
|
||
margin-bottom: 5px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
transition: all 0.3s;
|
||
cursor: pointer;
|
||
border-left: 3px solid #666;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.lesson-item:hover {
|
||
border-left-color: var(--accent);
|
||
background: var(--bg-tertiary-hover);
|
||
transform: translateX(5px);
|
||
}
|
||
|
||
.lesson-item.completed {
|
||
border-left-color: var(--success);
|
||
background: #1e3d1e;
|
||
}
|
||
|
||
.lesson-item.completed:hover {
|
||
background: #2d5a2d;
|
||
}
|
||
|
||
.lesson-item.in-progress {
|
||
border-left-color: var(--accent);
|
||
}
|
||
|
||
.lesson-progress-track {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 3px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.lesson-progress-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.lesson-item.completed .lesson-progress-fill {
|
||
background: var(--success);
|
||
}
|
||
|
||
.watched-badge {
|
||
font-size: 0.75em;
|
||
background: var(--bg-primary);
|
||
color: var(--accent);
|
||
padding: 2px 7px;
|
||
border-radius: 3px;
|
||
border: 1px solid var(--accent);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.lesson-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.lesson-icon {
|
||
font-size: 1.1em;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.lesson-thumb {
|
||
width: 32px;
|
||
height: 32px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
display: block;
|
||
}
|
||
|
||
.library-toolbar {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.library-search-input {
|
||
flex: 1;
|
||
min-width: 160px;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
font-family: var(--font-family);
|
||
}
|
||
|
||
.library-sort-select {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.library-view-toggle {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
}
|
||
.view-toggle-btn {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-muted);
|
||
border: 1px solid var(--border-color);
|
||
padding: 8px 12px;
|
||
margin: 0;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
.view-toggle-btn:first-child {
|
||
border-radius: var(--radius) 0 0 var(--radius);
|
||
border-right: none;
|
||
}
|
||
.view-toggle-btn:last-child {
|
||
border-radius: 0 var(--radius) var(--radius) 0;
|
||
}
|
||
.view-toggle-btn.active {
|
||
background: var(--accent);
|
||
color: white;
|
||
border-color: var(--accent);
|
||
}
|
||
#library-select-btn {
|
||
border-radius: var(--radius);
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.bulk-action-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius);
|
||
}
|
||
.bulk-selected-count {
|
||
font-size: 0.9em;
|
||
color: var(--text-muted);
|
||
margin-right: auto;
|
||
}
|
||
.select-checkbox {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.library-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
.library-grid-card {
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius);
|
||
padding: 10px;
|
||
cursor: pointer;
|
||
transition: background 0.2s, transform 0.2s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.library-grid-card:hover {
|
||
background: var(--bg-tertiary-hover);
|
||
transform: translateY(-2px);
|
||
}
|
||
.grid-card-thumb-wrap {
|
||
width: 100%;
|
||
aspect-ratio: 1 / 1;
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
background: var(--bg-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 2.4em;
|
||
}
|
||
.grid-card-thumb {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.grid-card-initial {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 1.6em;
|
||
font-weight: 700;
|
||
font-family: var(--font-family);
|
||
}
|
||
.grid-card-name {
|
||
font-size: 0.9em;
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
.grid-card-meta {
|
||
font-size: 0.75em;
|
||
color: var(--text-muted);
|
||
}
|
||
.grid-card-progress-track {
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
.grid-card-progress-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.transcript-search-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.transcript-result {
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius);
|
||
padding: 10px 15px;
|
||
margin-bottom: 5px;
|
||
border-left: 3px solid var(--accent);
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.transcript-result:hover {
|
||
background: var(--bg-tertiary-hover);
|
||
transform: translateX(5px);
|
||
}
|
||
|
||
.transcript-result-title {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.transcript-result-course {
|
||
font-size: 0.8em;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.transcript-result-snippet {
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
font-style: italic;
|
||
}
|
||
|
||
.lesson-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.lesson-meta {
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
flex-shrink: 0;
|
||
min-width: 0;
|
||
max-width: 45%;
|
||
}
|
||
|
||
.lesson-meta-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
}
|
||
|
||
.lesson-type {
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 0.8em;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.lesson-type.video { background: #e74c3c; color: white; }
|
||
.lesson-type.audio { background: #9b59b6; color: white; }
|
||
.lesson-type.text { background: #34495e; color: white; }
|
||
.lesson-type.quiz { background: #f39c12; color: white; }
|
||
.lesson-type.mixed { background: #16a085; color: white; }
|
||
|
||
.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;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-header">
|
||
<div class="container">
|
||
<a href="/reset_course" class="brand">
|
||
<svg class="brand-mark" viewBox="0 0 64 64" width="44" height="44" aria-hidden="true">
|
||
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
|
||
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
|
||
</svg>
|
||
<div>
|
||
<h1>OfflineU</h1>
|
||
<p class="tagline">Your self-hosted course library</p>
|
||
</div>
|
||
</a>
|
||
{% if course %}
|
||
<div class="header-course-badge">{{ course.name }}</div>
|
||
{% elif 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">
|
||
<h2 class="course-name-heading">{{ course.name }}</h2>
|
||
{% if resume_lesson %}
|
||
<a class="btn resume-btn" href="/lesson/{{ resume_lesson.url }}">▶ Resume: {{ resume_lesson.title }}</a>
|
||
{% endif %}
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;">
|
||
<div>
|
||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||
</div>
|
||
<div style="text-align: right;">
|
||
<div style="color: var(--accent); font-size: 1.2em;">
|
||
{{ "%.1f"|format(stats.completion_percentage) }}%
|
||
</div>
|
||
{% if stats.remaining_display %}
|
||
<div style="color: var(--text-muted); font-size: 0.85em;">
|
||
~{{ stats.remaining_display }} remaining
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: {{ stats.completion_percentage }}%;"></div>
|
||
</div>
|
||
|
||
{% if stats.total_lessons %}
|
||
<div class="course-actions">
|
||
<button class="btn btn-secondary btn-sm" onclick="markCourseWatched()">
|
||
{{ icons.icon('check', 14) }} Mark all as completed
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
|
||
{{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }}
|
||
</button>
|
||
{% if has_note %}
|
||
<a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} Download Study Guide</a>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if stats.last_accessed_path %}
|
||
<div class="last-accessed">
|
||
<strong>Last Accessed:</strong> {{ stats.last_accessed_path }}
|
||
<a href="/lesson/{{ stats.last_accessed_path }}" class="btn" style="margin-left: 10px;">
|
||
Continue
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="tree-container">
|
||
{% macro render_tree_node(node, depth=0) %}
|
||
{% set stats = section_stats(node) %}
|
||
<div class="tree-item">
|
||
<div class="tree-header directory" onclick="toggleTree(this)">
|
||
<div class="tree-title">
|
||
<span class="tree-icon">{{ icons.icon('folder', 16) }}</span>
|
||
<span class="tree-name">{{ node.name }}</span>
|
||
<span class="tree-stats">
|
||
{% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %}
|
||
</span>
|
||
</div>
|
||
{% if node.children or node.lessons %}
|
||
<button class="tree-toggle">▶</button>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if node.children or node.lessons %}
|
||
<div class="tree-content">
|
||
{% for child_name, child_node in node.children.items() %}
|
||
{{ render_tree_node(child_node, depth + 1) }}
|
||
{% endfor %}
|
||
|
||
{% for lesson in node.lessons %}
|
||
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
|
||
{% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
|
||
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
|
||
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
|
||
<div class="lesson-title">
|
||
<span class="lesson-icon">
|
||
{% if lesson.lesson_type == 'video' %}{{ icons.icon('video', 16) }}
|
||
{% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 16) }}
|
||
{% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }}
|
||
{% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }}
|
||
{% else %}{{ icons.icon('file-text', 16) }}{% endif %}
|
||
</span>
|
||
<span class="lesson-name">{{ lesson.title }}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||
{% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
|
||
<span class="lesson-duration">{{ lesson.duration_seconds|format_duration }}</span>
|
||
{% endif %}
|
||
{% if lesson.completed %}
|
||
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
|
||
{% elif percent_watched %}
|
||
<span class="watched-badge">{{ percent_watched }}% watched</span>
|
||
{% else %}
|
||
<span class="status-icon pending">{{ icons.icon('circle', 14) }}</span>
|
||
{% endif %}
|
||
</div>
|
||
{% if percent_watched %}
|
||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{{ render_tree_node(course.root_node) }}
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
{% macro render_view_row(view) %}
|
||
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
|
||
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
|
||
<div class="lesson-title">
|
||
{% if view.has_thumbnail %}
|
||
<img class="lesson-thumb" src="/library/thumbnail?path={{ view.course_path | urlencode }}" alt=""
|
||
onerror="this.replaceWith(courseFallbackIcon('play'))">
|
||
{% else %}
|
||
<span class="lesson-icon">{{ icons.icon('play', 16) }}</span>
|
||
{% endif %}
|
||
<span class="lesson-name">{{ view.lesson_title }}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
{% if view.completed %}
|
||
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
|
||
{% elif view.percent_watched %}
|
||
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
|
||
{% endif %}
|
||
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
||
</div>
|
||
{% if view.percent_watched %}
|
||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
||
{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
{% macro render_course_row(item, meta_text) %}
|
||
<div class="lesson-item" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
|
||
<div class="lesson-title">
|
||
{% if item.has_thumbnail %}
|
||
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
|
||
onerror="this.replaceWith(courseFallbackIcon('graduation-cap'))">
|
||
{% else %}
|
||
<span class="lesson-icon">{{ icons.icon('graduation-cap', 16) }}</span>
|
||
{% endif %}
|
||
<span class="lesson-name">{{ item.name }}</span>
|
||
</div>
|
||
<span class="lesson-meta">{{ meta_text }}</span>
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
<div class="container">
|
||
<div class="card" id="library-card">
|
||
<h2><span class="card-header-label">{{ icons.icon('grid') }}Your Courses</span></h2>
|
||
<div class="library-toolbar">
|
||
<input type="text" id="library-search-input" class="library-search-input"
|
||
placeholder="Search courses…" oninput="handleLibrarySearchInput(this.value)">
|
||
<select id="library-sort" class="library-sort-select" onchange="reapplySort()">
|
||
<option value="name-asc">Name (A→Z)</option>
|
||
<option value="name-desc">Name (Z→A)</option>
|
||
</select>
|
||
<div class="library-view-toggle">
|
||
<button type="button" class="view-toggle-btn" id="library-view-list-btn"
|
||
onclick="setLibraryViewMode('list')" title="List view">{{ icons.icon('list', 16) }}</button>
|
||
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
|
||
onclick="setLibraryViewMode('grid')" title="Grid view">{{ icons.icon('grid', 16) }}</button>
|
||
</div>
|
||
<button type="button" class="view-toggle-btn" id="library-select-btn"
|
||
onclick="toggleSelectionMode()" title="Select multiple">{{ icons.icon('checkbox', 16) }}</button>
|
||
</div>
|
||
<label class="transcript-search-toggle">
|
||
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
|
||
Also search transcripts
|
||
</label>
|
||
<div id="bulk-action-bar" class="bulk-action-bar" style="display: none;">
|
||
<span id="bulk-selected-count" class="bulk-selected-count"></span>
|
||
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkHideSelected()">{{ icons.icon('ban', 14) }} Hide Selected</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkQueueSelected()">{{ icons.icon('pin', 14) }} Add to Next Up</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSelectionMode()">Cancel</button>
|
||
</div>
|
||
<p id="library-path-bar" class="library-breadcrumb"></p>
|
||
<div id="library-groups" style="margin-top: 15px;"></div>
|
||
<div id="transcript-results"></div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if library_stats and library_stats.total_courses %}
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2 class="collapsible-header" data-card-id="library-stats"><span class="card-header-label">{{ icons.icon('bar-chart') }}Library Stats</span></h2>
|
||
<div class="card-body">
|
||
<div class="stats-grid">
|
||
<div class="stats-tile">
|
||
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
|
||
<div class="stats-tile-label">Courses</div>
|
||
</div>
|
||
<div class="stats-tile">
|
||
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
|
||
<div class="stats-tile-label">Lessons completed</div>
|
||
</div>
|
||
<div class="stats-tile">
|
||
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
|
||
<div class="stats-tile-label">Watched</div>
|
||
</div>
|
||
{% 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 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 class="page-footer-link">
|
||
<a href="/unsorted">{{ icons.icon('folder', 14) }} File Management</a>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="bottom-tab-bar">
|
||
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
|
||
</a>
|
||
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
|
||
</a>
|
||
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
|
||
</a>
|
||
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
|
||
</a>
|
||
</nav>
|
||
|
||
<div id="toast-container" class="toast-container"></div>
|
||
|
||
<script>
|
||
// Client-rendered mirror of a few templates/_icons.html entries -
|
||
// JS template literals can't call the Jinja macro, so the handful
|
||
// of icons built dynamically (thumbnail-load fallbacks, toggled
|
||
// button states, JS-rendered library rows) are kept here in sync
|
||
// with the same path data by hand.
|
||
const ICON_SVGS = {
|
||
play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>',
|
||
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
|
||
pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>',
|
||
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
|
||
};
|
||
|
||
function showToast(message) {
|
||
const container = document.getElementById('toast-container');
|
||
if (!container) return;
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast';
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
requestAnimationFrame(() => toast.classList.add('toast-visible'));
|
||
setTimeout(() => {
|
||
toast.classList.remove('toast-visible');
|
||
setTimeout(() => toast.remove(), 250);
|
||
}, 3500);
|
||
}
|
||
|
||
function toggleTree(element) {
|
||
console.log('Toggle clicked:', element);
|
||
|
||
// Find the content div that comes after this header
|
||
const content = element.nextElementSibling;
|
||
const toggle = element.querySelector('.tree-toggle');
|
||
|
||
console.log('Content element:', content);
|
||
console.log('Toggle element:', toggle);
|
||
|
||
if (content && content.classList.contains('tree-content')) {
|
||
const isExpanded = content.classList.contains('expanded');
|
||
content.classList.toggle('expanded');
|
||
|
||
if (toggle) {
|
||
toggle.textContent = content.classList.contains('expanded') ? '▼' : '▶';
|
||
}
|
||
|
||
console.log('Toggled tree, expanded:', content.classList.contains('expanded'));
|
||
} else {
|
||
console.log('No content element found or not tree-content');
|
||
}
|
||
}
|
||
|
||
// Single-level drill-down: the Library browser shows one folder's
|
||
// contents at a time (replacing the list, not nesting under it) and
|
||
// tracks how we got here in libraryTrail so a breadcrumb can jump
|
||
// back up. Much less horizontal space wasted than the old
|
||
// indent-per-level accordion, especially on narrow screens.
|
||
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
|
||
let lastLibraryItems = null; // whatever's on screen right now (browse or search), for re-sort without re-fetching
|
||
let searchActive = false;
|
||
let searchDebounceTimer = null;
|
||
|
||
function loadLibrary() {
|
||
const libraryCard = document.getElementById('library-card');
|
||
if (!libraryCard) return;
|
||
// If we landed here via popstate/reload from a course view
|
||
// that got unloaded (see the popstate listener below), the
|
||
// current history entry may already carry the folder trail
|
||
// we were browsing before the course was loaded - restore it
|
||
// instead of always resetting to the library root.
|
||
const restored = history.state && history.state.libraryTrail;
|
||
libraryTrail = restored ? history.state.libraryTrail : [];
|
||
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
|
||
if (!restored) {
|
||
history.replaceState({ libraryTrail: [], path: null }, '');
|
||
}
|
||
}
|
||
|
||
// Drilling into a folder only ever swapped #library-groups's
|
||
// contents - no history entry, no URL change - so the back
|
||
// button/gesture had nothing of ours to step back through once
|
||
// you were a few folders deep, and fell straight through to
|
||
// exiting the app instead of going up one level. Pushing a state
|
||
// per level (and restoring it on popstate, below) makes back
|
||
// step back up the library trail one folder at a time instead.
|
||
function pushLibraryState() {
|
||
history.pushState({ libraryTrail: libraryTrail.slice(), path: libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null }, '');
|
||
}
|
||
|
||
window.addEventListener('popstate', function(event) {
|
||
{% if course %}
|
||
// The course view is a distinct server-side render tied to
|
||
// current_course, not something this page can patch back to
|
||
// client-side - unload the course and reload so back actually
|
||
// returns to the library/dashboard instead of doing nothing
|
||
// (which would otherwise leave a stale course view on screen
|
||
// while quietly burning through history entries until there's
|
||
// nowhere left to go but out of the app).
|
||
fetch('/reset_course').then(function() { location.reload(); });
|
||
return;
|
||
{% endif %}
|
||
const state = event.state;
|
||
if (!state || !document.getElementById('library-card')) return;
|
||
libraryTrail = state.libraryTrail || [];
|
||
fetchLibraryLevel(state.path || null);
|
||
});
|
||
|
||
function fetchLibraryLevel(path) {
|
||
searchActive = false;
|
||
clearSelection(); // selection is scoped to the current folder view
|
||
const searchInput = document.getElementById('library-search-input');
|
||
if (searchInput) searchInput.value = '';
|
||
const transcriptResults = document.getElementById('transcript-results');
|
||
if (transcriptResults) transcriptResults.innerHTML = '';
|
||
|
||
const container = document.getElementById('library-groups');
|
||
container.innerHTML = skeletonRowsHtml(3);
|
||
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
|
||
fetch(url)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
renderLibraryBreadcrumb();
|
||
renderLibraryLevel(data);
|
||
})
|
||
.catch(() => {
|
||
container.innerHTML =
|
||
'<p style="color:var(--error);">Could not reach the library scanner.</p>';
|
||
});
|
||
}
|
||
|
||
function renderLibraryBreadcrumb() {
|
||
const bar = document.getElementById('library-path-bar');
|
||
const atRoot = libraryTrail.length === 0;
|
||
let html = `<span class="crumb ${atRoot ? 'current' : ''}"` +
|
||
(atRoot ? '' : ` onclick="goToLibraryCrumb(-1)"`) + `>Library</span>`;
|
||
libraryTrail.forEach((crumb, i) => {
|
||
const isLast = i === libraryTrail.length - 1;
|
||
html += ` <span class="crumb-sep">/</span> ` +
|
||
`<span class="crumb ${isLast ? 'current' : ''}"` +
|
||
(isLast ? '' : ` onclick="goToLibraryCrumb(${i})"`) +
|
||
`>${crumb.name}</span>`;
|
||
});
|
||
bar.innerHTML = html;
|
||
}
|
||
|
||
function goToLibraryCrumb(index) {
|
||
if (index < 0) {
|
||
libraryTrail = [];
|
||
fetchLibraryLevel(null);
|
||
pushLibraryState();
|
||
return;
|
||
}
|
||
const target = libraryTrail[index];
|
||
libraryTrail = libraryTrail.slice(0, index + 1);
|
||
fetchLibraryLevel(target.path);
|
||
pushLibraryState();
|
||
}
|
||
|
||
function renderLibraryLevel(data) {
|
||
const container = document.getElementById('library-groups');
|
||
const isRoot = libraryTrail.length === 0;
|
||
if (!data.items || data.items.length === 0) {
|
||
lastLibraryItems = null;
|
||
const reason = (data.errors && data.errors.length)
|
||
? data.errors.join(' ')
|
||
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
|
||
container.innerHTML = `<p style="color:var(--text-muted); padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
|
||
return;
|
||
}
|
||
|
||
lastLibraryItems = data.items;
|
||
renderItemRows(sortLibraryItems(data.items));
|
||
}
|
||
|
||
function enterLibraryDir(path, name) {
|
||
libraryTrail.push({ name: name, path: path });
|
||
fetchLibraryLevel(path);
|
||
pushLibraryState();
|
||
}
|
||
|
||
// A few shimmering placeholder rows for in-flight fetches, instead
|
||
// of bare "Loading..." text.
|
||
function skeletonRowsHtml(count) {
|
||
return Array(count).fill('<div class="skeleton-row"></div>').join('');
|
||
}
|
||
|
||
// Fallback if a course's thumbnail image 404s/errors after the row
|
||
// already rendered (e.g. the file was removed on disk in between) -
|
||
// swaps back to the plain icon rather than showing a broken image.
|
||
function courseFallbackIcon(iconName) {
|
||
const span = document.createElement('span');
|
||
span.className = 'lesson-icon';
|
||
span.innerHTML = ICON_SVGS[iconName] || '';
|
||
return span;
|
||
}
|
||
|
||
// Deterministic color+initial per course name (same idea as
|
||
// Slack/Google Photos default avatars) - almost none of this
|
||
// library's courses have real cover art, so without this every
|
||
// grid card without a thumbnail looked identical, defeating the
|
||
// point of browsing by appearance.
|
||
function courseColorHue(name) {
|
||
let hash = 0;
|
||
for (let i = 0; i < name.length; i++) {
|
||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||
}
|
||
return Math.abs(hash) % 360;
|
||
}
|
||
function courseInitial(name) {
|
||
const match = name.match(/[a-zA-Z0-9]/);
|
||
return match ? match[0].toUpperCase() : '?';
|
||
}
|
||
function courseInitialHtml(name) {
|
||
return `<div class="grid-card-initial" style="background: hsl(${courseColorHue(name)}, 40%, 32%);">${courseInitial(name)}</div>`;
|
||
}
|
||
function gridFallbackInitial(name) {
|
||
const div = document.createElement('div');
|
||
div.className = 'grid-card-initial';
|
||
div.style.background = `hsl(${courseColorHue(name)}, 40%, 32%)`;
|
||
div.textContent = courseInitial(name);
|
||
return div;
|
||
}
|
||
|
||
// For embedding a name in an HTML attribute (e.g. title="...") -
|
||
// grid-card names are clamped to 2 lines and clipped, so the title
|
||
// attribute is what lets a hover reveal the full name.
|
||
function escapeAttr(text) {
|
||
return text.replace(/&/g, '&').replace(/"/g, '"');
|
||
}
|
||
|
||
// ---- Library list/grid view toggle (client-only, remembered per device) ----
|
||
let libraryViewMode = localStorage.getItem('offlineu-library-view-mode') || 'list';
|
||
|
||
function updateViewModeButtons() {
|
||
const listBtn = document.getElementById('library-view-list-btn');
|
||
const gridBtn = document.getElementById('library-view-grid-btn');
|
||
if (listBtn) listBtn.classList.toggle('active', libraryViewMode === 'list');
|
||
if (gridBtn) gridBtn.classList.toggle('active', libraryViewMode === 'grid');
|
||
}
|
||
|
||
function setLibraryViewMode(mode) {
|
||
libraryViewMode = mode;
|
||
localStorage.setItem('offlineu-library-view-mode', mode);
|
||
updateViewModeButtons();
|
||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||
}
|
||
|
||
// ---- Bulk select (hide / queue several courses or folders at once) ----
|
||
let selectionMode = false;
|
||
let selectedPaths = new Set();
|
||
|
||
function selectableAttrs(item) {
|
||
if (!selectionMode) return '';
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const checked = selectedPaths.has(item.path) ? 'checked' : '';
|
||
return `<input type="checkbox" class="select-checkbox" onclick="event.stopPropagation(); toggleSelection('${safePath}')" ${checked}>`;
|
||
}
|
||
|
||
function toggleSelectionMode() {
|
||
selectionMode = !selectionMode;
|
||
if (!selectionMode) selectedPaths.clear();
|
||
const btn = document.getElementById('library-select-btn');
|
||
if (btn) btn.classList.toggle('active', selectionMode);
|
||
updateBulkActionBar();
|
||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||
}
|
||
|
||
function toggleSelection(path) {
|
||
if (selectedPaths.has(path)) selectedPaths.delete(path);
|
||
else selectedPaths.add(path);
|
||
updateBulkActionBar();
|
||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectionMode = false;
|
||
selectedPaths.clear();
|
||
const btn = document.getElementById('library-select-btn');
|
||
if (btn) btn.classList.remove('active');
|
||
updateBulkActionBar();
|
||
}
|
||
|
||
function updateBulkActionBar() {
|
||
const bar = document.getElementById('bulk-action-bar');
|
||
const count = document.getElementById('bulk-selected-count');
|
||
if (!bar) return;
|
||
bar.style.display = selectedPaths.size > 0 ? 'flex' : 'none';
|
||
if (count) count.textContent = `${selectedPaths.size} selected`;
|
||
}
|
||
|
||
function bulkHideSelected() {
|
||
const paths = Array.from(selectedPaths);
|
||
fetch('/api/hidden-paths/bulk', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paths: paths, hidden: true })
|
||
})
|
||
.then(r => r.json())
|
||
.then(() => {
|
||
clearSelection();
|
||
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function bulkQueueSelected() {
|
||
const coursePaths = Array.from(selectedPaths).filter(p => {
|
||
const item = (lastLibraryItems || []).find(i => i.path === p);
|
||
return item && item.type === 'course';
|
||
});
|
||
if (!coursePaths.length) {
|
||
showToast('Select at least one course (not a folder) to add to Next Up.');
|
||
return;
|
||
}
|
||
fetch('/api/next-up/bulk', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paths: coursePaths })
|
||
})
|
||
.then(r => r.json())
|
||
.then(() => clearSelection())
|
||
.catch(() => {});
|
||
}
|
||
|
||
function courseCardHtml(item) {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const safeName = item.name.replace(/'/g, "\\'");
|
||
const iconHtml = item.has_thumbnail
|
||
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackInitial('${safeName}'))">`
|
||
: courseInitialHtml(item.name);
|
||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||
const pct = item.completion_percentage;
|
||
const progressBar = pct
|
||
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
|
||
: '';
|
||
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
|
||
if (item.duration_display) metaBits.push(item.duration_display);
|
||
if (pct) metaBits.push(`${pct}% done`);
|
||
return `
|
||
<div class="library-grid-card" onclick="${clickHandler}">
|
||
${selectableAttrs(item)}
|
||
<div class="grid-card-thumb-wrap">${iconHtml}</div>
|
||
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
|
||
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
|
||
${progressBar}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function directoryCardHtml(item) {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const safeName = item.name.replace(/'/g, "\\'");
|
||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
|
||
return `
|
||
<div class="library-grid-card" onclick="${clickHandler}">
|
||
${selectableAttrs(item)}
|
||
<div class="grid-card-thumb-wrap"><svg class="icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg></div>
|
||
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function courseRowHtml(item) {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const iconHtml = item.has_thumbnail
|
||
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('graduation-cap'))">`
|
||
: `<span class="lesson-icon">${ICON_SVGS['graduation-cap']}</span>`;
|
||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||
const pct = item.completion_percentage;
|
||
const progressBadge = pct ? `<span class="watched-badge">${pct}% done</span>` : '';
|
||
const progressBar = pct
|
||
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
|
||
: '';
|
||
return `
|
||
<div class="lesson-item" onclick="${clickHandler}">
|
||
<div class="lesson-title">
|
||
${selectableAttrs(item)}
|
||
${iconHtml}
|
||
<span class="lesson-name">${item.name}</span>
|
||
</div>
|
||
<div class="lesson-meta">
|
||
${progressBadge}
|
||
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
|
||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
|
||
</div>
|
||
${progressBar}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function directoryRowHtml(item) {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const safeName = item.name.replace(/'/g, "\\'");
|
||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
|
||
return `
|
||
<div class="tree-header directory" onclick="${clickHandler}">
|
||
<div class="tree-title">
|
||
${selectableAttrs(item)}
|
||
<span class="tree-icon">${ICON_SVGS.folder}</span>
|
||
<span class="tree-name">${item.name}</span>
|
||
</div>
|
||
<span class="tree-toggle">›</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderItemRows(items) {
|
||
const container = document.getElementById('library-groups');
|
||
container.classList.toggle('library-grid', libraryViewMode === 'grid');
|
||
container.innerHTML = libraryViewMode === 'grid'
|
||
? items.map(item => item.type === 'course' ? courseCardHtml(item) : directoryCardHtml(item)).join('')
|
||
: items.map(item => item.type === 'course' ? courseRowHtml(item) : directoryRowHtml(item)).join('');
|
||
}
|
||
|
||
function sortLibraryItems(items) {
|
||
const order = document.getElementById('library-sort').value;
|
||
const sorted = items.slice();
|
||
sorted.sort((a, b) => order === 'name-desc'
|
||
? b.name.localeCompare(a.name)
|
||
: a.name.localeCompare(b.name));
|
||
return sorted;
|
||
}
|
||
|
||
function reapplySort() {
|
||
if (!lastLibraryItems) return;
|
||
renderItemRows(sortLibraryItems(lastLibraryItems));
|
||
}
|
||
|
||
// ---- Search ----
|
||
function handleLibrarySearchInput(value) {
|
||
clearTimeout(searchDebounceTimer);
|
||
const query = value.trim();
|
||
if (!query) {
|
||
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
|
||
return;
|
||
}
|
||
searchDebounceTimer = setTimeout(() => runLibrarySearch(query), 300);
|
||
}
|
||
|
||
function runLibrarySearch(query) {
|
||
searchActive = true;
|
||
clearSelection(); // selection is scoped to the current folder view
|
||
const container = document.getElementById('library-groups');
|
||
const transcriptContainer = document.getElementById('transcript-results');
|
||
container.innerHTML = skeletonRowsHtml(2);
|
||
if (transcriptContainer) transcriptContainer.innerHTML = '';
|
||
|
||
fetch(`/library/search?q=${encodeURIComponent(query)}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
document.getElementById('library-path-bar').innerHTML =
|
||
`<span class="crumb current" style="max-width: none;">Search results for "${query}"</span>`;
|
||
lastLibraryItems = data.results;
|
||
if (!data.results.length) {
|
||
container.innerHTML = `<p style="color:var(--text-muted); padding: 6px 0;">No courses match "${query}".</p>`;
|
||
return;
|
||
}
|
||
renderItemRows(sortLibraryItems(data.results));
|
||
})
|
||
.catch(() => {
|
||
container.innerHTML = '<p style="color:var(--error);">Search failed.</p>';
|
||
});
|
||
|
||
const searchTranscripts = document.getElementById('search-transcripts-toggle');
|
||
if (searchTranscripts && searchTranscripts.checked) {
|
||
runTranscriptSearch(query);
|
||
}
|
||
}
|
||
|
||
function runTranscriptSearch(query) {
|
||
const transcriptContainer = document.getElementById('transcript-results');
|
||
if (!transcriptContainer) return;
|
||
transcriptContainer.innerHTML = skeletonRowsHtml(2);
|
||
fetch(`/library/search-transcripts?q=${encodeURIComponent(query)}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const results = data.results || [];
|
||
if (!results.length) {
|
||
transcriptContainer.innerHTML = '<p style="color:var(--text-muted); padding: 6px 0;">No matching transcripts.</p>';
|
||
return;
|
||
}
|
||
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
|
||
results.map(r => `
|
||
<div class="transcript-result" onclick="window.location.href='/recent/open?course_path=${encodeURIComponent(r.course_path)}&lesson_path=${encodeURIComponent(r.lesson_path)}'">
|
||
<div class="transcript-result-title">${r.lesson_title}</div>
|
||
<div class="transcript-result-course">${r.course_name}</div>
|
||
<div class="transcript-result-snippet">${r.snippet}</div>
|
||
</div>
|
||
`).join('');
|
||
})
|
||
.catch(() => {
|
||
transcriptContainer.innerHTML = '<p style="color:var(--error);">Transcript search failed.</p>';
|
||
});
|
||
}
|
||
|
||
function queueCourse(path, btnEl) {
|
||
fetch('/api/next-up', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, queued: true })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success && btnEl) {
|
||
btnEl.textContent = '✓';
|
||
btnEl.disabled = true;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function loadCoursePath(path) {
|
||
fetch('/load_course', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({course_path: path})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
// A plain reload doesn't touch history, so without this
|
||
// push there'd be nothing for back to land on once the
|
||
// course view replaces this page - see the popstate
|
||
// listener above for how landing back here unloads it.
|
||
history.pushState({ courseLoaded: true }, '');
|
||
location.reload();
|
||
} else {
|
||
showToast('Error: ' + data.error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- Next Up queue (dashboard card) ----
|
||
function saveNextUpOrder() {
|
||
const paths = Array.from(document.querySelectorAll('#next-up-list .lesson-item')).map(row => row.dataset.path);
|
||
fetch('/api/next-up/reorder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paths: paths })
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function moveNextUp(btnEl, direction) {
|
||
const row = btnEl.closest('.lesson-item');
|
||
const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling;
|
||
if (!sibling) return;
|
||
if (direction < 0) {
|
||
row.parentNode.insertBefore(row, sibling);
|
||
} else {
|
||
row.parentNode.insertBefore(sibling, row);
|
||
}
|
||
saveNextUpOrder();
|
||
}
|
||
|
||
function removeNextUp(btnEl) {
|
||
const row = btnEl.closest('.lesson-item');
|
||
const path = row.dataset.path;
|
||
fetch('/api/next-up', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, queued: false })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
row.remove();
|
||
const list = document.getElementById('next-up-list');
|
||
if (list && list.children.length === 0) {
|
||
document.getElementById('next-up-card').closest('.container').remove();
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function markCourseWatched() {
|
||
if (!confirm('Mark every lesson in this course as completed?')) return;
|
||
fetch('/api/course/mark-watched', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
location.reload();
|
||
} else {
|
||
showToast('Error: ' + (data.error || 'Unknown error'));
|
||
}
|
||
});
|
||
}
|
||
|
||
{% if course %}
|
||
function toggleQueued() {
|
||
const btn = document.getElementById('queue-toggle-btn');
|
||
const currentlyQueued = btn.textContent.includes('Remove');
|
||
fetch('/api/next-up', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: {{ course.path|tojson }}, queued: !currentlyQueued })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
btn.innerHTML = ICON_SVGS.pin + ' ' + (currentlyQueued ? 'Add to Next Up' : 'Remove from Next Up');
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
{% endif %}
|
||
|
||
// ---- Collapsible dashboard cards (client-only, remembered per device) ----
|
||
function initCollapsibleCards() {
|
||
document.querySelectorAll('.collapsible-header').forEach(function(header) {
|
||
const cardId = header.dataset.cardId;
|
||
const body = header.nextElementSibling;
|
||
if (!cardId || !body) return;
|
||
|
||
if (localStorage.getItem('offlineu-collapsed-' + cardId) === '1') {
|
||
header.classList.add('collapsed');
|
||
body.classList.add('collapsed');
|
||
}
|
||
|
||
header.addEventListener('click', function() {
|
||
const isCollapsed = body.classList.toggle('collapsed');
|
||
header.classList.toggle('collapsed', isCollapsed);
|
||
localStorage.setItem('offlineu-collapsed-' + cardId, isCollapsed ? '1' : '0');
|
||
});
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', loadLibrary);
|
||
document.addEventListener('DOMContentLoaded', updateViewModeButtons);
|
||
document.addEventListener('DOMContentLoaded', initCollapsibleCards);
|
||
|
||
</script>
|
||
<script src="/static/theme.js"></script>
|
||
</body>
|
||
</html> |