Files
coursehub/assets/app.js
T
Harwinder Sekhon 76d8f259b2 Move the course advisor into a floating panel and link courses in its prose
The advisor was a card inside the home page, so reaching it meant navigating
back to #/ and it was rebuilt on every render of that view. It is now a bubble
in the bottom-right corner of every screen bar the player and a quiz, opening
a chat panel that stays put as you move around — ask what to take next, follow
a recommendation, keep asking. It is built once into #overlay-root, outside
the view that gets replaced, and syncAdvisor() called from renderChrome() is
the single path that decides whether it is on screen; that covers every route
change and every loadMeta(), and makes the Settings toggle work with no change
to its handler.

Being permanent removes the reason for the routeSeq guards in ask(). An answer
that arrived after the user navigated was written to sessionStorage and then
dropped unrendered; now it lands, and the bubble carries an unread dot if the
panel is closed. showAuth() wipes the transcript and hides the dock, both of
which are new requirements: the dock is a sibling of #app-shell, so hiding the
shell no longer hides the advisor, and the widget now outlives a sign-out.

Courses named in the answer itself are links. advisorResolveCourses() is
lifted out of advisorValidateRecommendations() so an id written inline goes
through the same existence check and the same redirect to whichever duplicate
copy the learner has progress on — a sentence and the card under it can never
point at different copies. The map is keyed by the id as the model wrote it,
since that is what the browser has to find in the text. The client splits the
raw line before escaping rather than linkifying escaped output: esc() writes
an apostrophe as ', and the course-id pattern matches the #39 inside it.

Under 900px the panel is a full-height sheet, and the mobile toast strip lifts
clear of the bubble only while the bubble is actually on screen. The z-index
sits on the dock rather than #overlay-root, which would otherwise become a
stacking context and trap the confirm dialog behind the panel.

README's advisor section is brought back in line, including the parts left
stale by 05f0abc about sections and the whole library going out per question.

Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
2026-09-10 06:56:01 -04:00

1959 lines
84 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>',
chat: '<path d="M20.5 11.6a7.9 7.9 0 0 1-8.5 7.9 9 9 0 0 1-3.1-.6L3.5 20.5l1.7-4.4a7.7 7.7 0 0 1-1.7-4.5 7.9 7.9 0 0 1 8.5-7.9 7.9 7.9 0 0 1 8.5 7.9Z"></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);
syncAdvisor();
}
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;
// The advisor dock is a sibling of #app-shell, so hiding the shell does not
// hide it. Clearing the transcript here is also what stops one person's
// conversation surviving into the next sign-in in the same tab.
advisorReset({ wipe: true });
syncAdvisor();
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)}
${statCards(data.stats)}
<div class="section-head">
<h2>In progress</h2>
<a class="btn-quiet" href="#/library">All courses</a>
</div>
${progressBlock}
${recentBlock}
</div>`;
}
/* ---------- course advisor ---------- */
// The transcript lives in this tab: it survives navigating around the app and
// dies with the tab. Deliberately not 'coursehub.advisor' — that string is the
// localStorage show/hide flag behind pref('advisor'), and the two used to sit
// one store apart under the same name.
const ADVISOR_KEY = 'coursehub.advisor.chat';
// 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 player and a quiz are the two screens you are *inside* something, so the
// bubble stays out of them. It also keeps the panel's textarea clear of Plyr's
// window-level key handler (initPlayer, keyboard.global) — belt and braces,
// since Plyr already ignores keys typed into a textarea.
const isFocusRoute = () => /^#\/(?:watch|quiz)\/\d+$/.test(location.hash || '#/');
// Built once, bound once, never torn down: the dock outlives every route, so
// unlike a view nothing here may assume it is rebuilt on navigation.
let advisorEl = null;
let advisorTurns = [];
let advisorOpen = false;
let advisorBusy = false;
let advisorLastFocus = null;
function advisorLoad() {
try {
const t = JSON.parse(sessionStorage.getItem(ADVISOR_KEY) || '[]');
return Array.isArray(t) ? t : [];
} catch (e) {
return [];
}
}
function advisorSave() {
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(advisorTurns)); } catch (e) { /* not persisted */ }
}
/**
* One line of the model's text, with "#412" turned into a link to the course
* the server resolved it to. The split runs over the *raw* line, never the
* escaped one: esc() writes an apostrophe as "&#39;", which this pattern would
* otherwise match as a course id and cut the entity in half.
*/
function advisorLine(raw, courses) {
const out = [];
const re = /#(\d{1,9})\b/g;
let last = 0;
let m;
while ((m = re.exec(raw)) !== null) {
const c = courses && courses[m[1]];
if (!c) continue; // an id the server could not place stays plain text
out.push(esc(raw.slice(last, m.index)));
out.push(`<a href="#/folder/${+c.folder_id}">${esc(c.name)}</a>`);
last = m.index + m[0].length;
}
out.push(esc(raw.slice(last)));
return out.join('');
}
/** The model's plain text as HTML: paragraphs, and "- " lines as bullets. Escaped first. */
function advisorAnswerHtml(text, courses) {
const out = [];
let para = [];
let list = [];
const line = (l) => advisorLine(l, courses);
const flushP = () => { if (para.length) { out.push(`<p>${para.map(line).join('<br>')}</p>`); para = []; } };
const flushL = () => { if (list.length) { out.push(`<ul>${list.map((l) => `<li>${line(l)}</li>`).join('')}</ul>`); list = []; } };
for (const raw of String(text || '').split('\n')) {
const l = raw.trim();
const m = l.match(/^[-•*]\s+(.*)$/);
if (!l) { flushP(); flushL(); continue; }
if (m) { flushP(); list.push(m[1]); continue; }
flushL();
para.push(l);
}
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, t.courses)}</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 advisorDraw(extra = '') {
if (!advisorEl) return;
advisorEl.root.classList.toggle('has-turns', advisorTurns.length > 0);
advisorEl.thread.innerHTML = advisorTurns.map(advisorTurnHtml).join('') + extra;
// The thread is its own scroll box. Pin it twice: the first run lands before
// the web font has settled the final line heights, which leaves it short.
const pin = () => { advisorEl.thread.scrollTop = advisorEl.thread.scrollHeight; };
pin();
requestAnimationFrame(pin);
}
function advisorSetBusy(b) {
advisorBusy = b;
if (!advisorEl) return;
advisorEl.input.disabled = b;
advisorEl.submit.disabled = b;
}
/** Announces to a screen reader. The thread itself cannot be a live region:
* advisorDraw() replaces it wholesale, so every repaint would re-read it all. */
function advisorSay(message) {
if (advisorEl) advisorEl.live.textContent = message;
}
function advisorMarkUnread() {
if (!advisorEl) return;
advisorEl.dot.hidden = false;
advisorEl.fab.setAttribute('aria-label', 'Course advisor — new answer');
}
/** wipe: the session ended, so drop the transcript rather than just the view of it. */
function advisorReset({ wipe = false } = {}) {
if (advisorBusy && !wipe) return;
advisorTurns = [];
if (wipe) { try { sessionStorage.removeItem(ADVISOR_KEY); } catch (e) { /* nothing to clear */ } }
else advisorSave();
if (!advisorEl) return;
advisorEl.note.textContent = '';
advisorEl.dot.hidden = true;
advisorEl.fab.setAttribute('aria-label', 'Course advisor');
advisorDraw();
}
// What the server replays to the model: the assistant's own JSON, so the
// follow-up sees exactly what it said before. The "#412" markers in `answer`
// must survive — they keep a follow-up coherent, and advisorRelevant() greps
// for them to pull those courses into the details block. `courses` is derived
// from the answer and deliberately not replayed.
const advisorMessages = (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 advisorAsk(text) {
// No routeSeq guard: the panel outlives every route, so an answer that lands
// after the user navigated is a good answer, not a stale one.
advisorTurns.push({ role: 'user', text });
advisorSave();
advisorSetBusy(true);
advisorSay('Thinking…');
advisorDraw(`<div class="advisor-msg is-bot advisor-pending"><span class="spinner"></span>Thinking…</div>`);
try {
const res = await api('api/advisor.php', { json: { messages: advisorMessages(advisorTurns) } });
advisorTurns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [],
courses: res.courses || {} });
advisorSave();
advisorDraw();
const left = res.usage && res.usage.remaining_today;
advisorEl.note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
advisorSay('The advisor answered.');
if (!advisorOpen) advisorMarkUnread(); // it arrived behind a closed bubble
if (res.degraded) toast('The advisor answered in plain text this time, without course cards.', 'error');
} catch (err) {
// A 401 sent us to the sign-in screen, and showAuth() has already wiped the
// transcript — advisorTurns is a fresh array now and must not be popped.
if (!state.user) return;
advisorTurns.pop(); // the question goes back in the box
advisorSave();
if (err.status === 503) { // switched off on the server since the page loaded
if (state.meta) state.meta.advisor = false;
closeAdvisor({ restoreFocus: false });
syncAdvisor();
toast('The course advisor is switched off.', 'error');
return;
}
advisorEl.input.value = text;
advisorDraw(`<div class="advisor-msg is-bot"><div class="alert">${esc(err.message)}</div></div>`);
} finally {
advisorSetBusy(false);
}
}
const advisorIsSheet = () => matchMedia('(max-width: 900px)').matches;
function openAdvisor() {
if (!advisorEl || advisorOpen) return;
advisorOpen = true;
advisorLastFocus = document.activeElement;
advisorEl.root.classList.add('is-open');
advisorEl.panel.hidden = false;
advisorEl.scrim.hidden = false;
advisorEl.fab.setAttribute('aria-expanded', 'true');
advisorEl.dot.hidden = true;
advisorEl.fab.setAttribute('aria-label', 'Course advisor');
document.body.classList.toggle('advisor-sheet-open', advisorIsSheet());
advisorDraw();
advisorEl.input.focus();
}
function closeAdvisor({ restoreFocus = true } = {}) {
if (!advisorEl || !advisorOpen) return;
advisorOpen = false;
advisorEl.root.classList.remove('is-open');
advisorEl.panel.hidden = true;
advisorEl.scrim.hidden = true;
advisorEl.fab.setAttribute('aria-expanded', 'false');
document.body.classList.remove('advisor-sheet-open');
if (!restoreFocus) return; // the route changed; the old focus is gone with it
const back = advisorLastFocus && advisorLastFocus.isConnected ? advisorLastFocus : advisorEl.fab;
back.focus();
}
/** Builds the dock on first use, so a browser with the advisor hidden never
* gets the markup or the listeners. Idempotent. */
function renderAdvisor() {
if (advisorEl) return;
const root = document.createElement('div');
root.className = 'advisor-dock';
root.hidden = true;
// The bubble comes first so Tab runs bubble → panel; CSS stacks the panel
// above it with column-reverse.
root.innerHTML = `
<div class="advisor-scrim" data-advisor-close hidden></div>
<button class="advisor-fab" type="button" aria-expanded="false" aria-controls="advisor-panel"
aria-label="Course advisor" title="Course advisor">
${icon('chat', 22, 1.7)}<span class="advisor-dot" hidden></span>
</button>
<div class="advisor-panel" id="advisor-panel" role="dialog" aria-labelledby="advisor-title" hidden>
<div class="advisor-head">
<h2 id="advisor-title">Course advisor</h2>
<button class="btn-quiet" type="button" data-advisor-new>New</button>
<button class="icon-btn" type="button" data-advisor-close
aria-label="Close the course advisor">${icon('close', 16, 1.8)}</button>
</div>
<p class="advisor-sub">Ask what to take next, or in what order — answered from your own library.</p>
<div class="advisor-thread"></div>
<form class="advisor-form">
<textarea rows="1" placeholder="What should I learn next?"
aria-label="Ask the course advisor"></textarea>
<button class="btn btn-accent" type="submit">Ask</button>
</form>
<div class="advisor-note"></div>
<span class="sr-only" role="status" aria-live="polite"></span>
</div>`;
document.getElementById('overlay-root').appendChild(root);
const form = root.querySelector('.advisor-form');
advisorEl = {
root,
fab: root.querySelector('.advisor-fab'),
dot: root.querySelector('.advisor-dot'),
scrim: root.querySelector('.advisor-scrim'),
panel: root.querySelector('.advisor-panel'),
thread: root.querySelector('.advisor-thread'),
input: form.querySelector('textarea'),
submit: form.querySelector('button[type=submit]'),
note: root.querySelector('.advisor-note'),
live: root.querySelector('.sr-only'),
};
advisorTurns = advisorLoad();
advisorDraw();
advisorEl.fab.addEventListener('click', () => (advisorOpen ? closeAdvisor() : openAdvisor()));
root.addEventListener('click', (e) => {
if (e.target.closest('[data-advisor-close]')) closeAdvisor();
});
root.querySelector('[data-advisor-new]').addEventListener('click', () => {
advisorReset();
advisorEl.input.focus();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = advisorEl.input.value.trim();
if (!text || advisorBusy) return;
advisorEl.input.value = '';
advisorAsk(text);
});
advisorEl.input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
});
advisorEl.thread.addEventListener('click', (e) => {
// The desktop panel sits beside the app, so a course link just navigates
// underneath it. The mobile sheet covers the page it navigated to.
if (e.target.closest('a[href^="#/"]') && advisorIsSheet()) closeAdvisor({ restoreFocus: false });
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !advisorOpen) return;
if (document.querySelector('.overlay, .menu')) return; // a dialog or the account menu owns this one
closeAdvisor();
});
}
/** The one path that decides whether the bubble is on screen. Called from
* renderChrome(), so every route change and every loadMeta() covers it. */
function syncAdvisor() {
const want = !!state.user && advisorOn() && !isFocusRoute();
document.body.classList.toggle('has-advisor', want); // shifts the mobile toast strip clear of it
if (!want) {
if (advisorEl) { closeAdvisor({ restoreFocus: false }); advisorEl.root.hidden = true; }
return;
}
renderAdvisor();
advisorEl.root.hidden = false;
}
/* ---------- 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 button</div>
<div class="sub">The bubble in the bottom-right corner, on every screen but the player
and a quiz. 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 button"
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();
})();
})();