1412 lines
57 KiB
JavaScript
1412 lines
57 KiB
JavaScript
/* CourseHub SPA — hash router + fetch API + Plyr player, on the
|
|
* "Course Library" design system.
|
|
*
|
|
* Routes: #/ home (resume, stats, in progress, last scan)
|
|
* #/library[/f] all courses, optionally filtered to one shelf
|
|
* #/folder/N browse a course or section
|
|
* #/watch/N video player
|
|
* #/quiz/N quiz
|
|
* #/search/Q search results (Q is URI-encoded)
|
|
* #/settings library, preferences, reset
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const shell = document.getElementById('app-shell');
|
|
const authEl = document.getElementById('auth');
|
|
const view = document.getElementById('view');
|
|
const state = {
|
|
user: null,
|
|
meta: null, // totals / shelves / last scan, for the chrome
|
|
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
|
|
searchSeq: 0,
|
|
scanning: false,
|
|
};
|
|
let cleanupView = null;
|
|
|
|
/* ---------- design tokens mirrored in JS ---------- */
|
|
|
|
// A course has no cover art, so it gets one of the palette's hues, picked
|
|
// from its id — stable across renders and reloads.
|
|
const COURSE_HUES = ['#B85A2E', '#3F7A50', '#374B5C', '#96691C', '#7A4560', '#2E6A8C'];
|
|
const hueOf = (id) => COURSE_HUES[Math.abs(id | 0) % COURSE_HUES.length];
|
|
|
|
const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' };
|
|
const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' };
|
|
|
|
const ICONS = {
|
|
home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>',
|
|
grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect>',
|
|
list: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path>',
|
|
search: '<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2"></path>',
|
|
settings: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1v.3a2 2 0 1 1-4 0v-.2a1.6 1.6 0 0 0-2.8-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3.5 14H3a2 2 0 1 1 0-4h.2A1.6 1.6 0 0 0 4.3 7.2l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 2.7-1.1V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0 1.1 2.7h.3a2 2 0 1 1 0 4h-.2a1.6 1.6 0 0 0-1.1 1Z"></path>',
|
|
scan: '<path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path>',
|
|
reset: '<path d="M3 12a9 9 0 1 0 3-6.7"></path><path d="M3 4v5h5"></path>',
|
|
back: '<path d="m15 18-6-6 6-6"></path>',
|
|
folder: '<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h7A1.5 1.5 0 0 1 19 10v7.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 3 17.5Z"></path>',
|
|
quiz: '<path d="M9.1 9a3 3 0 1 1 4.1 2.8c-.8.3-1.2 1-1.2 1.9v.3"></path><path d="M12 18h.01"></path><circle cx="12" cy="12" r="9"></circle>',
|
|
check: '<path d="m4 12 5 5L20 6"></path>',
|
|
alert: '<circle cx="12" cy="12" r="9"></circle><path d="M12 8v5"></path><path d="M12 16h.01"></path>',
|
|
clock: '<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path>',
|
|
prev: '<polygon points="19 20 9 12 19 4 19 20"></polygon><path d="M5 5v14"></path>',
|
|
next: '<polygon points="5 4 15 12 5 20 5 4"></polygon><path d="M19 5v14"></path>',
|
|
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><path d="m16 17 5-5-5-5"></path><path d="M21 12H9"></path>',
|
|
close: '<path d="M18 6 6 18M6 6l12 12"></path>',
|
|
play: '<polygon points="6 4 20 12 6 20 6 4"></polygon>',
|
|
list_ol: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path>',
|
|
};
|
|
|
|
// Sizes are the design's own; GLYPH_SCALE keeps glyphs in step with the type
|
|
// scale in style.css, which runs larger than the fixed-width design canvas.
|
|
const GLYPH_SCALE = 1.15;
|
|
const glyph = (size) => Math.round(size * GLYPH_SCALE);
|
|
|
|
const icon = (name, size = 17, width = 1.6) =>
|
|
`<svg width="${glyph(size)}" height="${glyph(size)}" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`;
|
|
const iconFill = (name, size = 12) =>
|
|
`<svg width="${glyph(size)}" height="${glyph(size)}" viewBox="0 0 24 24" fill="currentColor">${ICONS[name]}</svg>`;
|
|
|
|
/* ---------- helpers ---------- */
|
|
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
|
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
/** m:ss / h:mm:ss — for a single video. */
|
|
function fmt(seconds) {
|
|
if (!seconds || seconds <= 0) return '';
|
|
const s = Math.round(seconds);
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
|
: `${m}:${String(sec).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** 6h 10m / 45m — for a course's total running time. */
|
|
function fmtLong(seconds) {
|
|
if (!seconds || seconds <= 0) return '';
|
|
const m = Math.round(seconds / 60);
|
|
const h = Math.floor(m / 60);
|
|
return h ? `${h}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`;
|
|
}
|
|
|
|
const fmtHours = (seconds) => {
|
|
if (!seconds || seconds < 60) return '0h';
|
|
const h = seconds / 3600;
|
|
return h < 1 ? `${Math.round(seconds / 60)}m` : `${h < 10 ? h.toFixed(1) : Math.round(h)}h`;
|
|
};
|
|
|
|
function fmtBytes(bytes) {
|
|
if (!bytes) return '';
|
|
const gb = bytes / 1e9;
|
|
return gb >= 1 ? `${gb.toFixed(1)} gb` : `${Math.max(1, Math.round(bytes / 1e6))} mb`;
|
|
}
|
|
|
|
/** "4h ago" — server timestamps are UTC. */
|
|
function relTime(iso) {
|
|
if (!iso) return 'never';
|
|
const then = new Date(String(iso).replace(' ', 'T') + 'Z').getTime();
|
|
if (!then) return 'never';
|
|
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
|
if (mins < 2) return 'now';
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.round(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
const days = Math.round(hrs / 24);
|
|
return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
|
|
}
|
|
|
|
const courseMeta = (c) => {
|
|
const bits = [];
|
|
if (c.videos) bits.push(`${c.videos} video${c.videos === 1 ? '' : 's'}`);
|
|
if (c.quizzes) bits.push(`${c.quizzes} quiz${c.quizzes === 1 ? '' : 'zes'}`);
|
|
if (c.duration) bits.push(fmtLong(c.duration));
|
|
return bits.join(' · ');
|
|
};
|
|
|
|
const initialOf = (name) => (String(name || '?').trim()[0] || '?').toUpperCase();
|
|
|
|
/* ---------- preferences ---------- */
|
|
|
|
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true, motion: true };
|
|
|
|
function pref(key) {
|
|
try {
|
|
const raw = localStorage.getItem('coursehub.' + key);
|
|
if (raw === null) return PREF_DEFAULTS[key];
|
|
return raw === 'true' ? true : raw === 'false' ? false : raw;
|
|
} catch (e) {
|
|
return PREF_DEFAULTS[key]; // storage blocked (private mode)
|
|
}
|
|
}
|
|
|
|
function setPref(key, value) {
|
|
try { localStorage.setItem('coursehub.' + key, String(value)); } catch (e) { /* not persisted */ }
|
|
}
|
|
|
|
/* ---------- api ---------- */
|
|
|
|
async function api(url, options = {}) {
|
|
if (options.json) {
|
|
options.method = options.method || 'POST';
|
|
options.headers = { 'Content-Type': 'application/json' };
|
|
options.body = JSON.stringify(options.json);
|
|
delete options.json;
|
|
}
|
|
const res = await fetch(url, options);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (res.status === 401) {
|
|
state.user = null;
|
|
showAuth();
|
|
throw new Error(data.error || 'Not logged in');
|
|
}
|
|
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
|
return data;
|
|
}
|
|
|
|
const spinner = () => { view.innerHTML = '<div class="loading"><span class="spinner"></span></div>'; };
|
|
|
|
/* ---------- motion (anime.js) ----------
|
|
* Skipped when anime.js is unavailable, when the OS asks for reduced motion,
|
|
* or when the user turned animations off in settings. */
|
|
|
|
const canAnimate = () =>
|
|
typeof anime !== 'undefined' && pref('motion') !== false &&
|
|
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
const clearInline = (els) => els.forEach((el) => { el.style.opacity = ''; el.style.transform = ''; });
|
|
|
|
function reveal(root = view) {
|
|
if (!canAnimate()) return;
|
|
const items = [...root.querySelectorAll('.course-card, .row, .row-card, .card, .q-card, .hero, .settings-section')];
|
|
items.forEach((el) => { el.style.opacity = '0'; }); // before the frame paints
|
|
|
|
anime({ targets: root, opacity: [0, 1], translateY: [8, 0], duration: 260,
|
|
easing: 'easeOutQuad', complete: () => clearInline([root]) });
|
|
if (items.length) {
|
|
anime({ targets: items, opacity: [0, 1], translateY: [10, 0], duration: 420,
|
|
delay: anime.stagger(26, { start: 70 }), easing: 'easeOutCubic',
|
|
complete: () => clearInline(items) });
|
|
}
|
|
growBars(root);
|
|
}
|
|
|
|
/** Progress bars grow from zero, so a reset is visible as movement. */
|
|
function growBars(root = view) {
|
|
if (!canAnimate()) return;
|
|
const bars = [...root.querySelectorAll('.bar > span, .thumb-bar > span')];
|
|
if (!bars.length) return;
|
|
bars.forEach((b) => { b.dataset.pct = b.style.width || '0%'; b.style.width = '0%'; });
|
|
anime({ targets: bars, width: (el) => el.dataset.pct, duration: 700,
|
|
delay: anime.stagger(28, { start: 110 }), easing: 'easeOutQuart' });
|
|
}
|
|
|
|
/* ---------- toast / dialog / menu ---------- */
|
|
|
|
function toast(message, kind = 'ok') {
|
|
const el = document.createElement('div');
|
|
el.className = 'toast' + (kind === 'error' ? ' is-error' : '');
|
|
el.innerHTML = `<span class="glyph">${icon(kind === 'error' ? 'alert' : 'check', 16, 2)}</span>
|
|
<span>${esc(message)}</span>`;
|
|
document.getElementById('toasts').appendChild(el);
|
|
setTimeout(() => {
|
|
if (canAnimate()) {
|
|
anime({ targets: el, opacity: 0, translateY: 8, duration: 200, easing: 'easeInQuad',
|
|
complete: () => el.remove() });
|
|
} else {
|
|
el.remove();
|
|
}
|
|
}, 3200);
|
|
}
|
|
|
|
/** Resolves true only if the user confirms. */
|
|
function confirmDialog({ title, body, confirmLabel = 'Confirm', danger = true }) {
|
|
return new Promise((resolve) => {
|
|
const host = document.createElement('div');
|
|
host.className = 'overlay';
|
|
host.innerHTML = `
|
|
<div class="dialog" role="dialog" aria-modal="true">
|
|
<h3>${esc(title)}</h3>
|
|
<p>${esc(body)}</p>
|
|
<div class="dialog-actions">
|
|
<button class="btn" data-cancel>Cancel</button>
|
|
<button class="btn ${danger ? 'btn-solid-danger' : 'btn-accent'}" data-confirm>${esc(confirmLabel)}</button>
|
|
</div>
|
|
</div>`;
|
|
document.getElementById('overlay-root').appendChild(host);
|
|
|
|
const close = (result) => {
|
|
document.removeEventListener('keydown', onKey);
|
|
host.remove();
|
|
resolve(result);
|
|
};
|
|
const onKey = (e) => { if (e.key === 'Escape') close(false); };
|
|
host.querySelector('[data-confirm]').addEventListener('click', () => close(true));
|
|
host.querySelector('[data-cancel]').addEventListener('click', () => close(false));
|
|
host.addEventListener('click', (e) => { if (e.target === host) close(false); });
|
|
document.addEventListener('keydown', onKey);
|
|
host.querySelector('[data-confirm]').focus();
|
|
});
|
|
}
|
|
|
|
function openMenu(anchor, html, onClick) {
|
|
closeMenu();
|
|
const menu = document.createElement('div');
|
|
menu.className = 'menu';
|
|
menu.id = 'account-menu';
|
|
menu.innerHTML = html;
|
|
document.body.appendChild(menu);
|
|
|
|
const r = anchor.getBoundingClientRect();
|
|
menu.style.top = `${Math.round(r.bottom + 8)}px`;
|
|
menu.style.left = `${Math.round(Math.min(r.right - menu.offsetWidth, window.innerWidth - menu.offsetWidth - 12))}px`;
|
|
|
|
menu.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn) return;
|
|
closeMenu();
|
|
onClick(btn.dataset.action);
|
|
});
|
|
setTimeout(() => {
|
|
document.addEventListener('click', onDocClick, { once: true });
|
|
document.addEventListener('keydown', onMenuKey);
|
|
}, 0);
|
|
}
|
|
|
|
const onDocClick = () => closeMenu();
|
|
const onMenuKey = (e) => { if (e.key === 'Escape') closeMenu(); };
|
|
|
|
function closeMenu() {
|
|
const el = document.getElementById('account-menu');
|
|
if (el) el.remove();
|
|
document.removeEventListener('keydown', onMenuKey);
|
|
}
|
|
|
|
/* ---------- chrome (sidebar, tab bar, header) ---------- */
|
|
|
|
const NAV = [
|
|
{ key: 'home', href: '#/', label: 'Home', glyph: 'home' },
|
|
{ key: 'library', href: '#/library', label: 'Library', glyph: 'grid' },
|
|
{ key: 'search', href: '#/search/', label: 'Search', glyph: 'search' },
|
|
{ key: 'settings', href: '#/settings', label: 'Settings', glyph: 'settings' },
|
|
];
|
|
|
|
function activeNav() {
|
|
const h = location.hash || '#/';
|
|
if (h.startsWith('#/search')) return 'search';
|
|
if (h.startsWith('#/settings')) return 'settings';
|
|
if (h.startsWith('#/library') || h.startsWith('#/folder') ||
|
|
h.startsWith('#/watch') || h.startsWith('#/quiz')) return 'library';
|
|
return 'home';
|
|
}
|
|
|
|
function renderChrome() {
|
|
const active = activeNav();
|
|
|
|
document.getElementById('nav-main').innerHTML = NAV.map((n) => `
|
|
<a class="nav-item${n.key === active ? ' is-active' : ''}" href="${n.href}">
|
|
${icon(n.glyph)}${n.label}
|
|
</a>`).join('');
|
|
|
|
document.getElementById('nav-tabs').innerHTML = NAV.map((n) => `
|
|
<a class="tab${n.key === active ? ' is-active' : ''}" href="${n.href}">
|
|
${icon(n.glyph, 20)}${n.label}
|
|
</a>`).join('');
|
|
|
|
const shelves = state.meta ? state.meta.shelves : [];
|
|
document.getElementById('nav-shelves').innerHTML = shelves.map((s) => `
|
|
<a class="shelf-item" href="#/library/${s.key}">
|
|
<span class="shelf-dot" style="--c:${SHELF_HUE[s.key]}"></span>
|
|
<span>${esc(s.name)}</span>
|
|
<span class="shelf-count">${s.count}</span>
|
|
</a>`).join('');
|
|
|
|
const totals = state.meta && state.meta.totals;
|
|
document.getElementById('topbar-totals').textContent = totals
|
|
? [`${totals.courses} course${totals.courses === 1 ? '' : 's'}`,
|
|
fmtBytes(totals.bytes)].filter(Boolean).join(' · ')
|
|
: '';
|
|
|
|
document.getElementById('scan-when').textContent =
|
|
state.scanning ? 'now' : relTime(state.meta && state.meta.last_scan);
|
|
|
|
const avatar = document.getElementById('btn-account');
|
|
avatar.textContent = (state.user || '?').slice(0, 2).toUpperCase();
|
|
avatar.style.background = hueOf((state.user || '').length + 4);
|
|
}
|
|
|
|
async function loadMeta() {
|
|
try {
|
|
state.meta = await api('api/library.php?view=meta');
|
|
} catch (e) {
|
|
state.meta = null; // the view itself will surface the error
|
|
}
|
|
renderChrome();
|
|
}
|
|
|
|
/* ---------- shared pieces ---------- */
|
|
|
|
const bar = (percent, hue) =>
|
|
`<div class="bar"${hue ? ` style="--c:${hue}"` : ''}><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>`;
|
|
|
|
// A course thumbnail is a real captured frame when the scanner made one
|
|
// (see generateThumbnails() server-side); until then, or if the request
|
|
// 404s, onerror drops the broken <img> and the colour block underneath
|
|
// (already painted — cat/dur/play/percent all sit above it) shows through.
|
|
function thumb(hue, { cat, dur, play, percent, courseId } = {}) {
|
|
return `<div class="thumb" style="--c:${hue}">
|
|
${courseId ? `<img src="thumb.php?course_id=${courseId}" alt="" loading="lazy"
|
|
onerror="this.remove()">` : ''}
|
|
${cat ? `<span class="thumb-cat">${esc(cat)}</span>` : ''}
|
|
${dur ? `<span class="thumb-dur">${esc(dur)}</span>` : ''}
|
|
${play ? `<span class="thumb-play"><span>${iconFill('play', 20)}</span></span>` : ''}
|
|
${percent !== undefined
|
|
? `<div class="thumb-bar"><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function courseCard(c) {
|
|
const hue = hueOf(c.id);
|
|
return `<a class="course-card" href="#/folder/${c.id}">
|
|
${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration), courseId: c.id })}
|
|
<div class="course-card-body">
|
|
<div class="course-title">${esc(c.name)}</div>
|
|
<div class="course-meta">${esc(courseMeta(c))}</div>
|
|
<div class="bar-row">
|
|
${bar(c.progress.percent, hue)}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
function courseRow(c) {
|
|
const hue = hueOf(c.id);
|
|
return `<div class="row">
|
|
<a class="row-link" href="#/folder/${c.id}">
|
|
<span class="initial initial-sm" style="--c:${hue}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-fixed">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc(c.path || '')}</div>
|
|
</div>
|
|
<span class="row-col">${esc(SHELF_NAME[c.state])}</span>
|
|
<span class="row-num">${c.videos} vids</span>
|
|
<span class="row-num">${esc(fmtLong(c.duration))}</span>
|
|
<div class="row-bar">
|
|
${bar(c.progress.percent, hue)}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</a>
|
|
<button class="icon-btn" title="Reset progress"
|
|
data-reset="folder" data-id="${c.id}" data-label="${esc(c.name)}">${icon('reset', 15, 1.7)}</button>
|
|
</div>`;
|
|
}
|
|
|
|
const courseList = (courses, mode) => mode === 'list'
|
|
? `<div class="rows">${courses.map(courseRow).join('')}</div>`
|
|
: `<div class="grid">${courses.map(courseCard).join('')}</div>`;
|
|
|
|
function viewToggle() {
|
|
const mode = pref('view');
|
|
const btn = (m, glyph, label) => `
|
|
<button class="seg-icon${mode === m ? ' is-on' : ''}" data-view-mode="${m}"
|
|
title="${label}" aria-label="${label}" aria-pressed="${mode === m}">${icon(glyph, 16, 1.7)}</button>`;
|
|
return `<div class="seg">${btn('grid', 'grid', 'Grid view')}${btn('list', 'list', 'List view')}</div>`;
|
|
}
|
|
|
|
const resetBtn = (scope, id, label, text = 'Reset progress') =>
|
|
`<button class="btn btn-danger" data-reset="${scope}" data-id="${id}" data-label="${esc(label)}">
|
|
${icon('reset', 15, 1.7)}${esc(text)}
|
|
</button>`;
|
|
|
|
function crumbs(list, tail) {
|
|
const parts = list.map((c, i) =>
|
|
(i === list.length - 1 && !tail)
|
|
? `<span class="here">${esc(c.name)}</span>`
|
|
: `<a href="#/folder/${c.id}">${esc(c.name)}</a>`);
|
|
return `<nav class="crumbs">
|
|
<a href="#/library">Library</a>
|
|
${parts.map((p) => `<span class="sep">/</span>${p}`).join('')}
|
|
${tail ? `<span class="sep">/</span><span class="here">${esc(tail)}</span>` : ''}
|
|
</nav>`;
|
|
}
|
|
|
|
const videoRow = (v, extra = '') => {
|
|
const cls = v.completed ? ' is-done' : (v.position > 5 ? ' is-partial' : '');
|
|
return `<a class="row" href="#/watch/${v.id}">
|
|
<span class="play-dot${cls}">${iconFill('play', 12)}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(v.name)}</div>
|
|
${extra ? `<div class="row-sub">${esc(extra)}</div>` : ''}
|
|
${!v.completed && v.percent > 0 ? `<div class="bar-row" style="margin-top:6px">${bar(v.percent)}</div>` : ''}
|
|
</div>
|
|
<span class="row-num">${esc(fmt(v.duration))}</span>
|
|
</a>`;
|
|
};
|
|
|
|
const quizRow = (q, extra = '') => `
|
|
<a class="row" href="#/quiz/${q.id}">
|
|
<span class="play-dot${q.completed ? ' is-done' : ''}">${icon('quiz', 14, 1.7)}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(q.name)}</div>
|
|
<div class="row-sub">${extra ? esc(extra) + ' · ' : ''}Quiz · ${q.questions} question${q.questions === 1 ? '' : 's'}</div>
|
|
</div>
|
|
${q.attempts ? `<span class="pill${q.completed ? '' : ' pill-quiet'}">${q.best_score}/${q.questions}</span>` : ''}
|
|
</a>`;
|
|
|
|
/* ---------- auth ---------- */
|
|
|
|
function showAuth() {
|
|
if (cleanupView) { cleanupView(); cleanupView = null; }
|
|
shell.hidden = true;
|
|
authEl.hidden = false;
|
|
renderLogin();
|
|
}
|
|
|
|
function showShell() {
|
|
authEl.hidden = true;
|
|
shell.hidden = false;
|
|
}
|
|
|
|
function renderLogin(error = '') {
|
|
authEl.innerHTML = `
|
|
<div class="auth-card">
|
|
<div class="auth-brand">
|
|
<span class="brand-mark">${iconFill('play', 15)}</span>
|
|
<span class="brand-name">CourseHub</span>
|
|
</div>
|
|
<h1>Your course library.</h1>
|
|
<p class="sub">Sign in to pick up where you stopped.</p>
|
|
<div id="login-error">${error ? `<div class="alert">${esc(error)}</div>` : ''}</div>
|
|
<form id="login-form">
|
|
<div class="field">
|
|
<label for="f-user">Username</label>
|
|
<input id="f-user" name="username" required autofocus autocomplete="username">
|
|
</div>
|
|
<div class="field">
|
|
<label for="f-pass">Password</label>
|
|
<input id="f-pass" name="password" type="password" required autocomplete="current-password">
|
|
</div>
|
|
<div class="auth-actions">
|
|
<button class="btn btn-accent btn-lg" data-action="login">Sign in</button>
|
|
<button class="btn" data-action="register" type="button">Create account</button>
|
|
</div>
|
|
</form>
|
|
</div>`;
|
|
|
|
const form = document.getElementById('login-form');
|
|
let action = 'login';
|
|
form.querySelectorAll('button[data-action]').forEach((b) =>
|
|
b.addEventListener('click', () => {
|
|
action = b.dataset.action;
|
|
if (action === 'register' && form.reportValidity()) form.requestSubmit();
|
|
}));
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(form);
|
|
try {
|
|
const data = await api('api/auth.php', {
|
|
json: { action, username: fd.get('username'), password: fd.get('password') },
|
|
});
|
|
state.user = data.username;
|
|
showShell();
|
|
toast(action === 'register' ? 'Account created — welcome.' : `Welcome back, ${data.username}.`);
|
|
await loadMeta();
|
|
route();
|
|
} catch (err) {
|
|
document.getElementById('login-error').innerHTML = `<div class="alert">${esc(err.message)}</div>`;
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- home ---------- */
|
|
|
|
function todayLine() {
|
|
const d = new Date();
|
|
const weekday = d.toLocaleDateString(undefined, { weekday: 'long' });
|
|
const month = d.toLocaleDateString(undefined, { month: 'long' });
|
|
return `${weekday}, ${d.getDate()} ${month}`;
|
|
}
|
|
|
|
function heroBlock(resume) {
|
|
if (!resume) {
|
|
return `<div class="empty" style="margin-bottom:32px">
|
|
<h2>Nothing in progress</h2>
|
|
<p>Open a course and start a video — it will show up here with your place kept.</p>
|
|
<a class="btn btn-accent" href="#/library">Browse the library</a>
|
|
</div>`;
|
|
}
|
|
const pct = resume.duration ? Math.min(100, Math.round(100 * resume.position / resume.duration)) : 0;
|
|
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
|
|
const hue = hueOf(resume.course_id ?? resume.id);
|
|
return `<div class="hero">
|
|
<div class="hero-art">${thumb(hue, { play: true, percent: pct, courseId: resume.course_id })}</div>
|
|
<div class="hero-body">
|
|
<span class="hero-eyebrow">Continue watching</span>
|
|
<div class="hero-title">${esc(resume.name)}</div>
|
|
<div class="hero-sub">${esc(resume.folder_path)}</div>
|
|
<div class="hero-progress">
|
|
${bar(pct)}
|
|
<span class="mono">${pct}%${left ? ` · ${fmt(left)} left` : ''}</span>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<a class="btn btn-light" href="#/watch/${resume.id}">${iconFill('play', 15)}Resume</a>
|
|
<a class="btn btn-ghost" href="#/library">${icon('grid', 15)}All courses</a>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function statCards(stats) {
|
|
const cards = [
|
|
{ label: 'In progress', value: String(stats.in_progress), sub: `of ${stats.courses} course${stats.courses === 1 ? '' : 's'}`, hue: '#B85A2E' },
|
|
{ label: 'Finished', value: String(stats.finished), sub: stats.finished ? 'courses complete' : 'nothing finished yet', hue: '#3F7A50' },
|
|
{ label: 'Watched', value: fmtHours(stats.watched_seconds), sub: 'across the library', hue: '#2E6A8C' },
|
|
stats.quizzes_taken
|
|
? { label: 'Quiz average', value: `${stats.quiz_average}%`, sub: `${stats.quizzes_taken} quiz${stats.quizzes_taken === 1 ? '' : 'zes'} taken`, hue: '#7A4560' }
|
|
: { label: 'Quiz average', value: '—', sub: 'no quizzes taken yet', hue: '#7A4560' },
|
|
];
|
|
return `<div class="grid-stats">${cards.map((s) => `
|
|
<div class="card">
|
|
<div class="stat-label"><span class="swatch" style="--c:${s.hue}"></span><span>${s.label}</span></div>
|
|
<div class="stat-value">${esc(s.value)}</div>
|
|
<div class="stat-sub">${esc(s.sub)}</div>
|
|
</div>`).join('')}</div>`;
|
|
}
|
|
|
|
async function renderHome() {
|
|
spinner();
|
|
const data = await api('api/library.php?view=home');
|
|
|
|
const inProgress = data.in_progress.slice(0, 4);
|
|
const progressBlock = inProgress.length
|
|
? `<div class="grid" style="margin-bottom:32px">${inProgress.map(courseCard).join('')}</div>`
|
|
: `<div class="empty" style="margin-bottom:32px">
|
|
<h2>No course started</h2>
|
|
<p>Everything in the library is still untouched.</p>
|
|
<a class="btn btn-accent" href="#/library">Open the library</a>
|
|
</div>`;
|
|
|
|
const recentBlock = data.recent.length ? `
|
|
<h2 style="margin:0 0 14px">Found in the last scan</h2>
|
|
<div class="stack">
|
|
${data.recent.map((c) => `
|
|
<a class="row-card" href="#/folder/${c.id}">
|
|
<span class="initial" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc(c.path)}</div>
|
|
</div>
|
|
${c.is_new ? '<span class="pill">New</span>' : ''}
|
|
<span class="row-num" style="text-align:right">${esc([courseMeta(c), fmtBytes(c.bytes)].filter(Boolean).join(' · '))}</span>
|
|
</a>`).join('')}
|
|
</div>` : '';
|
|
|
|
view.innerHTML = `
|
|
<div class="page">
|
|
<div class="eyebrow">${esc(todayLine())}</div>
|
|
<h1 style="margin:10px 0 24px">Pick up where you stopped.</h1>
|
|
${heroBlock(data.resume)}
|
|
${statCards(data.stats)}
|
|
<div class="section-head">
|
|
<h2>In progress</h2>
|
|
<a class="btn-quiet" href="#/library">All courses</a>
|
|
</div>
|
|
${progressBlock}
|
|
${recentBlock}
|
|
</div>`;
|
|
|
|
reveal();
|
|
}
|
|
|
|
/* ---------- library ---------- */
|
|
|
|
async function renderLibrary(filter) {
|
|
spinner();
|
|
const data = await api('api/library.php?view=library');
|
|
|
|
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
|
|
const mode = pref('view');
|
|
const active = filter || 'all';
|
|
const shown = active === 'all' ? data.courses : data.courses.filter((c) => c.state === active);
|
|
|
|
const chips = ['all', 'progress', 'finished', 'fresh'].map((k) => `
|
|
<a class="chip${k === active ? ' is-on' : ''}" href="#/library${k === 'all' ? '' : '/' + k}">
|
|
<span class="dot" style="--c:${SHELF_HUE[k]}"></span>${esc(SHELF_NAME[k])}
|
|
</a>`).join('');
|
|
|
|
let body;
|
|
if (!data.courses.length) {
|
|
body = `<div class="empty">
|
|
<h2>The library is empty</h2>
|
|
<p>Add course folders to the videos directory, then scan.</p>
|
|
<button class="btn btn-accent" data-scan>Scan library</button>
|
|
</div>`;
|
|
} else if (layout === 'shelves') {
|
|
body = data.shelves.map((s) => `
|
|
<div style="margin-bottom:34px">
|
|
<div class="shelf-head">
|
|
<span class="swatch" style="--c:${SHELF_HUE[s.key]}"></span>
|
|
<h2>${esc(s.name)}</h2>
|
|
<span class="mono">${s.courses.length} course${s.courses.length === 1 ? '' : 's'}</span>
|
|
<div class="rule"></div>
|
|
</div>
|
|
${courseList(s.courses, mode)}
|
|
</div>`).join('');
|
|
} else {
|
|
body = `<div class="chips">${chips}</div>` + (shown.length
|
|
? courseList(shown, mode)
|
|
: `<div class="empty"><h2>Nothing on this shelf</h2>
|
|
<p>No course is ${esc(SHELF_NAME[active].toLowerCase())} right now.</p>
|
|
<a class="btn" href="#/library">Show every course</a></div>`);
|
|
}
|
|
|
|
const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0);
|
|
|
|
view.innerHTML = `
|
|
<div>
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>Library</h1>
|
|
<p class="page-sub">${data.courses.length} course${data.courses.length === 1 ? '' : 's'} ·
|
|
${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine</p>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<div class="seg">
|
|
<button class="${layout === 'shelves' ? 'is-on' : ''}" data-layout="shelves">Shelves</button>
|
|
<button class="${layout === 'catalog' ? 'is-on' : ''}" data-layout="catalog">Catalog</button>
|
|
</div>
|
|
${viewToggle()}
|
|
</div>
|
|
</div>
|
|
${body}
|
|
</div>`;
|
|
|
|
reveal();
|
|
}
|
|
|
|
/* ---------- folder ---------- */
|
|
|
|
async function renderFolder(id) {
|
|
spinner();
|
|
const data = await api(`api/library.php?view=folder&id=${id}`);
|
|
const mode = pref('view');
|
|
const hue = hueOf(data.course_id);
|
|
|
|
const sections = data.subfolders.length ? `
|
|
<div class="section-head">
|
|
<h2 class="h-section">Sections</h2>
|
|
${viewToggle()}
|
|
</div>
|
|
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '';
|
|
|
|
const items = [
|
|
...data.videos.map((v) => ({ ...v, kind: 'video' })),
|
|
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
|
|
].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' }));
|
|
|
|
const content = items.length ? `
|
|
<h2 class="h-section" style="margin-bottom:14px">Content</h2>
|
|
<div class="rows">${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}</div>`
|
|
: (!data.subfolders.length
|
|
? `<div class="empty"><h2>This folder is empty</h2>
|
|
<p>No videos or quizzes were found here in the last scan.</p></div>` : '');
|
|
|
|
const meta = [
|
|
data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '',
|
|
data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '',
|
|
fmtLong(data.rollup.duration),
|
|
fmtBytes(data.rollup.bytes),
|
|
].filter(Boolean).join(' · ');
|
|
|
|
view.innerHTML = `
|
|
<div class="page">
|
|
${crumbs(data.breadcrumbs)}
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>${esc(data.folder.name)}</h1>
|
|
<p class="page-sub">${esc(meta || 'Empty folder')}</p>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:14px">
|
|
<span class="mono-sm">
|
|
${data.progress.completed} / ${data.progress.total} done
|
|
</span>
|
|
${data.progress.total ? resetBtn('folder', data.folder.id, data.folder.name) : ''}
|
|
</div>
|
|
</div>
|
|
<div class="bar-row" style="margin:0 0 32px">
|
|
${bar(data.progress.percent, hue)}
|
|
<span class="mono">${data.progress.percent}%</span>
|
|
</div>
|
|
${sections}${content}
|
|
</div>`;
|
|
|
|
reveal();
|
|
}
|
|
|
|
/* ---------- search ---------- */
|
|
|
|
async function renderSearch(query) {
|
|
const seq = ++state.searchSeq;
|
|
|
|
if (!query) {
|
|
view.innerHTML = `
|
|
<div class="page-mid">
|
|
<h1>Search the library</h1>
|
|
<p class="page-sub">Type to match course titles, folder paths, and individual video names.</p>
|
|
</div>`;
|
|
reveal();
|
|
document.getElementById('search-input').focus();
|
|
return;
|
|
}
|
|
|
|
spinner();
|
|
const data = await api(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
|
|
if (seq !== state.searchSeq) return; // a newer keystroke already won
|
|
|
|
const total = data.folders.length + data.videos.length + data.quizzes.length;
|
|
|
|
const section = (title, count, html) => !count ? '' : `
|
|
<div style="margin-bottom:32px">
|
|
<div class="eyebrow eyebrow-sm" style="margin-bottom:12px">${title}</div>
|
|
${html}
|
|
</div>`;
|
|
|
|
const courses = `<div class="stack">${data.folders.map((c) => `
|
|
<a class="row-card" href="#/folder/${c.id}">
|
|
<span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc([courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
|
|
</div>
|
|
<div class="row-bar" style="flex:none;width:120px">
|
|
${bar(c.progress.percent, hueOf(c.id))}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</a>`).join('')}</div>`;
|
|
|
|
const videos = `<div class="rows">${data.videos.map((v) => videoRow(v, v.folder_path)).join('')}</div>`;
|
|
const quizzes = `<div class="rows">${data.quizzes.map((q) => quizRow(q, q.folder_path)).join('')}</div>`;
|
|
|
|
view.innerHTML = `
|
|
<div class="page-mid">
|
|
<h1>${total} result${total === 1 ? '' : 's'} for “${esc(query)}”</h1>
|
|
<p class="page-sub" style="margin-bottom:26px">Matches across course titles, folder paths, and video names.</p>
|
|
${total === 0 ? `
|
|
<div class="empty">
|
|
<h2>No files match “${esc(query)}”</h2>
|
|
<p>Try a different word, or scan the library for new files.</p>
|
|
<button class="btn btn-accent" data-scan>Scan library</button>
|
|
</div>`
|
|
: section('Courses and sections', data.folders.length, courses)
|
|
+ section('Videos', data.videos.length, videos)
|
|
+ section('Quizzes', data.quizzes.length, quizzes)}
|
|
</div>`;
|
|
|
|
reveal();
|
|
}
|
|
|
|
/* ---------- watch ---------- */
|
|
|
|
async function renderWatch(id) {
|
|
spinner();
|
|
const data = await api(`api/library.php?view=video&id=${id}`);
|
|
|
|
const playlist = data.playlist.map((p) => {
|
|
const active = p.id === data.video.id;
|
|
const cls = p.completed ? ' is-done' : (p.position > 5 ? ' is-partial' : '');
|
|
return `<a class="row${active ? ' is-active' : ''}" href="#/watch/${p.id}">
|
|
<span class="play-dot${cls}">${iconFill('play', 11)}</span>
|
|
<div class="row-main"><div class="row-title">${esc(p.name)}</div></div>
|
|
<span class="row-num">${esc(fmt(p.duration))}</span>
|
|
</a>`;
|
|
}).join('');
|
|
|
|
const quizzes = data.quizzes.length ? `
|
|
<div class="playlist" style="margin-top:16px">
|
|
<div class="playlist-head">${icon('quiz', 15, 1.7)}Quizzes in this section</div>
|
|
<div>${data.quizzes.map((q) => quizRow(q)).join('')}</div>
|
|
</div>` : '';
|
|
|
|
view.innerHTML = `
|
|
<div class="page">
|
|
${crumbs(data.breadcrumbs, data.video.name)}
|
|
<div class="watch">
|
|
<div>
|
|
<div class="player-frame">
|
|
<video id="player" playsinline controls>
|
|
<source src="${data.stream_url}" type="video/mp4">
|
|
</video>
|
|
</div>
|
|
<div class="watch-head">
|
|
<h1>${esc(data.video.name)}</h1>
|
|
<div class="watch-actions">
|
|
${data.prev_id ? `<a class="btn" href="#/watch/${data.prev_id}">${icon('prev', 15, 1.7)}Previous</a>` : ''}
|
|
${data.next_id ? `<a class="btn" href="#/watch/${data.next_id}">Next${icon('next', 15, 1.7)}</a>` : ''}
|
|
${resetBtn('video', data.video.id, data.video.name, 'Reset')}
|
|
</div>
|
|
</div>
|
|
${data.resume_at > 0
|
|
? `<div class="watch-note">${icon('clock', 14)}Resuming from ${fmt(data.resume_at)}</div>` : ''}
|
|
<div class="watch-note" id="save-status"></div>
|
|
</div>
|
|
<div>
|
|
<div class="playlist">
|
|
<div class="playlist-head">${icon('list_ol', 15, 1.7)}${esc(data.folder.name)}</div>
|
|
<div class="playlist-scroll">${playlist}</div>
|
|
</div>
|
|
${quizzes}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
reveal();
|
|
cleanupView = initPlayer(data);
|
|
}
|
|
|
|
/* ---------- quiz ---------- */
|
|
|
|
async function renderQuiz(id) {
|
|
spinner();
|
|
const data = await api(`api/quiz.php?id=${id}`);
|
|
|
|
const answers = data.questions.map(() => []);
|
|
let graded = null; // server results once submitted
|
|
|
|
function optionClass(qi, oi) {
|
|
const picked = answers[qi].includes(oi);
|
|
if (!graded) return picked ? ' is-picked' : '';
|
|
const r = graded.results[qi];
|
|
if (r.correct_options.includes(oi)) return ' is-correct';
|
|
if (r.selected.includes(oi)) return ' is-missed';
|
|
return '';
|
|
}
|
|
|
|
function draw() {
|
|
const questions = data.questions.map((q, qi) => {
|
|
const r = graded ? graded.results[qi] : null;
|
|
const cardCls = r ? (r.correct ? ' is-right' : ' is-wrong') : '';
|
|
return `<div class="q-card${cardCls}" data-qi="${qi}">
|
|
<div class="q-head">
|
|
<span class="q-num">${qi + 1}</span>
|
|
<div style="flex:1">
|
|
<div class="q-text">${esc(q.text)}</div>
|
|
${q.multi && !r ? '<div class="q-note" style="color:var(--ink-3)">Select every answer that applies.</div>' : ''}
|
|
${r ? `<div class="q-note${r.correct ? '' : ' is-wrong'}">${r.correct
|
|
? 'Correct.' : 'Not quite — the right answer is highlighted below.'}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="q-options">
|
|
${q.options.map((o, oi) => {
|
|
const tag = graded
|
|
? (graded.results[qi].correct_options.includes(oi) ? 'Correct'
|
|
: (graded.results[qi].selected.includes(oi) ? 'Your answer' : ''))
|
|
: '';
|
|
return `<button type="button" class="q-option${q.multi ? ' multi' : ''}${optionClass(qi, oi)}"
|
|
data-qi="${qi}" data-oi="${oi}"${graded ? ' disabled' : ''}>
|
|
<span class="mark"></span>
|
|
<span class="label">${esc(o)}</span>
|
|
<span class="tag">${tag}</span>
|
|
</button>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const answered = answers.filter((a) => a.length).length;
|
|
const scoreBlock = graded ? (() => {
|
|
const pass = graded.completed;
|
|
return `<div class="quiz-score${pass ? '' : ' is-fail'}">
|
|
<div class="quiz-score-num">${graded.score}/${graded.total}</div>
|
|
<div class="quiz-score-body">
|
|
<div class="quiz-score-head">${pass ? 'Quiz passed' : 'Not passed yet'}</div>
|
|
<div class="quiz-score-sub">${pass
|
|
? 'Every question answered correctly — this quiz counts as complete.'
|
|
: `${graded.total - graded.score} question${graded.total - graded.score === 1 ? '' : 's'} to get right for a complete pass.`}</div>
|
|
</div>
|
|
<button class="btn" data-quiz-retry>Try again</button>
|
|
</div>`;
|
|
})() : '';
|
|
|
|
view.innerHTML = `
|
|
<div class="page-narrow">
|
|
<a class="back-link" href="#/folder/${data.folder.id}">${icon('back', 14, 1.8)}Back to ${esc(data.folder.name)}</a>
|
|
<div class="eyebrow eyebrow-sm">${esc(data.folder.name)}</div>
|
|
<h1 style="margin:10px 0 6px">${esc(data.quiz.name)}</h1>
|
|
<p class="page-sub" style="margin-bottom:24px">
|
|
${data.questions.length} question${data.questions.length === 1 ? '' : 's'}.
|
|
Answer them all, then submit — nothing is graded until you do.
|
|
${data.progress.attempts ? `Best so far ${data.progress.best_score}/${data.progress.total}.` : ''}
|
|
</p>
|
|
${scoreBlock}
|
|
<div class="stack" style="gap:14px">${questions}</div>
|
|
<div class="quiz-actions">
|
|
<button class="btn btn-accent btn-lg" data-quiz-submit${graded ? ' disabled' : ''}>Submit answers</button>
|
|
<span class="hint">${graded
|
|
? `${graded.score} of ${graded.total} correct`
|
|
: `${answered} of ${data.questions.length} answered`}</span>
|
|
${data.progress.attempts ? resetBtn('quiz', data.quiz.id, data.quiz.name, 'Reset attempts') : ''}
|
|
</div>
|
|
</div>`;
|
|
reveal();
|
|
}
|
|
|
|
view.onclick = null;
|
|
draw();
|
|
|
|
// One delegated handler for the whole quiz view, re-bound on each draw.
|
|
const onClick = async (e) => {
|
|
const opt = e.target.closest('.q-option');
|
|
if (opt && !graded) {
|
|
const qi = +opt.dataset.qi, oi = +opt.dataset.oi;
|
|
const multi = data.questions[qi].multi;
|
|
if (multi) {
|
|
const at = answers[qi].indexOf(oi);
|
|
if (at === -1) answers[qi].push(oi); else answers[qi].splice(at, 1);
|
|
} else {
|
|
answers[qi] = [oi];
|
|
}
|
|
draw();
|
|
return;
|
|
}
|
|
if (e.target.closest('[data-quiz-submit]') && !graded) {
|
|
try {
|
|
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
|
|
draw();
|
|
markGraded();
|
|
toast(graded.completed
|
|
? `Perfect score — ${graded.score}/${graded.total}.`
|
|
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
|
|
loadMeta();
|
|
} catch (err) {
|
|
toast(err.message, 'error');
|
|
}
|
|
return;
|
|
}
|
|
if (e.target.closest('[data-quiz-retry]')) {
|
|
graded = null;
|
|
answers.forEach((a, i) => { answers[i] = []; });
|
|
draw();
|
|
}
|
|
};
|
|
view.addEventListener('click', onClick);
|
|
cleanupView = () => view.removeEventListener('click', onClick);
|
|
|
|
/** Correct cards settle into place; wrong ones shake their heads. */
|
|
function markGraded() {
|
|
if (!canAnimate()) return;
|
|
graded.results.forEach((r, qi) => {
|
|
const card = view.querySelector(`[data-qi="${qi}"].q-card`);
|
|
if (!card) return;
|
|
if (r.correct) {
|
|
anime({ targets: card, scale: [0.98, 1], duration: 420, delay: qi * 45,
|
|
easing: 'easeOutBack', complete: () => clearInline([card]) });
|
|
} else {
|
|
anime({ targets: card, duration: 440, delay: qi * 45, easing: 'easeInOutSine',
|
|
keyframes: [{ translateX: -7 }, { translateX: 6 }, { translateX: -4 }, { translateX: 0 }],
|
|
complete: () => clearInline([card]) });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ---------- settings ---------- */
|
|
|
|
async function renderSettings() {
|
|
spinner();
|
|
const data = await api('api/library.php?view=settings');
|
|
|
|
const toggles = [
|
|
{ key: 'autoplay', label: 'Autoplay next video',
|
|
sub: 'Starts the next lesson a moment after one ends.' },
|
|
{ key: 'motion', label: 'Animate transitions',
|
|
sub: 'Fades views in and grows progress bars. Off is always honoured when your system asks for reduced motion.' },
|
|
{ key: 'list-default', label: 'Open the library in list view',
|
|
sub: 'Otherwise courses open as a grid of cards.' },
|
|
];
|
|
|
|
const isOn = (key) => key === 'list-default' ? pref('view') === 'list' : pref(key) !== false;
|
|
|
|
view.innerHTML = `
|
|
<div class="page-narrow">
|
|
<h1 style="margin-bottom:26px">Settings</h1>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Library folder</h3>
|
|
<p>Scanned for courses, videos and quizzes on demand.</p>
|
|
<div class="stack" style="margin-bottom:14px">
|
|
<div class="path-row">
|
|
${icon('folder', 16)}
|
|
<span class="path">${esc(data.root)}</span>
|
|
<span class="count">${data.totals.courses} course${data.totals.courses === 1 ? '' : 's'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="fact-row"><span>Indexed</span>
|
|
<span class="mono">${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}</span></div>
|
|
<div class="fact-row"><span>Total running time</span>
|
|
<span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div>
|
|
<div class="fact-row"><span>A video counts as watched at</span>
|
|
<span class="mono">${data.complete_threshold}%</span></div>
|
|
<div class="fact-row"><span>Last scan</span>
|
|
<span class="mono">${esc(relTime(data.last_scan))}</span></div>
|
|
${data.root_exists ? '' : `<div class="alert" style="margin-top:14px">
|
|
This folder is not readable right now — scanning will fail until it is back.</div>`}
|
|
<div style="display:flex;gap:10px;margin-top:16px">
|
|
<button class="btn btn-accent" data-scan>Scan library</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Playback and display</h3>
|
|
<p>Stored in this browser, for this device.</p>
|
|
${toggles.map((t) => `
|
|
<div class="toggle-row">
|
|
<div style="flex:1">
|
|
<div class="label">${esc(t.label)}</div>
|
|
<div class="sub">${esc(t.sub)}</div>
|
|
</div>
|
|
<button class="switch${isOn(t.key) ? ' is-on' : ''}" role="switch"
|
|
aria-checked="${isOn(t.key)}" aria-label="${esc(t.label)}"
|
|
data-toggle="${t.key}"><span></span></button>
|
|
</div>`).join('')}
|
|
</section>
|
|
|
|
<section class="card settings-section is-danger">
|
|
<h3>Reset progress</h3>
|
|
<p>You have progress on ${data.progress_courses} of ${data.courses} course${data.courses === 1 ? '' : 's'}.
|
|
Resetting clears watched marks, resume positions and quiz scores. Video files are never touched.</p>
|
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
|
${resetBtn('all', 0, '', 'Reset all progress')}
|
|
<span class="hint-inline">
|
|
Or reset one course from its row in the library list.</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Account</h3>
|
|
<p>Signed in as ${esc(data.username || '')}.
|
|
${data.registration_open ? 'New accounts can be created from the sign-in page.'
|
|
: 'Registration is closed on this install.'}</p>
|
|
<button class="btn" data-logout>${icon('logout', 15)}Sign out</button>
|
|
</section>
|
|
</div>`;
|
|
|
|
reveal();
|
|
}
|
|
|
|
/* ---------- player + progress tracking ---------- */
|
|
|
|
function initPlayer(data) {
|
|
const el = document.getElementById('player');
|
|
const statusEl = document.getElementById('save-status');
|
|
const player = new Plyr(el, {
|
|
keyboard: { focused: true, global: true },
|
|
seekTime: 10,
|
|
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
|
|
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
|
|
'current-time', 'duration', 'mute', 'volume', 'settings',
|
|
'pip', 'fullscreen'],
|
|
});
|
|
|
|
let resumed = data.resume_at <= 0;
|
|
el.addEventListener('loadedmetadata', () => {
|
|
if (!resumed && data.resume_at < el.duration - 2) el.currentTime = data.resume_at;
|
|
resumed = true;
|
|
});
|
|
|
|
let lastSaved = -1;
|
|
let saveTimer = null;
|
|
|
|
const payload = () => ({
|
|
video_id: data.video.id,
|
|
position: el.currentTime || 0,
|
|
duration: el.duration || 0,
|
|
});
|
|
|
|
const status = (html) => { if (statusEl) statusEl.innerHTML = html; };
|
|
|
|
async function save() {
|
|
const p = payload();
|
|
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
|
|
if (!p.duration || Math.abs(p.position - lastSaved) < 1) return;
|
|
lastSaved = p.position;
|
|
try {
|
|
const res = await api('api/progress.php', { json: p, keepalive: true });
|
|
status(res.completed
|
|
? `${icon('check', 14, 2)}Completed`
|
|
: `${icon('clock', 14)}Progress saved`);
|
|
} catch (e) { /* transient — the next heartbeat retries */ }
|
|
}
|
|
|
|
function saveBeacon() {
|
|
const p = payload();
|
|
if (state.suppressSave || !p.duration) return;
|
|
navigator.sendBeacon('api/progress.php',
|
|
new URLSearchParams(Object.entries(p).map(([k, v]) => [k, String(v)])));
|
|
}
|
|
|
|
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); });
|
|
player.on('pause', save);
|
|
player.on('seeked', save);
|
|
player.on('ended', () => {
|
|
save();
|
|
if (data.next_id && pref('autoplay') !== false) {
|
|
status(`${icon('next', 14, 1.7)}Playing next video…`);
|
|
setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
|
|
}
|
|
});
|
|
|
|
const onHide = () => { if (document.visibilityState === 'hidden') saveBeacon(); };
|
|
document.addEventListener('visibilitychange', onHide);
|
|
window.addEventListener('pagehide', saveBeacon);
|
|
|
|
return function cleanup() {
|
|
saveBeacon();
|
|
if (saveTimer) clearInterval(saveTimer);
|
|
document.removeEventListener('visibilitychange', onHide);
|
|
window.removeEventListener('pagehide', saveBeacon);
|
|
try { player.destroy(); } catch (e) { /* already gone */ }
|
|
};
|
|
}
|
|
|
|
/* ---------- reset ---------- */
|
|
|
|
const RESET_COPY = {
|
|
all: () => ['Reset all progress?',
|
|
'Every saved position, watched mark and quiz result on your account is deleted. '
|
|
+ 'Your video files stay where they are.'],
|
|
folder: (label) => [`Reset progress on “${label}”?`,
|
|
'Saved positions, watched marks and quiz results inside this folder, including its '
|
|
+ 'subfolders, are deleted. Your video files stay where they are.'],
|
|
video: (label) => [`Reset progress on “${label}”?`,
|
|
'The saved position and watched mark for this video are deleted.'],
|
|
quiz: (label) => [`Reset attempts on “${label}”?`,
|
|
'Your attempts, best score and completion mark for this quiz are deleted.'],
|
|
};
|
|
|
|
async function doReset(scope, id, label) {
|
|
const copy = RESET_COPY[scope];
|
|
if (!copy) return;
|
|
const [title, body] = copy(label);
|
|
if (!await confirmDialog({ title, body, confirmLabel: 'Reset' })) return;
|
|
|
|
// The player's teardown would otherwise re-save the position just wiped.
|
|
state.suppressSave = true;
|
|
try {
|
|
const res = await api('api/reset.php', { json: { scope, id } });
|
|
const n = res.reset.videos + res.reset.quizzes;
|
|
toast(n
|
|
? `Progress reset — ${res.reset.videos} video${res.reset.videos === 1 ? '' : 's'}, `
|
|
+ `${res.reset.quizzes} quiz${res.reset.quizzes === 1 ? '' : 'zes'}.`
|
|
: 'Nothing to reset — no progress was saved here.');
|
|
await loadMeta();
|
|
route();
|
|
} catch (err) {
|
|
state.suppressSave = false;
|
|
toast(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
/* ---------- scan ---------- */
|
|
|
|
async function runScan() {
|
|
if (state.scanning) return;
|
|
state.scanning = true;
|
|
shell.classList.add('is-scanning');
|
|
document.getElementById('scan-label').textContent = 'Scanning…';
|
|
document.getElementById('scan-when').textContent = 'now';
|
|
document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = true; });
|
|
|
|
try {
|
|
const res = await api('api/scan.php', { method: 'POST' });
|
|
const s = res.stats;
|
|
toast(`Scan finished — ${s.folders} folders, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
|
|
await loadMeta();
|
|
route();
|
|
} catch (err) {
|
|
toast(err.message, 'error');
|
|
} finally {
|
|
state.scanning = false;
|
|
shell.classList.remove('is-scanning');
|
|
document.getElementById('scan-label').textContent = 'Scan library';
|
|
document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = false; });
|
|
renderChrome();
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ }
|
|
state.user = null;
|
|
state.meta = null;
|
|
location.hash = '#/';
|
|
showAuth();
|
|
}
|
|
|
|
/* ---------- router ---------- */
|
|
|
|
async function route() {
|
|
if (cleanupView) { cleanupView(); cleanupView = null; }
|
|
state.suppressSave = false; // the teardown above is the only thing it guarded
|
|
closeMenu();
|
|
|
|
if (!state.user) { showAuth(); return; }
|
|
showShell();
|
|
|
|
const hash = location.hash || '#/';
|
|
const searchMatch = hash.match(/^#\/search\/(.*)$/);
|
|
syncSearchInput(searchMatch ? decodeURIComponent(searchMatch[1]) : '');
|
|
renderChrome();
|
|
|
|
let m;
|
|
try {
|
|
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
|
|
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
|
|
else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]);
|
|
else if (searchMatch) await renderSearch(decodeURIComponent(searchMatch[1]));
|
|
else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null);
|
|
else if (hash.startsWith('#/settings')) await renderSettings();
|
|
else await renderHome();
|
|
} catch (err) {
|
|
if (state.user) {
|
|
view.innerHTML = `<div class="empty">
|
|
<h2>That didn't load</h2>
|
|
<p>${esc(err.message)}</p>
|
|
<a class="btn btn-accent" href="#/">Back to home</a>
|
|
</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- search box ---------- */
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const searchClear = document.getElementById('btn-search-clear');
|
|
let searchTimer = null;
|
|
|
|
function syncSearchInput(query) {
|
|
if (document.activeElement !== searchInput && searchInput.value !== query) {
|
|
searchInput.value = query;
|
|
}
|
|
searchClear.hidden = !searchInput.value;
|
|
}
|
|
|
|
/** Search routes replace each other, so Back doesn't walk through keystrokes. */
|
|
function goSearch(query, replace) {
|
|
const target = query ? `#/search/${encodeURIComponent(query)}` : '#/search/';
|
|
if (location.hash === target) { route(); return; }
|
|
if (replace) {
|
|
history.replaceState(null, '', target);
|
|
route(); // replaceState doesn't fire hashchange
|
|
} else {
|
|
location.hash = target;
|
|
}
|
|
}
|
|
|
|
searchInput.addEventListener('input', () => {
|
|
searchClear.hidden = !searchInput.value;
|
|
clearTimeout(searchTimer);
|
|
const q = searchInput.value.trim();
|
|
if (q && q.length < 2) return; // one character matches nearly everything
|
|
searchTimer = setTimeout(() => {
|
|
const onSearch = location.hash.startsWith('#/search/');
|
|
if (!q && !onSearch) return;
|
|
goSearch(q, onSearch); // replace while refining a search
|
|
}, 350);
|
|
});
|
|
|
|
searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Enter') return;
|
|
e.preventDefault();
|
|
clearTimeout(searchTimer);
|
|
goSearch(searchInput.value.trim(), location.hash.startsWith('#/search/'));
|
|
});
|
|
|
|
searchClear.addEventListener('click', () => {
|
|
clearTimeout(searchTimer);
|
|
searchInput.value = '';
|
|
searchClear.hidden = true;
|
|
searchInput.focus();
|
|
if (location.hash.startsWith('#/search/')) goSearch('', true);
|
|
});
|
|
|
|
/* ---------- delegated actions ---------- */
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const scan = e.target.closest('[data-scan]');
|
|
if (scan) { e.preventDefault(); runScan(); return; }
|
|
|
|
const layout = e.target.closest('[data-view-mode]');
|
|
if (layout) { setPref('view', layout.dataset.viewMode); route(); return; }
|
|
|
|
const shelvesOrCatalog = e.target.closest('[data-layout]');
|
|
if (shelvesOrCatalog) {
|
|
setPref('layout', shelvesOrCatalog.dataset.layout);
|
|
if (location.hash.startsWith('#/library/')) { location.hash = '#/library'; return; }
|
|
route();
|
|
return;
|
|
}
|
|
|
|
const toggle = e.target.closest('[data-toggle]');
|
|
if (toggle) {
|
|
const key = toggle.dataset.toggle;
|
|
if (key === 'list-default') {
|
|
setPref('view', pref('view') === 'list' ? 'grid' : 'list');
|
|
} else {
|
|
setPref(key, pref(key) === false);
|
|
}
|
|
route();
|
|
return;
|
|
}
|
|
|
|
const reset = e.target.closest('[data-reset]');
|
|
if (reset) {
|
|
e.preventDefault();
|
|
doReset(reset.dataset.reset, +reset.dataset.id || 0, reset.dataset.label || '');
|
|
return;
|
|
}
|
|
|
|
if (e.target.closest('[data-logout]')) { logout(); return; }
|
|
});
|
|
|
|
document.getElementById('btn-account').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (document.getElementById('account-menu')) { closeMenu(); return; }
|
|
openMenu(e.currentTarget, `
|
|
<div class="menu-user">
|
|
<div class="who">${esc(state.user || '')}</div>
|
|
<div class="what">${state.meta ? state.meta.totals.videos : 0} videos in the library</div>
|
|
</div>
|
|
<button data-action="settings">${icon('settings', 15)}Settings</button>
|
|
<button data-action="reset">${icon('reset', 15, 1.7)}Reset all progress</button>
|
|
<button class="is-danger" data-action="logout">${icon('logout', 15)}Sign out</button>`,
|
|
(action) => {
|
|
if (action === 'settings') location.hash = '#/settings';
|
|
else if (action === 'reset') doReset('all', 0, '');
|
|
else if (action === 'logout') logout();
|
|
});
|
|
});
|
|
|
|
/* ---------- boot ---------- */
|
|
|
|
window.addEventListener('hashchange', route);
|
|
|
|
(async function boot() {
|
|
try {
|
|
const me = await fetch('api/auth.php').then((r) => r.json());
|
|
state.user = me.logged_in ? me.username : null;
|
|
} catch (e) {
|
|
state.user = null;
|
|
}
|
|
|
|
if (!state.user) { showAuth(); return; }
|
|
showShell();
|
|
await loadMeta();
|
|
route();
|
|
|
|
if (canAnimate()) {
|
|
anime({ targets: '.brand-mark', scale: [0.5, 1], rotate: [-24, 0], opacity: [0, 1],
|
|
duration: 700, easing: 'easeOutElastic(1, 0.7)' });
|
|
}
|
|
})();
|
|
})();
|