2fa7452253
- A quiz question can now carry the diagram it refers to: a Markdown image line anywhere inside the question block is parsed out of the text and rendered under it (referrerpolicy="no-referrer", lazy). The URL points at the course provider, so an offline install gets a note and a link instead of an empty box. - New optional SESSION_LIFETIME (30 days in the example config) replaces the browser-session cookie plus php.ini's 24-minute idle limit, which signed people out over a long pause. Configs that predate it behave as before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy no longer costs the flag. - Error responses no longer echo the exception message unless DEBUG: a PDOException names the DB user, host and tables, and that handler also runs for visitors who never signed in. - The .htaccess denials are anchored to a path segment rather than the root, so videos/ and includes/ stay unreachable under a prefix such as /coursehub/ instead of silently opening up. - A rescan takes a MySQL advisory lock; a second one answers "already running" rather than racing the first and flagging its rows missing. A file swapped in at the same path with a different size drops its cached duration so the probe reads the new length. - stream.php discards output buffering and zlib compression (both break the promised Content-Length) and answers HEAD with headers alone. - A progress save with no duration keeps the length already stored instead of nulling it, and the folder view builds its rollup once. - In the SPA, a view whose data arrives after the route moved on is discarded — previously a slow load could paint over a newer screen or start a heartbeat for a video nobody was watching. Also: the quiz submit button can't be double-fired, leaving a video during the autoplay countdown no longer jumps ahead later, the final position is always saved on 'ended', the playlist scrolls the current entry into its own box, the login field takes focus, views start at the top, and a stray "%" in the hash no longer throws. - Dropped the unused requireLogin(); registerUser() answers "taken" instead of a 500 when two people race for the same name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
1412 lines
58 KiB
JavaScript
1412 lines
58 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,
|
|
csrf: null, // session token, echoed back on every POST
|
|
meta: null, // totals / shelves / last scan, for the chrome
|
|
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
|
|
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 };
|
|
|
|
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 = {}) {
|
|
options.headers = Object.assign({}, options.headers);
|
|
if (state.csrf) options.headers['X-CSRF-Token'] = state.csrf;
|
|
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;
|
|
}
|
|
|
|
/* Every route() bumps this. A view that was still waiting on its data when
|
|
the route moved on must not paint over the newer view — nor, for the
|
|
player, start a progress heartbeat for a video nobody is watching. */
|
|
let routeSeq = 0;
|
|
class StaleRender extends Error {}
|
|
|
|
/** api() for a view's data: throws StaleRender if the route changed meanwhile. */
|
|
async function load(url) {
|
|
const seq = routeSeq;
|
|
const data = await api(url);
|
|
if (seq !== routeSeq) throw new StaleRender('route changed');
|
|
return data;
|
|
}
|
|
|
|
/** decodeURIComponent that survives a stray "%" typed into the URL bar. */
|
|
const safeDecode = (s) => { try { return decodeURIComponent(s); } catch (e) { return s; } };
|
|
|
|
const spinner = () => { view.innerHTML = '<div class="loading"><span class="spinner"></span></div>'; };
|
|
|
|
/* ---------- 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(() => 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>`;
|
|
|
|
// Courses have no cover art, so the card art is a colour block in the
|
|
// course's own hue, with the cat/dur/play/percent overlays on top.
|
|
function thumb(hue, { cat, dur, play, percent } = {}) {
|
|
return `<div class="thumb" style="--c:${hue}">
|
|
${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) })}
|
|
<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 ---------- */
|
|
|
|
/* Tells the boot-error handler in index.php that a UI is on screen, so later
|
|
failures get logged instead of buried under a full-screen error panel. */
|
|
function markBooted() { window.__chBooted = true; }
|
|
|
|
function showAuth() {
|
|
if (cleanupView) { cleanupView(); cleanupView = null; }
|
|
shell.hidden = true;
|
|
authEl.hidden = false;
|
|
renderLogin();
|
|
markBooted();
|
|
}
|
|
|
|
function showShell() {
|
|
authEl.hidden = true;
|
|
shell.hidden = false;
|
|
markBooted();
|
|
}
|
|
|
|
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');
|
|
document.getElementById('f-user').focus(); // the autofocus attribute is inert on injected markup
|
|
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 {
|
|
// The session (and its CSRF token) may have expired while the tab sat
|
|
// on this form — fetch a fresh token so the POST is never rejected.
|
|
try {
|
|
const me = await fetch('api/auth.php').then((r) => r.json());
|
|
state.csrf = me.csrf || state.csrf;
|
|
} catch (e) { /* the POST below will surface the real problem */ }
|
|
const data = await api('api/auth.php', {
|
|
json: { action, username: fd.get('username'), password: fd.get('password') },
|
|
});
|
|
state.csrf = data.csrf || state.csrf;
|
|
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 })}</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 load('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>`;
|
|
|
|
}
|
|
|
|
/* ---------- library ---------- */
|
|
|
|
async function renderLibrary(filter) {
|
|
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
|
|
spinner();
|
|
const data = await load('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>`;
|
|
|
|
}
|
|
|
|
/* ---------- folder ---------- */
|
|
|
|
async function renderFolder(id) {
|
|
spinner();
|
|
const data = await load(`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>`;
|
|
|
|
}
|
|
|
|
/* ---------- search ---------- */
|
|
|
|
async function renderSearch(query) {
|
|
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>`;
|
|
document.getElementById('search-input').focus();
|
|
return;
|
|
}
|
|
|
|
spinner();
|
|
const data = await load(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
|
|
|
|
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>`;
|
|
|
|
}
|
|
|
|
/* ---------- watch ---------- */
|
|
|
|
async function renderWatch(id) {
|
|
spinner();
|
|
const data = await load(`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">
|
|
${data.captions_url
|
|
? `<track kind="captions" label="English" srclang="en" src="${data.captions_url}">` : ''}
|
|
</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>`;
|
|
|
|
// Bring the current entry into the playlist's own scroll box (not the
|
|
// page's — scrollIntoView would drag the viewport down to it on mobile).
|
|
const scroller = view.querySelector('.playlist-scroll');
|
|
const current = scroller && scroller.querySelector('.is-active');
|
|
if (current) {
|
|
const offset = current.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
scroller.scrollTop += offset - (scroller.clientHeight - current.offsetHeight) / 2;
|
|
}
|
|
|
|
cleanupView = initPlayer(data);
|
|
}
|
|
|
|
/* ---------- quiz ---------- */
|
|
|
|
async function renderQuiz(id) {
|
|
spinner();
|
|
const data = await load(`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.image ? `<figure class="q-exhibit">
|
|
<img src="${esc(q.image.url)}" alt="${esc(q.image.alt)}" loading="lazy" referrerpolicy="no-referrer">
|
|
<figcaption>${esc(q.image.alt)} · <a href="${esc(q.image.url)}" target="_blank" rel="noopener">open</a></figcaption>
|
|
</figure>` : ''}
|
|
${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>`;
|
|
}
|
|
|
|
draw();
|
|
|
|
// Exhibits are external images; when one can't load, say so instead of
|
|
// leaving a blank box (the caption's link still opens the original).
|
|
const onImageError = (e) => {
|
|
const fig = e.target.closest && e.target.closest('.q-exhibit');
|
|
if (fig) fig.classList.add('is-broken');
|
|
};
|
|
view.addEventListener('error', onImageError, true); // error events don't bubble
|
|
|
|
let submitting = false;
|
|
// 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;
|
|
}
|
|
const submit = e.target.closest('[data-quiz-submit]');
|
|
if (submit && !graded) {
|
|
if (submitting) return; // a second click while the first is in flight
|
|
submitting = true;
|
|
submit.disabled = true;
|
|
try {
|
|
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
|
|
draw();
|
|
toast(graded.completed
|
|
? `Perfect score — ${graded.score}/${graded.total}.`
|
|
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
|
|
loadMeta();
|
|
} catch (err) {
|
|
submit.disabled = false;
|
|
toast(err.message, 'error');
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
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);
|
|
view.removeEventListener('error', onImageError, true);
|
|
};
|
|
}
|
|
|
|
/* ---------- settings ---------- */
|
|
|
|
async function renderSettings() {
|
|
spinner();
|
|
const data = await load('api/library.php?view=settings');
|
|
|
|
const toggles = [
|
|
{ key: 'autoplay', label: 'Autoplay next video',
|
|
sub: 'Starts the next lesson a moment after one ends.' },
|
|
{ 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>`;
|
|
|
|
}
|
|
|
|
/* ---------- player + progress tracking ---------- */
|
|
|
|
function initPlayer(data) {
|
|
const el = document.getElementById('player');
|
|
const statusEl = document.getElementById('save-status');
|
|
const player = new Plyr(el, {
|
|
// Plyr fetches its icon sprite and blank video from its CDN unless told
|
|
// otherwise — everything ships locally now.
|
|
iconUrl: 'assets/vendor/plyr.svg',
|
|
blankVideo: 'assets/vendor/blank.mp4',
|
|
keyboard: { focused: true, global: true },
|
|
seekTime: 10,
|
|
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
|
|
captions: { active: false, update: true },
|
|
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
|
|
'current-time', 'duration', 'mute', 'volume', 'captions',
|
|
'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(force = false) {
|
|
const p = payload();
|
|
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
|
|
if (!p.duration || (!force && 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, csrf: state.csrf || '' })
|
|
.map(([k, v]) => [k, String(v)])));
|
|
}
|
|
|
|
let nextTimer = null;
|
|
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(() => save(), 5000); });
|
|
player.on('pause', () => save());
|
|
player.on('seeked', () => save());
|
|
player.on('ended', () => {
|
|
save(true); // the final position always lands, even within 1 s of the last heartbeat
|
|
if (data.next_id && pref('autoplay') !== false) {
|
|
status(`${icon('next', 14, 1.7)}Playing next video…`);
|
|
nextTimer = 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();
|
|
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
|
|
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() {
|
|
routeSeq++; // anything still loading for the previous view is now stale
|
|
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\/(.*)$/);
|
|
const query = searchMatch ? safeDecode(searchMatch[1]) : '';
|
|
syncSearchInput(query);
|
|
renderChrome();
|
|
// Views replace each other in the same document, so the page keeps the old
|
|
// scroll offset unless told otherwise — except while a search is being refined.
|
|
if (!(searchMatch && document.activeElement === searchInput)) window.scrollTo(0, 0);
|
|
|
|
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(query);
|
|
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 (err instanceof StaleRender) return; // a newer route is drawing; nothing to show
|
|
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;
|
|
state.csrf = me.csrf || null;
|
|
} catch (e) {
|
|
state.user = null;
|
|
}
|
|
|
|
if (!state.user) { showAuth(); return; }
|
|
showShell();
|
|
await loadMeta();
|
|
route();
|
|
})();
|
|
})();
|