/* 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: '', grid: '', list: '', search: '', settings: '', scan: '', reset: '', back: '', folder: '', quiz: '', check: '', alert: '', clock: '', prev: '', next: '', logout: '', close: '', play: '', list_ol: '', }; // 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) => `${ICONS[name]}`; const iconFill = (name, size = 12) => `${ICONS[name]}`; /* ---------- helpers ---------- */ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); /** m:ss / h:mm:ss — for a single video. */ function fmt(seconds) { if (!seconds || seconds <= 0) return ''; const s = Math.round(seconds); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`; } /** 6h 10m / 45m — for a course's total running time. */ function fmtLong(seconds) { if (!seconds || seconds <= 0) return ''; const m = Math.round(seconds / 60); const h = Math.floor(m / 60); return h ? `${h}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`; } const fmtHours = (seconds) => { if (!seconds || seconds < 60) return '0h'; const h = seconds / 3600; return h < 1 ? `${Math.round(seconds / 60)}m` : `${h < 10 ? h.toFixed(1) : Math.round(h)}h`; }; function fmtBytes(bytes) { if (!bytes) return ''; const gb = bytes / 1e9; return gb >= 1 ? `${gb.toFixed(1)} gb` : `${Math.max(1, Math.round(bytes / 1e6))} mb`; } /** "4h ago" — server timestamps are UTC. */ function relTime(iso) { if (!iso) return 'never'; const then = new Date(String(iso).replace(' ', 'T') + 'Z').getTime(); if (!then) return 'never'; const mins = Math.max(0, Math.round((Date.now() - then) / 60000)); if (mins < 2) return 'now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.round(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.round(hrs / 24); return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`; } const courseMeta = (c) => { const bits = []; if (c.videos) bits.push(`${c.videos} video${c.videos === 1 ? '' : 's'}`); if (c.quizzes) bits.push(`${c.quizzes} quiz${c.quizzes === 1 ? '' : 'zes'}`); if (c.duration) bits.push(fmtLong(c.duration)); return bits.join(' · '); }; const initialOf = (name) => (String(name || '?').trim()[0] || '?').toUpperCase(); /* ---------- preferences ---------- */ const PREF_DEFAULTS = { view: 'grid', layout: '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 = '
'; }; /* ---------- toast / dialog / menu ---------- */ function toast(message, kind = 'ok') { const el = document.createElement('div'); el.className = 'toast' + (kind === 'error' ? ' is-error' : ''); el.innerHTML = `${icon(kind === 'error' ? 'alert' : 'check', 16, 2)} ${esc(message)}`; 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 = ` `; 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) => ` ${icon(n.glyph)}${n.label} `).join(''); document.getElementById('nav-tabs').innerHTML = NAV.map((n) => ` ${icon(n.glyph, 20)}${n.label} `).join(''); const shelves = state.meta ? state.meta.shelves : []; document.getElementById('nav-shelves').innerHTML = shelves.map((s) => ` ${esc(s.name)} ${s.count} `).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) => `
`; // 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 `
${cat ? `${esc(cat)}` : ''} ${dur ? `${esc(dur)}` : ''} ${play ? `${iconFill('play', 20)}` : ''} ${percent !== undefined ? `
` : ''}
`; } function courseCard(c) { const hue = hueOf(c.id); return ` ${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration) })}
${esc(c.name)}
${esc(courseMeta(c))}
${bar(c.progress.percent, hue)} ${c.progress.percent}%
`; } // 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 ` ${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
${esc(c.name)}
${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}
${bar(c.progress.percent, hue)} ${c.progress.percent}%
`; } function categoryRow(c) { const hue = hueOf(c.id); return ` ${esc(initialOf(c.name))}
${esc(c.name)}
${esc(c.path || '')}
${c.courses} course${c.courses === 1 ? '' : 's'} ${c.videos} vids ${esc(fmtLong(c.duration))}
${bar(c.progress.percent, hue)} ${c.progress.percent}%
`; } const categoryList = (cats, mode) => mode === 'list' ? `
${cats.map(categoryRow).join('')}
` : `
${cats.map(categoryCard).join('')}
`; function courseRow(c) { const hue = hueOf(c.id); return `
${esc(initialOf(c.name))}
${esc(c.name)}
${esc(c.path || '')}
${esc(SHELF_NAME[c.state])} ${c.videos} vids ${esc(fmtLong(c.duration))}
${bar(c.progress.percent, hue)} ${c.progress.percent}%
`; } const courseList = (courses, mode) => mode === 'list' ? `
${courses.map(courseRow).join('')}
` : `
${courses.map(courseCard).join('')}
`; function viewToggle() { const mode = pref('view'); const btn = (m, glyph, label) => ` `; return `
${btn('grid', 'grid', 'Grid view')}${btn('list', 'list', 'List view')}
`; } const resetBtn = (scope, id, label, text = 'Reset progress') => ``; function crumbs(list, tail) { const parts = list.map((c, i) => (i === list.length - 1 && !tail) ? `${esc(c.name)}` : `${esc(c.name)}`); return ``; } const videoRow = (v, extra = '') => { const cls = v.completed ? ' is-done' : (v.position > 5 ? ' is-partial' : ''); return ` ${iconFill('play', 12)}
${esc(v.name)}
${extra ? `
${esc(extra)}
` : ''} ${!v.completed && v.percent > 0 ? `
${bar(v.percent)}
` : ''}
${esc(fmt(v.duration))}
`; }; const quizRow = (q, extra = '') => ` ${icon('quiz', 14, 1.7)}
${esc(q.name)}
${extra ? esc(extra) + ' · ' : ''}Quiz · ${q.questions} question${q.questions === 1 ? '' : 's'}
${q.attempts ? `${q.best_score}/${q.questions}` : ''}
`; /* ---------- 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 = `
${iconFill('play', 15)} CourseHub

Your course library.

Sign in to pick up where you stopped.

${error ? `
${esc(error)}
` : ''}
`; 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 = `
${esc(err.message)}
`; } }); } /* ---------- 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 `

Nothing in progress

Open a course and start a video — it will show up here with your place kept.

Browse the library
`; } 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 `
${thumb(hue, { play: true, percent: pct })}
Continue watching
${esc(resume.name)}
${esc(resume.folder_path)}
${bar(pct)} ${pct}%${left ? ` · ${fmt(left)} left` : ''}
`; } 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 `
${cards.map((s) => `
${s.label}
${esc(s.value)}
${esc(s.sub)}
`).join('')}
`; } 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 ? `
${inProgress.map(courseCard).join('')}
` : `

No course started

Everything in the library is still untouched.

Open the library
`; const recentBlock = data.recent.length ? `

Found in the last scan

${data.recent.map((c) => ` ${esc(initialOf(c.name))}
${esc(c.name)}
${esc(c.path)}
${c.is_new ? 'New' : ''} ${esc([courseMeta(c), fmtBytes(c.bytes)].filter(Boolean).join(' · '))}
`).join('')}
` : ''; view.innerHTML = `
${esc(todayLine())}

Pick up where you stopped.

${heroBlock(data.resume)} ${advisorBlock()} ${statCards(data.stats)}

In progress

All courses
${progressBlock} ${recentBlock}
`; 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(`

${para.map(esc).join('
')}

`); para = []; } }; const flushL = () => { if (list.length) { out.push(``); 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 `
${esc(t.text)}
`; const recs = (t.recommended || []).map((r) => { const c = r.course; const hue = hueOf(c.id); return ` ${r.order}
${esc(c.name)}
${esc([c.path, courseMeta(c)].filter(Boolean).join(' · '))}
${r.reason ? `
${esc(r.reason)}
` : ''}
${bar(c.progress.percent, hue)}${c.progress.percent}%
`; }).join(''); const ine = (t.from_ine || []).map((r) => { const url = safeHttpUrl(r.url); const body = ` ↗
${esc(r.name)}
${esc([r.vendor, r.difficulty, 'not downloaded yet'].filter(Boolean).join(' · '))}
${r.reason ? `
${esc(r.reason)}
` : ''}
`; return url ? `${body}` : `
${body}
`; }).join(''); const missing = (t.missing || []).map((m) => `${esc(m)}`).join(''); return `
${advisorAnswerHtml(t.answer)}
${recs ? `
Suggested order
${recs}
` : ''} ${ine ? `
Worth downloading
${ine}
` : ''} ${missing ? `
Not covered anywhere
${missing}
` : ''}
`; } function advisorBlock() { if (!advisorOn()) return ''; return `

Course advisor

Ask which course to take next, what to study first, or in what order — answered from your own library.

`; } 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(`
Thinking…
`); 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(`
${esc(err.message)}
`); } 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) => `

${esc(shelf.name)}

${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'}
${courseList(shelf.courses, mode)}
`; 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) => ` ${esc(SHELF_NAME[k])} `).join(''); let body; if (!data.courses.length) { body = `

The library is empty

Add course folders to the videos directory, then scan.

`; } 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 ? `

Courses

${data.top_courses.length}
${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 = `
${chips}
` + (shown.length ? courseList(shown, mode) : `

Nothing on this shelf

No course is ${esc(SHELF_NAME[active].toLowerCase())} right now.

Show every course
`); } 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) => ``; view.innerHTML = `

Library

${sub}

${data.categories.length ? layoutBtn('categories', 'Categories') : ''} ${layoutBtn('shelves', 'Shelves')} ${layoutBtn('catalog', 'Catalog')}
${viewToggle()}
${body}
`; } /* ---------- 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 `${esc(text)}`; }; const chips = [ m.difficulty ? `${esc(m.difficulty)}` : '', m.vendor ? `${esc(m.vendor)}` : '', ...(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 `
${blurb ? `

${esc(blurb.length > 420 ? blurb.slice(0, 419).trim() + '…' : blurb)}

` : ''} ${chips ? `
${chips}
` : ''}
`; } 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 ? `

Categories

${viewToggle()}
${categoryList(data.categories, mode)}
` : '') + (data.courses.length ? `

Courses

${data.categories.length ? '' : viewToggle()}
${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '') : (data.subfolders.length ? `

Sections

${viewToggle()}
${courseList(data.subfolders, mode)}
` : ''); 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 ? `

Content

${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}
` : (!hasChildren ? `

This folder is empty

No videos or quizzes were found here in the last scan.

` : ''); 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 = `
${crumbs(data.breadcrumbs)}
${isCategory ? 'Category' : ''}

${esc(data.folder.name)}

${esc(meta || 'Empty folder')}

${data.progress.completed} / ${data.progress.total} done ${data.progress.total ? resetBtn('folder', data.folder.id, data.folder.name) : ''}
${courseAbout(data.meta)}
${bar(data.progress.percent, hue)} ${data.progress.percent}%
${sections}${content}
`; } /* ---------- search ---------- */ async function renderSearch(query) { if (!query) { view.innerHTML = `

Search the library

Type to match course titles, folder paths, and individual video names.

`; 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 ? '' : `
${title}
${html}
`; const courses = `
${data.folders.map((c) => ` ${esc(initialOf(c.name))}
${esc(c.name)}
${esc([KIND_NAME[c.kind], courseMeta(c), c.path].filter(Boolean).join(' · '))}
${bar(c.progress.percent, hueOf(c.id))} ${c.progress.percent}%
`).join('')}
`; const videos = `
${data.videos.map((v) => videoRow(v, v.folder_path)).join('')}
`; const quizzes = `
${data.quizzes.map((q) => quizRow(q, q.folder_path)).join('')}
`; view.innerHTML = `

${total} result${total === 1 ? '' : 's'} for “${esc(query)}”

Matches across course titles, folder paths, and video names.

${total === 0 ? `

No files match “${esc(query)}”

Try a different word, or scan the library for new files.

` : section('Courses and sections', data.folders.length, courses) + section('Videos', data.videos.length, videos) + section('Quizzes', data.quizzes.length, quizzes)}
`; } /* ---------- 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 ` ${iconFill('play', 11)}
${esc(p.name)}
${esc(fmt(p.duration))}
`; }).join(''); const quizzes = data.quizzes.length ? `
${icon('quiz', 15, 1.7)}Quizzes in this section
${data.quizzes.map((q) => quizRow(q)).join('')}
` : ''; view.innerHTML = `
${crumbs(data.breadcrumbs, data.video.name)}

${esc(data.video.name)}

${data.prev_id ? `${icon('prev', 15, 1.7)}Previous` : ''} ${data.next_id ? `Next${icon('next', 15, 1.7)}` : ''} ${resetBtn('video', data.video.id, data.video.name, 'Reset')}
${data.resume_at > 0 ? `
${icon('clock', 14)}Resuming from ${fmt(data.resume_at)}
` : ''}
${icon('list_ol', 15, 1.7)}${esc(data.folder.name)}
${playlist}
${quizzes}
`; // 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 `
${qi + 1}
${esc(q.text)}
${q.image ? `
${esc(q.image.alt)}
${esc(q.image.alt)} · open
` : ''} ${q.multi && !r ? '
Select every answer that applies.
' : ''} ${r ? `
${r.correct ? 'Correct.' : 'Not quite — the right answer is highlighted below.'}
` : ''}
${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 ``; }).join('')}
`; }).join(''); const answered = answers.filter((a) => a.length).length; const scoreBlock = graded ? (() => { const pass = graded.completed; return `
${graded.score}/${graded.total}
${pass ? 'Quiz passed' : 'Not passed yet'}
${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.`}
`; })() : ''; view.innerHTML = `
${icon('back', 14, 1.8)}Back to ${esc(data.folder.name)}
${esc(data.folder.name)}

${esc(data.quiz.name)}

${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}.` : ''}

${scoreBlock}
${questions}
${graded ? `${graded.score} of ${graded.total} correct` : `${answered} of ${data.questions.length} answered`} ${data.progress.attempts ? resetBtn('quiz', data.quiz.id, data.quiz.name, 'Reset attempts') : ''}
`; } 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 = `

Settings

Library folder

Scanned for courses, videos and quizzes on demand.

${icon('folder', 16)} ${esc(data.root)} ${data.totals.courses} course${data.totals.courses === 1 ? '' : 's'}
Indexed ${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}
Courses found ${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}
Shelves ${data.shelf_counts.progress} in progress · ${data.shelf_counts.finished} finished · ${data.shelf_counts.fresh} not started
Total running time ${esc(fmtLong(data.totals.duration) || '—')}
A video counts as watched at ${data.complete_threshold}%
Last scan ${esc(relTime(data.last_scan))}
Catalogue ${data.catalog_courses ? `${data.catalog_courses} courses seeded` : 'not seeded'}
${data.course_meta_counts ? `
Course details ${data.course_meta_counts.sidecar} from files · ${data.course_meta_counts.catalog} from the catalogue · ${data.course_meta_counts.none} without
` : ''} ${data.root_exists ? '' : `
This folder is not readable right now — scanning will fail until it is back.
`}

Playback and display

Stored in this browser, for this device.

${toggles.map((t) => `
${esc(t.label)}
${esc(t.sub)}
`).join('')}

Course advisor

${data.advisor && data.advisor.enabled ? `

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.

Show the advisor on the home page
Hides the box in this browser only; the server setting stays on.
` : `

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.

`}

Reset progress

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.

${resetBtn('all', 0, '', 'Reset all progress')} Or reset one course from its row in the library list.

Account

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.'}

`; } /* ---------- 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 = `

That didn't load

${esc(err.message)}

Back to home
`; } } } /* ---------- 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, ` `, (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(); })(); })();