Files
coursehub/assets/app.js
T
Harwinder Sekhon 090d7fa94c Seed the vendor catalogue into SQL and add an optional AI course advisor
Course metadata: a scan now gives every course a course_meta row from the
downloader's sidecar JSON when the folder has one, else from catalog_courses
matched by the folder name the downloader would create (folderKeyFor, a port
of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export
into seeds/ine_catalog.sql, an idempotent patch that creates its own table and
upserts by id, so it can be applied to the live server before or after the
code and re-run freely. Course pages show the blurb, difficulty, vendor and
tags; search matches tags and blurbs; Settings reports how each course got its
details. Against the live library 163 of 179 courses match.

Course advisor, off by default: api/advisor.php answers "which course should I
take to learn X / what comes before Y" with courses from the library, in
order, plus vendor courses not yet downloaded. includes/ai_client.php is a
small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by
default, no composer); the prompt is a byte-stable catalogue of the library so
the provider's prefix cache covers it. Every course id the model returns is
validated server-side, a bad JSON reply gets one retry then a plain-text
fallback, and a per-user daily cap with token counts lives in
advisor_requests. With AI_ENABLED false the app makes no outbound requests
and the box does not appear; users can also hide it per browser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
2026-09-08 18:36:43 -04:00

1787 lines
76 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
playOnLoad: null, // video id a play button asked to start, not just open
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' };
// What a folder is, for search results: the three now mean different things.
const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' };
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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: 'categories', autoplay: true, advisor: 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) {
const err = new Error(data.error || `Request failed (${res.status})`);
err.status = res.status;
err.data = data;
throw err;
}
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>`;
}
// A category is a folder of courses. It gets the same card as a course so the
// library reads as one grid, with the course count where a course shows its
// videos, and no play affordance — there is nothing to play at this level.
function categoryCard(c) {
const hue = hueOf(c.id);
return `<a class="course-card" href="#/folder/${c.id}">
${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
<div class="course-card-body">
<div class="course-title">${esc(c.name)}</div>
<div class="course-meta">${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}</div>
<div class="bar-row">
${bar(c.progress.percent, hue)}
<span class="mono">${c.progress.percent}%</span>
</div>
</div>
</a>`;
}
function categoryRow(c) {
const hue = hueOf(c.id);
return `<a class="row" 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">${c.courses} course${c.courses === 1 ? '' : 's'}</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>`;
}
const categoryList = (cats, mode) => mode === 'list'
? `<div class="rows">${cats.map(categoryRow).join('')}</div>`
: `<div class="grid">${cats.map(categoryCard).join('')}</div>`;
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">
<a class="hero-art" href="#/watch/${resume.id}" data-play="${resume.id}"
aria-label="Resume ${esc(resume.name)}">${thumb(hue, { play: true, percent: pct })}</a>
<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)}
${advisorBlock()}
${statCards(data.stats)}
<div class="section-head">
<h2>In progress</h2>
<a class="btn-quiet" href="#/library">All courses</a>
</div>
${progressBlock}
${recentBlock}
</div>`;
initAdvisor();
}
/* ---------- course advisor ---------- */
const ADVISOR_KEY = 'coursehub.advisor';
// Shown only when the server has it switched on and this browser has not hidden it.
const advisorOn = () => !!(state.meta && state.meta.advisor) && pref('advisor') !== false;
// The conversation lives in this tab: it survives navigating around the app
// and dies with the tab. It is replayed to the server on every question.
function advisorTurns() {
try {
const t = JSON.parse(sessionStorage.getItem(ADVISOR_KEY) || '[]');
return Array.isArray(t) ? t : [];
} catch (e) {
return [];
}
}
function advisorSave(turns) {
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(turns)); } catch (e) { /* not persisted */ }
}
/** The model's plain text as HTML: paragraphs, and "- " lines as bullets. Escaped first. */
function advisorAnswerHtml(text) {
const out = [];
let para = [];
let list = [];
const flushP = () => { if (para.length) { out.push(`<p>${para.map(esc).join('<br>')}</p>`); para = []; } };
const flushL = () => { if (list.length) { out.push(`<ul>${list.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>`); list = []; } };
for (const raw of String(text || '').split('\n')) {
const line = raw.trim();
const m = line.match(/^[-•*]\s+(.*)$/);
if (!line) { flushP(); flushL(); continue; }
if (m) { flushP(); list.push(m[1]); continue; }
flushL();
para.push(line);
}
flushP();
flushL();
return out.join('');
}
const safeHttpUrl = (u) => (/^https?:\/\//i.test(String(u || '')) ? String(u) : '');
function advisorTurnHtml(t) {
if (t.role === 'user') return `<div class="advisor-msg is-user">${esc(t.text)}</div>`;
const recs = (t.recommended || []).map((r) => {
const c = r.course;
const hue = hueOf(c.id);
return `<a class="row-card" href="#/folder/${r.folder_id}">
<span class="initial initial-sm" style="--c:${hue}">${r.order}</span>
<div class="row-main">
<div class="row-title">${esc(c.name)}</div>
<div class="row-sub">${esc([c.path, courseMeta(c)].filter(Boolean).join(' · '))}</div>
${r.reason ? `<div class="advisor-reason">${esc(r.reason)}</div>` : ''}
</div>
<div class="row-bar">${bar(c.progress.percent, hue)}<span class="mono">${c.progress.percent}%</span></div>
</a>`;
}).join('');
const ine = (t.from_ine || []).map((r) => {
const url = safeHttpUrl(r.url);
const body = `
<span class="initial initial-sm" style="--c:#756D5D">↗</span>
<div class="row-main">
<div class="row-title">${esc(r.name)}</div>
<div class="row-sub">${esc([r.vendor, r.difficulty, 'not downloaded yet'].filter(Boolean).join(' · '))}</div>
${r.reason ? `<div class="advisor-reason">${esc(r.reason)}</div>` : ''}
</div>`;
return url
? `<a class="row-card" href="${esc(url)}" target="_blank" rel="noopener">${body}</a>`
: `<div class="row-card">${body}</div>`;
}).join('');
const missing = (t.missing || []).map((m) => `<span class="pill pill-quiet">${esc(m)}</span>`).join('');
return `<div class="advisor-msg is-bot">
<div class="advisor-answer">${advisorAnswerHtml(t.answer)}</div>
${recs ? `<div class="advisor-label">Suggested order</div><div class="advisor-list">${recs}</div>` : ''}
${ine ? `<div class="advisor-label">Worth downloading</div><div class="advisor-list">${ine}</div>` : ''}
${missing ? `<div class="advisor-label">Not covered anywhere</div><div class="advisor-prereqs">${missing}</div>` : ''}
</div>`;
}
function advisorBlock() {
if (!advisorOn()) return '';
return `<section class="card advisor" id="advisor">
<div class="advisor-head">
<h2>Course advisor</h2>
<button class="btn-quiet" type="button" data-advisor-new>New conversation</button>
</div>
<p class="advisor-sub">Ask which course to take next, what to study first, or in what order — answered from your own library.</p>
<div class="advisor-thread" id="advisor-thread"></div>
<form class="advisor-form" id="advisor-form">
<textarea id="advisor-input" rows="1" placeholder="I want to learn SD-WAN — where should I start?"
aria-label="Ask the course advisor"></textarea>
<button class="btn btn-accent" type="submit">Ask</button>
</form>
<div class="advisor-note" id="advisor-note"></div>
</section>`;
}
function initAdvisor() {
const section = document.getElementById('advisor');
if (!section) return;
const thread = section.querySelector('#advisor-thread');
const form = section.querySelector('#advisor-form');
const input = section.querySelector('#advisor-input');
const note = section.querySelector('#advisor-note');
const submit = form.querySelector('button[type=submit]');
let turns = advisorTurns();
let busy = false;
const draw = (extra = '') => { thread.innerHTML = turns.map(advisorTurnHtml).join('') + extra; };
const setBusy = (b) => { busy = b; input.disabled = b; submit.disabled = b; };
draw();
// What the server replays to the model: the assistant's own JSON, so the
// follow-up sees exactly what it said before.
const messagesFor = (list) => list.map((t) => t.role === 'user'
? { role: 'user', content: t.text }
: { role: 'assistant', content: JSON.stringify({
answer: t.answer,
recommended: (t.recommended || []).map((r) => ({ folder_id: r.folder_id, order: r.order, reason: r.reason })),
from_ine: (t.from_ine || []).map((r) => ({ catalog_id: String(r.catalog_id).slice(0, 8), reason: r.reason })),
missing_prerequisites: t.missing || [],
}) });
async function ask(text) {
const seq = routeSeq;
turns.push({ role: 'user', text });
advisorSave(turns);
setBusy(true);
draw(`<div class="advisor-msg is-bot advisor-pending"><span class="spinner"></span>Thinking…</div>`);
try {
const res = await api('api/advisor.php', { json: { messages: messagesFor(turns) } });
turns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [] });
advisorSave(turns);
if (seq !== routeSeq || !thread.isConnected) return; // the user moved on meanwhile
draw();
const left = res.usage && res.usage.remaining_today;
note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
if (res.degraded) toast('The advisor answered in plain text this time, without course links.', 'error');
} catch (err) {
turns.pop(); // the question goes back in the box
advisorSave(turns);
if (seq !== routeSeq || !thread.isConnected) return;
if (err.status === 503) { // switched off on the server since the page loaded
if (state.meta) state.meta.advisor = false;
section.remove();
toast('The course advisor is switched off.', 'error');
return;
}
input.value = text;
draw(`<div class="advisor-msg is-bot"><div class="alert">${esc(err.message)}</div></div>`);
} finally {
if (thread.isConnected) setBusy(false);
}
}
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || busy) return;
input.value = '';
ask(text);
});
input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
});
section.querySelector('[data-advisor-new]').addEventListener('click', () => {
if (busy) return;
turns = [];
advisorSave(turns);
note.textContent = '';
draw();
input.focus();
});
}
/* ---------- library ---------- */
/**
* Split courses onto the three progress shelves. The API sends one flat list
* and this partitions it — shipping the partition as well would put every
* course in the payload twice.
*/
const shelveCourses = (courses) =>
[['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']]
.map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) }))
.filter((s) => s.courses.length);
/**
* A shelf block: a heading, a count and the cards on it. Used by the library
* and by a category page, which shelves its own courses the same way.
*/
const shelfBlock = (shelf, mode) => `
<div style="margin-bottom:34px">
<div class="shelf-head">
<span class="swatch" style="--c:${SHELF_HUE[shelf.key]}"></span>
<h2>${esc(shelf.name)}</h2>
<span class="mono">${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'}</span>
<div class="rule"></div>
</div>
${courseList(shelf.courses, mode)}
</div>`;
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');
// A shelf link from the sidebar is always a flat list of matching courses;
// otherwise the saved layout decides.
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 === 'categories' && data.categories.length) {
// The folders above the courses are the library's own shape — a few dozen
// cards to browse instead of a wall of every course at once.
body = categoryList(data.categories, mode)
+ (data.top_courses.length ? `
<div class="shelf-head" style="margin-top:34px">
<h2>Courses</h2>
<span class="mono">${data.top_courses.length}</span>
<div class="rule"></div>
</div>
${courseList(data.top_courses, mode)}` : '');
} else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).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);
const sub = [
`${data.courses.length} course${data.courses.length === 1 ? '' : 's'}`,
data.totals.categories ? `${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}` : '',
`${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine`,
].filter(Boolean).join(' · ');
const layoutBtn = (key, label) =>
`<button class="${layout === key ? 'is-on' : ''}" data-layout="${key}">${label}</button>`;
view.innerHTML = `
<div>
<div class="page-head">
<div>
<h1>Library</h1>
<p class="page-sub">${sub}</p>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="seg">
${data.categories.length ? layoutBtn('categories', 'Categories') : ''}
${layoutBtn('shelves', 'Shelves')}
${layoutBtn('catalog', 'Catalog')}
</div>
${viewToggle()}
</div>
</div>
${body}
</div>`;
}
/* ---------- folder ---------- */
/** What the catalogue (or the sidecar file) says about a course, under its title. */
function courseAbout(m) {
if (!m) return '';
const blurb = String(m.short_description || m.description || '');
const seen = new Set([m.difficulty, m.vendor].filter(Boolean).map((x) => x.toLowerCase()));
const chip = (text, cls) => {
if (!text || seen.has(text.toLowerCase())) return '';
seen.add(text.toLowerCase());
return `<span class="pill${cls ? ' ' + cls : ''}">${esc(text)}</span>`;
};
const chips = [
m.difficulty ? `<span class="pill">${esc(m.difficulty)}</span>` : '',
m.vendor ? `<span class="pill pill-quiet">${esc(m.vendor)}</span>` : '',
...(m.learning_paths || []).slice(0, 3).map((p) => chip(p, 'pill-quiet')),
...(m.tags || []).slice(0, 10).map((t) => chip(t, 'pill-quiet')),
].filter(Boolean).join('');
if (!blurb && !chips) return '';
return `<div class="course-about">
${blurb ? `<p>${esc(blurb.length > 420 ? blurb.slice(0, 419).trim() + '…' : blurb)}</p>` : ''}
${chips ? `<div class="chips-inline">${chips}</div>` : ''}
</div>`;
}
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 isCategory = data.kind === 'category';
// A category holds courses (and possibly more categories), so it reads like
// a small library of its own; a course holds sections, which are just a
// list. Both come back on the same route — only the shape below differs.
const sections = isCategory
? (data.categories.length ? `
<div class="section-head">
<h2 class="h-section">Categories</h2>
${viewToggle()}
</div>
<div style="margin-bottom:32px">${categoryList(data.categories, mode)}</div>` : '')
+ (data.courses.length ? `
<div class="section-head">
<h2 class="h-section">Courses</h2>
${data.categories.length ? '' : viewToggle()}
</div>
${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '')
: (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 hasChildren = isCategory
? (data.categories.length + data.courses.length) > 0
: data.subfolders.length > 0;
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>`
: (!hasChildren
? `<div class="empty"><h2>This folder is empty</h2>
<p>No videos or quizzes were found here in the last scan.</p></div>` : '');
const courseCount = isCategory ? data.categories.reduce((n, c) => n + c.courses, data.courses.length) : 0;
const meta = [
courseCount ? `${courseCount} course${courseCount === 1 ? '' : 's'}` : '',
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>
${isCategory ? '<span class="eyebrow eyebrow-sm">Category</span>' : ''}
<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>
${courseAbout(data.meta)}
<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([KIND_NAME[c.kind], 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>Courses found</span>
<span class="mono">${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}</span></div>
<div class="fact-row"><span>Shelves</span>
<span class="mono">${data.shelf_counts.progress} in progress ·
${data.shelf_counts.finished} finished ·
${data.shelf_counts.fresh} not started</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>
<div class="fact-row"><span>Catalogue</span>
<span class="mono">${data.catalog_courses ? `${data.catalog_courses} courses seeded` : 'not seeded'}</span></div>
${data.course_meta_counts ? `<div class="fact-row"><span>Course details</span>
<span class="mono">${data.course_meta_counts.sidecar} from files ·
${data.course_meta_counts.catalog} from the catalogue ·
${data.course_meta_counts.none} without</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">
<h3>Course advisor</h3>
${data.advisor && data.advisor.enabled ? `
<p>On — answers come from ${esc(data.advisor.model || 'the configured model')}.
You have used ${data.advisor.used_today} of ${data.advisor.limit} question${data.advisor.limit === 1 ? '' : 's'} today.</p>
<div class="toggle-row">
<div style="flex:1">
<div class="label">Show the advisor on the home page</div>
<div class="sub">Hides the box in this browser only; the server setting stays on.</div>
</div>
<button class="switch${pref('advisor') !== false ? ' is-on' : ''}" role="switch"
aria-checked="${pref('advisor') !== false}" aria-label="Show the advisor on the home page"
data-toggle="advisor"><span></span></button>
</div>`
: `<p>Off. Set AI_ENABLED and AI_API_KEY in config.php to turn it on — the app works the same without it,
and makes no outbound requests while it is off.</p>`}
</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;
});
// A play button elsewhere in the app (the home hero) asked for the video to
// start, not just for the player to open. Autoplay is only permitted off the
// gesture that got us here, so the flag is consumed once and never survives
// a reload — and it waits for the resume seek above, so play() picks up at
// the saved position rather than at zero.
if (state.playOnLoad === data.video.id) {
state.playOnLoad = null;
const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); };
if (el.readyState >= 1) start();
else el.addEventListener('loadedmetadata', start, { once: 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…`);
// "Autoplay next" has to actually start it — navigating alone just
// opened the next video paused. The gesture that began this playback
// still counts, so the browser lets the next one start.
state.playOnLoad = data.next_id;
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
// Autoplay-next arms playOnLoad before it navigates, and route() tears the
// old view down after the hash has already changed — so only disarm when
// we are leaving for somewhere other than the video it queued.
if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) {
state.playOnLoad = null;
}
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.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed`
+ (s.meta_none === undefined ? '.' : `; details for ${s.courses - s.meta_none} of ${s.courses} courses.`));
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) => {
// Not a preventDefault: the link still navigates to #/watch/N, this only
// tells the player it was reached from a play button and should start.
const playLink = e.target.closest('[data-play]');
if (playLink && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
state.playOnLoad = +playLink.dataset.play;
}
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();
})();
})();