090d7fa94c
Course metadata: a scan now gives every course a course_meta row from the downloader's sidecar JSON when the folder has one, else from catalog_courses matched by the folder name the downloader would create (folderKeyFor, a port of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export into seeds/ine_catalog.sql, an idempotent patch that creates its own table and upserts by id, so it can be applied to the live server before or after the code and re-run freely. Course pages show the blurb, difficulty, vendor and tags; search matches tags and blurbs; Settings reports how each course got its details. Against the live library 163 of 179 courses match. Course advisor, off by default: api/advisor.php answers "which course should I take to learn X / what comes before Y" with courses from the library, in order, plus vendor courses not yet downloaded. includes/ai_client.php is a small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by default, no composer); the prompt is a byte-stable catalogue of the library so the provider's prefix cache covers it. Every course id the model returns is validated server-side, a bad JSON reply gets one retry then a plain-text fallback, and a per-user daily cap with token counts lives in advisor_requests. With AI_ENABLED false the app makes no outbound requests and the box does not appear; users can also hide it per browser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
1787 lines
76 KiB
JavaScript
1787 lines
76 KiB
JavaScript
/* CourseHub SPA — hash router + fetch API + Plyr player, on the
|
|
* "Course Library" design system.
|
|
*
|
|
* Routes: #/ home (resume, stats, in progress, last scan)
|
|
* #/library[/f] all courses, optionally filtered to one shelf
|
|
* #/folder/N browse a course or section
|
|
* #/watch/N video player
|
|
* #/quiz/N quiz
|
|
* #/search/Q search results (Q is URI-encoded)
|
|
* #/settings library, preferences, reset
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const shell = document.getElementById('app-shell');
|
|
const authEl = document.getElementById('auth');
|
|
const view = document.getElementById('view');
|
|
const state = {
|
|
user: null,
|
|
csrf: null, // session token, echoed back on every POST
|
|
meta: null, // totals / shelves / last scan, for the chrome
|
|
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
|
|
playOnLoad: null, // video id a play button asked to start, not just open
|
|
scanning: false,
|
|
};
|
|
let cleanupView = null;
|
|
|
|
/* ---------- design tokens mirrored in JS ---------- */
|
|
|
|
// A course has no cover art, so it gets one of the palette's hues, picked
|
|
// from its id — stable across renders and reloads.
|
|
const COURSE_HUES = ['#B85A2E', '#3F7A50', '#374B5C', '#96691C', '#7A4560', '#2E6A8C'];
|
|
const hueOf = (id) => COURSE_HUES[Math.abs(id | 0) % COURSE_HUES.length];
|
|
|
|
const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' };
|
|
const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' };
|
|
// What a folder is, for search results: the three now mean different things.
|
|
const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' };
|
|
|
|
const ICONS = {
|
|
home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>',
|
|
grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect>',
|
|
list: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path>',
|
|
search: '<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2"></path>',
|
|
settings: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1v.3a2 2 0 1 1-4 0v-.2a1.6 1.6 0 0 0-2.8-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3.5 14H3a2 2 0 1 1 0-4h.2A1.6 1.6 0 0 0 4.3 7.2l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 2.7-1.1V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0 1.1 2.7h.3a2 2 0 1 1 0 4h-.2a1.6 1.6 0 0 0-1.1 1Z"></path>',
|
|
scan: '<path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path>',
|
|
reset: '<path d="M3 12a9 9 0 1 0 3-6.7"></path><path d="M3 4v5h5"></path>',
|
|
back: '<path d="m15 18-6-6 6-6"></path>',
|
|
folder: '<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h7A1.5 1.5 0 0 1 19 10v7.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 3 17.5Z"></path>',
|
|
quiz: '<path d="M9.1 9a3 3 0 1 1 4.1 2.8c-.8.3-1.2 1-1.2 1.9v.3"></path><path d="M12 18h.01"></path><circle cx="12" cy="12" r="9"></circle>',
|
|
check: '<path d="m4 12 5 5L20 6"></path>',
|
|
alert: '<circle cx="12" cy="12" r="9"></circle><path d="M12 8v5"></path><path d="M12 16h.01"></path>',
|
|
clock: '<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path>',
|
|
prev: '<polygon points="19 20 9 12 19 4 19 20"></polygon><path d="M5 5v14"></path>',
|
|
next: '<polygon points="5 4 15 12 5 20 5 4"></polygon><path d="M19 5v14"></path>',
|
|
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><path d="m16 17 5-5-5-5"></path><path d="M21 12H9"></path>',
|
|
close: '<path d="M18 6 6 18M6 6l12 12"></path>',
|
|
play: '<polygon points="6 4 20 12 6 20 6 4"></polygon>',
|
|
list_ol: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path>',
|
|
};
|
|
|
|
// Sizes are the design's own; GLYPH_SCALE keeps glyphs in step with the type
|
|
// scale in style.css, which runs larger than the fixed-width design canvas.
|
|
const GLYPH_SCALE = 1.15;
|
|
const glyph = (size) => Math.round(size * GLYPH_SCALE);
|
|
|
|
const icon = (name, size = 17, width = 1.6) =>
|
|
`<svg width="${glyph(size)}" height="${glyph(size)}" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`;
|
|
const iconFill = (name, size = 12) =>
|
|
`<svg width="${glyph(size)}" height="${glyph(size)}" viewBox="0 0 24 24" fill="currentColor">${ICONS[name]}</svg>`;
|
|
|
|
/* ---------- helpers ---------- */
|
|
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
|
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
/** m:ss / h:mm:ss — for a single video. */
|
|
function fmt(seconds) {
|
|
if (!seconds || seconds <= 0) return '';
|
|
const s = Math.round(seconds);
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
|
: `${m}:${String(sec).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** 6h 10m / 45m — for a course's total running time. */
|
|
function fmtLong(seconds) {
|
|
if (!seconds || seconds <= 0) return '';
|
|
const m = Math.round(seconds / 60);
|
|
const h = Math.floor(m / 60);
|
|
return h ? `${h}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`;
|
|
}
|
|
|
|
const fmtHours = (seconds) => {
|
|
if (!seconds || seconds < 60) return '0h';
|
|
const h = seconds / 3600;
|
|
return h < 1 ? `${Math.round(seconds / 60)}m` : `${h < 10 ? h.toFixed(1) : Math.round(h)}h`;
|
|
};
|
|
|
|
function fmtBytes(bytes) {
|
|
if (!bytes) return '';
|
|
const gb = bytes / 1e9;
|
|
return gb >= 1 ? `${gb.toFixed(1)} gb` : `${Math.max(1, Math.round(bytes / 1e6))} mb`;
|
|
}
|
|
|
|
/** "4h ago" — server timestamps are UTC. */
|
|
function relTime(iso) {
|
|
if (!iso) return 'never';
|
|
const then = new Date(String(iso).replace(' ', 'T') + 'Z').getTime();
|
|
if (!then) return 'never';
|
|
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
|
if (mins < 2) return 'now';
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.round(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
const days = Math.round(hrs / 24);
|
|
return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
|
|
}
|
|
|
|
const courseMeta = (c) => {
|
|
const bits = [];
|
|
if (c.videos) bits.push(`${c.videos} video${c.videos === 1 ? '' : 's'}`);
|
|
if (c.quizzes) bits.push(`${c.quizzes} quiz${c.quizzes === 1 ? '' : 'zes'}`);
|
|
if (c.duration) bits.push(fmtLong(c.duration));
|
|
return bits.join(' · ');
|
|
};
|
|
|
|
const initialOf = (name) => (String(name || '?').trim()[0] || '?').toUpperCase();
|
|
|
|
/* ---------- preferences ---------- */
|
|
|
|
const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true, advisor: true };
|
|
|
|
function pref(key) {
|
|
try {
|
|
const raw = localStorage.getItem('coursehub.' + key);
|
|
if (raw === null) return PREF_DEFAULTS[key];
|
|
return raw === 'true' ? true : raw === 'false' ? false : raw;
|
|
} catch (e) {
|
|
return PREF_DEFAULTS[key]; // storage blocked (private mode)
|
|
}
|
|
}
|
|
|
|
function setPref(key, value) {
|
|
try { localStorage.setItem('coursehub.' + key, String(value)); } catch (e) { /* not persisted */ }
|
|
}
|
|
|
|
/* ---------- api ---------- */
|
|
|
|
async function api(url, options = {}) {
|
|
options.headers = Object.assign({}, options.headers);
|
|
if (state.csrf) options.headers['X-CSRF-Token'] = state.csrf;
|
|
if (options.json) {
|
|
options.method = options.method || 'POST';
|
|
options.headers['Content-Type'] = 'application/json';
|
|
options.body = JSON.stringify(options.json);
|
|
delete options.json;
|
|
}
|
|
const res = await fetch(url, options);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (res.status === 401) {
|
|
state.user = null;
|
|
showAuth();
|
|
throw new Error(data.error || 'Not logged in');
|
|
}
|
|
if (!res.ok) {
|
|
const err = new Error(data.error || `Request failed (${res.status})`);
|
|
err.status = res.status;
|
|
err.data = data;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/* Every route() bumps this. A view that was still waiting on its data when
|
|
the route moved on must not paint over the newer view — nor, for the
|
|
player, start a progress heartbeat for a video nobody is watching. */
|
|
let routeSeq = 0;
|
|
class StaleRender extends Error {}
|
|
|
|
/** api() for a view's data: throws StaleRender if the route changed meanwhile. */
|
|
async function load(url) {
|
|
const seq = routeSeq;
|
|
const data = await api(url);
|
|
if (seq !== routeSeq) throw new StaleRender('route changed');
|
|
return data;
|
|
}
|
|
|
|
/** decodeURIComponent that survives a stray "%" typed into the URL bar. */
|
|
const safeDecode = (s) => { try { return decodeURIComponent(s); } catch (e) { return s; } };
|
|
|
|
const spinner = () => { view.innerHTML = '<div class="loading"><span class="spinner"></span></div>'; };
|
|
|
|
/* ---------- toast / dialog / menu ---------- */
|
|
|
|
function toast(message, kind = 'ok') {
|
|
const el = document.createElement('div');
|
|
el.className = 'toast' + (kind === 'error' ? ' is-error' : '');
|
|
el.innerHTML = `<span class="glyph">${icon(kind === 'error' ? 'alert' : 'check', 16, 2)}</span>
|
|
<span>${esc(message)}</span>`;
|
|
document.getElementById('toasts').appendChild(el);
|
|
setTimeout(() => el.remove(), 3200);
|
|
}
|
|
|
|
/** Resolves true only if the user confirms. */
|
|
function confirmDialog({ title, body, confirmLabel = 'Confirm', danger = true }) {
|
|
return new Promise((resolve) => {
|
|
const host = document.createElement('div');
|
|
host.className = 'overlay';
|
|
host.innerHTML = `
|
|
<div class="dialog" role="dialog" aria-modal="true">
|
|
<h3>${esc(title)}</h3>
|
|
<p>${esc(body)}</p>
|
|
<div class="dialog-actions">
|
|
<button class="btn" data-cancel>Cancel</button>
|
|
<button class="btn ${danger ? 'btn-solid-danger' : 'btn-accent'}" data-confirm>${esc(confirmLabel)}</button>
|
|
</div>
|
|
</div>`;
|
|
document.getElementById('overlay-root').appendChild(host);
|
|
|
|
const close = (result) => {
|
|
document.removeEventListener('keydown', onKey);
|
|
host.remove();
|
|
resolve(result);
|
|
};
|
|
const onKey = (e) => { if (e.key === 'Escape') close(false); };
|
|
host.querySelector('[data-confirm]').addEventListener('click', () => close(true));
|
|
host.querySelector('[data-cancel]').addEventListener('click', () => close(false));
|
|
host.addEventListener('click', (e) => { if (e.target === host) close(false); });
|
|
document.addEventListener('keydown', onKey);
|
|
host.querySelector('[data-confirm]').focus();
|
|
});
|
|
}
|
|
|
|
function openMenu(anchor, html, onClick) {
|
|
closeMenu();
|
|
const menu = document.createElement('div');
|
|
menu.className = 'menu';
|
|
menu.id = 'account-menu';
|
|
menu.innerHTML = html;
|
|
document.body.appendChild(menu);
|
|
|
|
const r = anchor.getBoundingClientRect();
|
|
menu.style.top = `${Math.round(r.bottom + 8)}px`;
|
|
menu.style.left = `${Math.round(Math.min(r.right - menu.offsetWidth, window.innerWidth - menu.offsetWidth - 12))}px`;
|
|
|
|
menu.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn) return;
|
|
closeMenu();
|
|
onClick(btn.dataset.action);
|
|
});
|
|
setTimeout(() => {
|
|
document.addEventListener('click', onDocClick, { once: true });
|
|
document.addEventListener('keydown', onMenuKey);
|
|
}, 0);
|
|
}
|
|
|
|
const onDocClick = () => closeMenu();
|
|
const onMenuKey = (e) => { if (e.key === 'Escape') closeMenu(); };
|
|
|
|
function closeMenu() {
|
|
const el = document.getElementById('account-menu');
|
|
if (el) el.remove();
|
|
document.removeEventListener('keydown', onMenuKey);
|
|
}
|
|
|
|
/* ---------- chrome (sidebar, tab bar, header) ---------- */
|
|
|
|
const NAV = [
|
|
{ key: 'home', href: '#/', label: 'Home', glyph: 'home' },
|
|
{ key: 'library', href: '#/library', label: 'Library', glyph: 'grid' },
|
|
{ key: 'search', href: '#/search/', label: 'Search', glyph: 'search' },
|
|
{ key: 'settings', href: '#/settings', label: 'Settings', glyph: 'settings' },
|
|
];
|
|
|
|
function activeNav() {
|
|
const h = location.hash || '#/';
|
|
if (h.startsWith('#/search')) return 'search';
|
|
if (h.startsWith('#/settings')) return 'settings';
|
|
if (h.startsWith('#/library') || h.startsWith('#/folder') ||
|
|
h.startsWith('#/watch') || h.startsWith('#/quiz')) return 'library';
|
|
return 'home';
|
|
}
|
|
|
|
function renderChrome() {
|
|
const active = activeNav();
|
|
|
|
document.getElementById('nav-main').innerHTML = NAV.map((n) => `
|
|
<a class="nav-item${n.key === active ? ' is-active' : ''}" href="${n.href}">
|
|
${icon(n.glyph)}${n.label}
|
|
</a>`).join('');
|
|
|
|
document.getElementById('nav-tabs').innerHTML = NAV.map((n) => `
|
|
<a class="tab${n.key === active ? ' is-active' : ''}" href="${n.href}">
|
|
${icon(n.glyph, 20)}${n.label}
|
|
</a>`).join('');
|
|
|
|
const shelves = state.meta ? state.meta.shelves : [];
|
|
document.getElementById('nav-shelves').innerHTML = shelves.map((s) => `
|
|
<a class="shelf-item" href="#/library/${s.key}">
|
|
<span class="shelf-dot" style="--c:${SHELF_HUE[s.key]}"></span>
|
|
<span>${esc(s.name)}</span>
|
|
<span class="shelf-count">${s.count}</span>
|
|
</a>`).join('');
|
|
|
|
const totals = state.meta && state.meta.totals;
|
|
document.getElementById('topbar-totals').textContent = totals
|
|
? [`${totals.courses} course${totals.courses === 1 ? '' : 's'}`,
|
|
fmtBytes(totals.bytes)].filter(Boolean).join(' · ')
|
|
: '';
|
|
|
|
document.getElementById('scan-when').textContent =
|
|
state.scanning ? 'now' : relTime(state.meta && state.meta.last_scan);
|
|
|
|
const avatar = document.getElementById('btn-account');
|
|
avatar.textContent = (state.user || '?').slice(0, 2).toUpperCase();
|
|
avatar.style.background = hueOf((state.user || '').length + 4);
|
|
}
|
|
|
|
async function loadMeta() {
|
|
try {
|
|
state.meta = await api('api/library.php?view=meta');
|
|
} catch (e) {
|
|
state.meta = null; // the view itself will surface the error
|
|
}
|
|
renderChrome();
|
|
}
|
|
|
|
/* ---------- shared pieces ---------- */
|
|
|
|
const bar = (percent, hue) =>
|
|
`<div class="bar"${hue ? ` style="--c:${hue}"` : ''}><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>`;
|
|
|
|
// Courses have no cover art, so the card art is a colour block in the
|
|
// course's own hue, with the cat/dur/play/percent overlays on top.
|
|
function thumb(hue, { cat, dur, play, percent } = {}) {
|
|
return `<div class="thumb" style="--c:${hue}">
|
|
${cat ? `<span class="thumb-cat">${esc(cat)}</span>` : ''}
|
|
${dur ? `<span class="thumb-dur">${esc(dur)}</span>` : ''}
|
|
${play ? `<span class="thumb-play"><span>${iconFill('play', 20)}</span></span>` : ''}
|
|
${percent !== undefined
|
|
? `<div class="thumb-bar"><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function courseCard(c) {
|
|
const hue = hueOf(c.id);
|
|
return `<a class="course-card" href="#/folder/${c.id}">
|
|
${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration) })}
|
|
<div class="course-card-body">
|
|
<div class="course-title">${esc(c.name)}</div>
|
|
<div class="course-meta">${esc(courseMeta(c))}</div>
|
|
<div class="bar-row">
|
|
${bar(c.progress.percent, hue)}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
// A category is a folder of courses. It gets the same card as a course so the
|
|
// library reads as one grid, with the course count where a course shows its
|
|
// videos, and no play affordance — there is nothing to play at this level.
|
|
function categoryCard(c) {
|
|
const hue = hueOf(c.id);
|
|
return `<a class="course-card" href="#/folder/${c.id}">
|
|
${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
|
|
<div class="course-card-body">
|
|
<div class="course-title">${esc(c.name)}</div>
|
|
<div class="course-meta">${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}</div>
|
|
<div class="bar-row">
|
|
${bar(c.progress.percent, hue)}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
function categoryRow(c) {
|
|
const hue = hueOf(c.id);
|
|
return `<a class="row" href="#/folder/${c.id}">
|
|
<span class="initial initial-sm" style="--c:${hue}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-fixed">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc(c.path || '')}</div>
|
|
</div>
|
|
<span class="row-col">${c.courses} course${c.courses === 1 ? '' : 's'}</span>
|
|
<span class="row-num">${c.videos} vids</span>
|
|
<span class="row-num">${esc(fmtLong(c.duration))}</span>
|
|
<div class="row-bar">
|
|
${bar(c.progress.percent, hue)}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
const categoryList = (cats, mode) => mode === 'list'
|
|
? `<div class="rows">${cats.map(categoryRow).join('')}</div>`
|
|
: `<div class="grid">${cats.map(categoryCard).join('')}</div>`;
|
|
|
|
function courseRow(c) {
|
|
const hue = hueOf(c.id);
|
|
return `<div class="row">
|
|
<a class="row-link" href="#/folder/${c.id}">
|
|
<span class="initial initial-sm" style="--c:${hue}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-fixed">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc(c.path || '')}</div>
|
|
</div>
|
|
<span class="row-col">${esc(SHELF_NAME[c.state])}</span>
|
|
<span class="row-num">${c.videos} vids</span>
|
|
<span class="row-num">${esc(fmtLong(c.duration))}</span>
|
|
<div class="row-bar">
|
|
${bar(c.progress.percent, hue)}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</a>
|
|
<button class="icon-btn" title="Reset progress"
|
|
data-reset="folder" data-id="${c.id}" data-label="${esc(c.name)}">${icon('reset', 15, 1.7)}</button>
|
|
</div>`;
|
|
}
|
|
|
|
const courseList = (courses, mode) => mode === 'list'
|
|
? `<div class="rows">${courses.map(courseRow).join('')}</div>`
|
|
: `<div class="grid">${courses.map(courseCard).join('')}</div>`;
|
|
|
|
function viewToggle() {
|
|
const mode = pref('view');
|
|
const btn = (m, glyph, label) => `
|
|
<button class="seg-icon${mode === m ? ' is-on' : ''}" data-view-mode="${m}"
|
|
title="${label}" aria-label="${label}" aria-pressed="${mode === m}">${icon(glyph, 16, 1.7)}</button>`;
|
|
return `<div class="seg">${btn('grid', 'grid', 'Grid view')}${btn('list', 'list', 'List view')}</div>`;
|
|
}
|
|
|
|
const resetBtn = (scope, id, label, text = 'Reset progress') =>
|
|
`<button class="btn btn-danger" data-reset="${scope}" data-id="${id}" data-label="${esc(label)}">
|
|
${icon('reset', 15, 1.7)}${esc(text)}
|
|
</button>`;
|
|
|
|
function crumbs(list, tail) {
|
|
const parts = list.map((c, i) =>
|
|
(i === list.length - 1 && !tail)
|
|
? `<span class="here">${esc(c.name)}</span>`
|
|
: `<a href="#/folder/${c.id}">${esc(c.name)}</a>`);
|
|
return `<nav class="crumbs">
|
|
<a href="#/library">Library</a>
|
|
${parts.map((p) => `<span class="sep">/</span>${p}`).join('')}
|
|
${tail ? `<span class="sep">/</span><span class="here">${esc(tail)}</span>` : ''}
|
|
</nav>`;
|
|
}
|
|
|
|
const videoRow = (v, extra = '') => {
|
|
const cls = v.completed ? ' is-done' : (v.position > 5 ? ' is-partial' : '');
|
|
return `<a class="row" href="#/watch/${v.id}">
|
|
<span class="play-dot${cls}">${iconFill('play', 12)}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(v.name)}</div>
|
|
${extra ? `<div class="row-sub">${esc(extra)}</div>` : ''}
|
|
${!v.completed && v.percent > 0 ? `<div class="bar-row" style="margin-top:6px">${bar(v.percent)}</div>` : ''}
|
|
</div>
|
|
<span class="row-num">${esc(fmt(v.duration))}</span>
|
|
</a>`;
|
|
};
|
|
|
|
const quizRow = (q, extra = '') => `
|
|
<a class="row" href="#/quiz/${q.id}">
|
|
<span class="play-dot${q.completed ? ' is-done' : ''}">${icon('quiz', 14, 1.7)}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(q.name)}</div>
|
|
<div class="row-sub">${extra ? esc(extra) + ' · ' : ''}Quiz · ${q.questions} question${q.questions === 1 ? '' : 's'}</div>
|
|
</div>
|
|
${q.attempts ? `<span class="pill${q.completed ? '' : ' pill-quiet'}">${q.best_score}/${q.questions}</span>` : ''}
|
|
</a>`;
|
|
|
|
/* ---------- auth ---------- */
|
|
|
|
/* Tells the boot-error handler in index.php that a UI is on screen, so later
|
|
failures get logged instead of buried under a full-screen error panel. */
|
|
function markBooted() { window.__chBooted = true; }
|
|
|
|
function showAuth() {
|
|
if (cleanupView) { cleanupView(); cleanupView = null; }
|
|
shell.hidden = true;
|
|
authEl.hidden = false;
|
|
renderLogin();
|
|
markBooted();
|
|
}
|
|
|
|
function showShell() {
|
|
authEl.hidden = true;
|
|
shell.hidden = false;
|
|
markBooted();
|
|
}
|
|
|
|
function renderLogin(error = '') {
|
|
authEl.innerHTML = `
|
|
<div class="auth-card">
|
|
<div class="auth-brand">
|
|
<span class="brand-mark">${iconFill('play', 15)}</span>
|
|
<span class="brand-name">CourseHub</span>
|
|
</div>
|
|
<h1>Your course library.</h1>
|
|
<p class="sub">Sign in to pick up where you stopped.</p>
|
|
<div id="login-error">${error ? `<div class="alert">${esc(error)}</div>` : ''}</div>
|
|
<form id="login-form">
|
|
<div class="field">
|
|
<label for="f-user">Username</label>
|
|
<input id="f-user" name="username" required autofocus autocomplete="username">
|
|
</div>
|
|
<div class="field">
|
|
<label for="f-pass">Password</label>
|
|
<input id="f-pass" name="password" type="password" required autocomplete="current-password">
|
|
</div>
|
|
<div class="auth-actions">
|
|
<button class="btn btn-accent btn-lg" data-action="login">Sign in</button>
|
|
<button class="btn" data-action="register" type="button">Create account</button>
|
|
</div>
|
|
</form>
|
|
</div>`;
|
|
|
|
const form = document.getElementById('login-form');
|
|
document.getElementById('f-user').focus(); // the autofocus attribute is inert on injected markup
|
|
let action = 'login';
|
|
form.querySelectorAll('button[data-action]').forEach((b) =>
|
|
b.addEventListener('click', () => {
|
|
action = b.dataset.action;
|
|
if (action === 'register' && form.reportValidity()) form.requestSubmit();
|
|
}));
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(form);
|
|
try {
|
|
// The session (and its CSRF token) may have expired while the tab sat
|
|
// on this form — fetch a fresh token so the POST is never rejected.
|
|
try {
|
|
const me = await fetch('api/auth.php').then((r) => r.json());
|
|
state.csrf = me.csrf || state.csrf;
|
|
} catch (e) { /* the POST below will surface the real problem */ }
|
|
const data = await api('api/auth.php', {
|
|
json: { action, username: fd.get('username'), password: fd.get('password') },
|
|
});
|
|
state.csrf = data.csrf || state.csrf;
|
|
state.user = data.username;
|
|
showShell();
|
|
toast(action === 'register' ? 'Account created — welcome.' : `Welcome back, ${data.username}.`);
|
|
await loadMeta();
|
|
route();
|
|
} catch (err) {
|
|
document.getElementById('login-error').innerHTML = `<div class="alert">${esc(err.message)}</div>`;
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- home ---------- */
|
|
|
|
function todayLine() {
|
|
const d = new Date();
|
|
const weekday = d.toLocaleDateString(undefined, { weekday: 'long' });
|
|
const month = d.toLocaleDateString(undefined, { month: 'long' });
|
|
return `${weekday}, ${d.getDate()} ${month}`;
|
|
}
|
|
|
|
function heroBlock(resume) {
|
|
if (!resume) {
|
|
return `<div class="empty" style="margin-bottom:32px">
|
|
<h2>Nothing in progress</h2>
|
|
<p>Open a course and start a video — it will show up here with your place kept.</p>
|
|
<a class="btn btn-accent" href="#/library">Browse the library</a>
|
|
</div>`;
|
|
}
|
|
const pct = resume.duration ? Math.min(100, Math.round(100 * resume.position / resume.duration)) : 0;
|
|
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
|
|
const hue = hueOf(resume.course_id ?? resume.id);
|
|
return `<div class="hero">
|
|
<a class="hero-art" href="#/watch/${resume.id}" data-play="${resume.id}"
|
|
aria-label="Resume ${esc(resume.name)}">${thumb(hue, { play: true, percent: pct })}</a>
|
|
<div class="hero-body">
|
|
<span class="hero-eyebrow">Continue watching</span>
|
|
<div class="hero-title">${esc(resume.name)}</div>
|
|
<div class="hero-sub">${esc(resume.folder_path)}</div>
|
|
<div class="hero-progress">
|
|
${bar(pct)}
|
|
<span class="mono">${pct}%${left ? ` · ${fmt(left)} left` : ''}</span>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<a class="btn btn-light" href="#/watch/${resume.id}">${iconFill('play', 15)}Resume</a>
|
|
<a class="btn btn-ghost" href="#/library">${icon('grid', 15)}All courses</a>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function statCards(stats) {
|
|
const cards = [
|
|
{ label: 'In progress', value: String(stats.in_progress), sub: `of ${stats.courses} course${stats.courses === 1 ? '' : 's'}`, hue: '#B85A2E' },
|
|
{ label: 'Finished', value: String(stats.finished), sub: stats.finished ? 'courses complete' : 'nothing finished yet', hue: '#3F7A50' },
|
|
{ label: 'Watched', value: fmtHours(stats.watched_seconds), sub: 'across the library', hue: '#2E6A8C' },
|
|
stats.quizzes_taken
|
|
? { label: 'Quiz average', value: `${stats.quiz_average}%`, sub: `${stats.quizzes_taken} quiz${stats.quizzes_taken === 1 ? '' : 'zes'} taken`, hue: '#7A4560' }
|
|
: { label: 'Quiz average', value: '—', sub: 'no quizzes taken yet', hue: '#7A4560' },
|
|
];
|
|
return `<div class="grid-stats">${cards.map((s) => `
|
|
<div class="card">
|
|
<div class="stat-label"><span class="swatch" style="--c:${s.hue}"></span><span>${s.label}</span></div>
|
|
<div class="stat-value">${esc(s.value)}</div>
|
|
<div class="stat-sub">${esc(s.sub)}</div>
|
|
</div>`).join('')}</div>`;
|
|
}
|
|
|
|
async function renderHome() {
|
|
spinner();
|
|
const data = await load('api/library.php?view=home');
|
|
|
|
const inProgress = data.in_progress.slice(0, 4);
|
|
const progressBlock = inProgress.length
|
|
? `<div class="grid" style="margin-bottom:32px">${inProgress.map(courseCard).join('')}</div>`
|
|
: `<div class="empty" style="margin-bottom:32px">
|
|
<h2>No course started</h2>
|
|
<p>Everything in the library is still untouched.</p>
|
|
<a class="btn btn-accent" href="#/library">Open the library</a>
|
|
</div>`;
|
|
|
|
const recentBlock = data.recent.length ? `
|
|
<h2 style="margin:0 0 14px">Found in the last scan</h2>
|
|
<div class="stack">
|
|
${data.recent.map((c) => `
|
|
<a class="row-card" href="#/folder/${c.id}">
|
|
<span class="initial" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc(c.path)}</div>
|
|
</div>
|
|
${c.is_new ? '<span class="pill">New</span>' : ''}
|
|
<span class="row-num" style="text-align:right">${esc([courseMeta(c), fmtBytes(c.bytes)].filter(Boolean).join(' · '))}</span>
|
|
</a>`).join('')}
|
|
</div>` : '';
|
|
|
|
view.innerHTML = `
|
|
<div class="page">
|
|
<div class="eyebrow">${esc(todayLine())}</div>
|
|
<h1 style="margin:10px 0 24px">Pick up where you stopped.</h1>
|
|
${heroBlock(data.resume)}
|
|
${advisorBlock()}
|
|
${statCards(data.stats)}
|
|
<div class="section-head">
|
|
<h2>In progress</h2>
|
|
<a class="btn-quiet" href="#/library">All courses</a>
|
|
</div>
|
|
${progressBlock}
|
|
${recentBlock}
|
|
</div>`;
|
|
initAdvisor();
|
|
}
|
|
|
|
/* ---------- course advisor ---------- */
|
|
|
|
const ADVISOR_KEY = 'coursehub.advisor';
|
|
// Shown only when the server has it switched on and this browser has not hidden it.
|
|
const advisorOn = () => !!(state.meta && state.meta.advisor) && pref('advisor') !== false;
|
|
|
|
// The conversation lives in this tab: it survives navigating around the app
|
|
// and dies with the tab. It is replayed to the server on every question.
|
|
function advisorTurns() {
|
|
try {
|
|
const t = JSON.parse(sessionStorage.getItem(ADVISOR_KEY) || '[]');
|
|
return Array.isArray(t) ? t : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
function advisorSave(turns) {
|
|
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(turns)); } catch (e) { /* not persisted */ }
|
|
}
|
|
|
|
/** The model's plain text as HTML: paragraphs, and "- " lines as bullets. Escaped first. */
|
|
function advisorAnswerHtml(text) {
|
|
const out = [];
|
|
let para = [];
|
|
let list = [];
|
|
const flushP = () => { if (para.length) { out.push(`<p>${para.map(esc).join('<br>')}</p>`); para = []; } };
|
|
const flushL = () => { if (list.length) { out.push(`<ul>${list.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>`); list = []; } };
|
|
for (const raw of String(text || '').split('\n')) {
|
|
const line = raw.trim();
|
|
const m = line.match(/^[-•*]\s+(.*)$/);
|
|
if (!line) { flushP(); flushL(); continue; }
|
|
if (m) { flushP(); list.push(m[1]); continue; }
|
|
flushL();
|
|
para.push(line);
|
|
}
|
|
flushP();
|
|
flushL();
|
|
return out.join('');
|
|
}
|
|
|
|
const safeHttpUrl = (u) => (/^https?:\/\//i.test(String(u || '')) ? String(u) : '');
|
|
|
|
function advisorTurnHtml(t) {
|
|
if (t.role === 'user') return `<div class="advisor-msg is-user">${esc(t.text)}</div>`;
|
|
|
|
const recs = (t.recommended || []).map((r) => {
|
|
const c = r.course;
|
|
const hue = hueOf(c.id);
|
|
return `<a class="row-card" href="#/folder/${r.folder_id}">
|
|
<span class="initial initial-sm" style="--c:${hue}">${r.order}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc([c.path, courseMeta(c)].filter(Boolean).join(' · '))}</div>
|
|
${r.reason ? `<div class="advisor-reason">${esc(r.reason)}</div>` : ''}
|
|
</div>
|
|
<div class="row-bar">${bar(c.progress.percent, hue)}<span class="mono">${c.progress.percent}%</span></div>
|
|
</a>`;
|
|
}).join('');
|
|
|
|
const ine = (t.from_ine || []).map((r) => {
|
|
const url = safeHttpUrl(r.url);
|
|
const body = `
|
|
<span class="initial initial-sm" style="--c:#756D5D">↗</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(r.name)}</div>
|
|
<div class="row-sub">${esc([r.vendor, r.difficulty, 'not downloaded yet'].filter(Boolean).join(' · '))}</div>
|
|
${r.reason ? `<div class="advisor-reason">${esc(r.reason)}</div>` : ''}
|
|
</div>`;
|
|
return url
|
|
? `<a class="row-card" href="${esc(url)}" target="_blank" rel="noopener">${body}</a>`
|
|
: `<div class="row-card">${body}</div>`;
|
|
}).join('');
|
|
|
|
const missing = (t.missing || []).map((m) => `<span class="pill pill-quiet">${esc(m)}</span>`).join('');
|
|
|
|
return `<div class="advisor-msg is-bot">
|
|
<div class="advisor-answer">${advisorAnswerHtml(t.answer)}</div>
|
|
${recs ? `<div class="advisor-label">Suggested order</div><div class="advisor-list">${recs}</div>` : ''}
|
|
${ine ? `<div class="advisor-label">Worth downloading</div><div class="advisor-list">${ine}</div>` : ''}
|
|
${missing ? `<div class="advisor-label">Not covered anywhere</div><div class="advisor-prereqs">${missing}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function advisorBlock() {
|
|
if (!advisorOn()) return '';
|
|
return `<section class="card advisor" id="advisor">
|
|
<div class="advisor-head">
|
|
<h2>Course advisor</h2>
|
|
<button class="btn-quiet" type="button" data-advisor-new>New conversation</button>
|
|
</div>
|
|
<p class="advisor-sub">Ask which course to take next, what to study first, or in what order — answered from your own library.</p>
|
|
<div class="advisor-thread" id="advisor-thread"></div>
|
|
<form class="advisor-form" id="advisor-form">
|
|
<textarea id="advisor-input" rows="1" placeholder="I want to learn SD-WAN — where should I start?"
|
|
aria-label="Ask the course advisor"></textarea>
|
|
<button class="btn btn-accent" type="submit">Ask</button>
|
|
</form>
|
|
<div class="advisor-note" id="advisor-note"></div>
|
|
</section>`;
|
|
}
|
|
|
|
function initAdvisor() {
|
|
const section = document.getElementById('advisor');
|
|
if (!section) return;
|
|
const thread = section.querySelector('#advisor-thread');
|
|
const form = section.querySelector('#advisor-form');
|
|
const input = section.querySelector('#advisor-input');
|
|
const note = section.querySelector('#advisor-note');
|
|
const submit = form.querySelector('button[type=submit]');
|
|
let turns = advisorTurns();
|
|
let busy = false;
|
|
|
|
const draw = (extra = '') => { thread.innerHTML = turns.map(advisorTurnHtml).join('') + extra; };
|
|
const setBusy = (b) => { busy = b; input.disabled = b; submit.disabled = b; };
|
|
draw();
|
|
|
|
// What the server replays to the model: the assistant's own JSON, so the
|
|
// follow-up sees exactly what it said before.
|
|
const messagesFor = (list) => list.map((t) => t.role === 'user'
|
|
? { role: 'user', content: t.text }
|
|
: { role: 'assistant', content: JSON.stringify({
|
|
answer: t.answer,
|
|
recommended: (t.recommended || []).map((r) => ({ folder_id: r.folder_id, order: r.order, reason: r.reason })),
|
|
from_ine: (t.from_ine || []).map((r) => ({ catalog_id: String(r.catalog_id).slice(0, 8), reason: r.reason })),
|
|
missing_prerequisites: t.missing || [],
|
|
}) });
|
|
|
|
async function ask(text) {
|
|
const seq = routeSeq;
|
|
turns.push({ role: 'user', text });
|
|
advisorSave(turns);
|
|
setBusy(true);
|
|
draw(`<div class="advisor-msg is-bot advisor-pending"><span class="spinner"></span>Thinking…</div>`);
|
|
try {
|
|
const res = await api('api/advisor.php', { json: { messages: messagesFor(turns) } });
|
|
turns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
|
|
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [] });
|
|
advisorSave(turns);
|
|
if (seq !== routeSeq || !thread.isConnected) return; // the user moved on meanwhile
|
|
draw();
|
|
const left = res.usage && res.usage.remaining_today;
|
|
note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
|
|
if (res.degraded) toast('The advisor answered in plain text this time, without course links.', 'error');
|
|
} catch (err) {
|
|
turns.pop(); // the question goes back in the box
|
|
advisorSave(turns);
|
|
if (seq !== routeSeq || !thread.isConnected) return;
|
|
if (err.status === 503) { // switched off on the server since the page loaded
|
|
if (state.meta) state.meta.advisor = false;
|
|
section.remove();
|
|
toast('The course advisor is switched off.', 'error');
|
|
return;
|
|
}
|
|
input.value = text;
|
|
draw(`<div class="advisor-msg is-bot"><div class="alert">${esc(err.message)}</div></div>`);
|
|
} finally {
|
|
if (thread.isConnected) setBusy(false);
|
|
}
|
|
}
|
|
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const text = input.value.trim();
|
|
if (!text || busy) return;
|
|
input.value = '';
|
|
ask(text);
|
|
});
|
|
input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
|
|
});
|
|
section.querySelector('[data-advisor-new]').addEventListener('click', () => {
|
|
if (busy) return;
|
|
turns = [];
|
|
advisorSave(turns);
|
|
note.textContent = '';
|
|
draw();
|
|
input.focus();
|
|
});
|
|
}
|
|
|
|
/* ---------- library ---------- */
|
|
|
|
/**
|
|
* Split courses onto the three progress shelves. The API sends one flat list
|
|
* and this partitions it — shipping the partition as well would put every
|
|
* course in the payload twice.
|
|
*/
|
|
const shelveCourses = (courses) =>
|
|
[['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']]
|
|
.map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) }))
|
|
.filter((s) => s.courses.length);
|
|
|
|
/**
|
|
* A shelf block: a heading, a count and the cards on it. Used by the library
|
|
* and by a category page, which shelves its own courses the same way.
|
|
*/
|
|
const shelfBlock = (shelf, mode) => `
|
|
<div style="margin-bottom:34px">
|
|
<div class="shelf-head">
|
|
<span class="swatch" style="--c:${SHELF_HUE[shelf.key]}"></span>
|
|
<h2>${esc(shelf.name)}</h2>
|
|
<span class="mono">${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'}</span>
|
|
<div class="rule"></div>
|
|
</div>
|
|
${courseList(shelf.courses, mode)}
|
|
</div>`;
|
|
|
|
async function renderLibrary(filter) {
|
|
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
|
|
spinner();
|
|
const data = await load('api/library.php?view=library');
|
|
|
|
// A shelf link from the sidebar is always a flat list of matching courses;
|
|
// otherwise the saved layout decides.
|
|
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
|
|
const mode = pref('view');
|
|
const active = filter || 'all';
|
|
const shown = active === 'all' ? data.courses : data.courses.filter((c) => c.state === active);
|
|
|
|
const chips = ['all', 'progress', 'finished', 'fresh'].map((k) => `
|
|
<a class="chip${k === active ? ' is-on' : ''}" href="#/library${k === 'all' ? '' : '/' + k}">
|
|
<span class="dot" style="--c:${SHELF_HUE[k]}"></span>${esc(SHELF_NAME[k])}
|
|
</a>`).join('');
|
|
|
|
let body;
|
|
if (!data.courses.length) {
|
|
body = `<div class="empty">
|
|
<h2>The library is empty</h2>
|
|
<p>Add course folders to the videos directory, then scan.</p>
|
|
<button class="btn btn-accent" data-scan>Scan library</button>
|
|
</div>`;
|
|
} else if (layout === 'categories' && data.categories.length) {
|
|
// The folders above the courses are the library's own shape — a few dozen
|
|
// cards to browse instead of a wall of every course at once.
|
|
body = categoryList(data.categories, mode)
|
|
+ (data.top_courses.length ? `
|
|
<div class="shelf-head" style="margin-top:34px">
|
|
<h2>Courses</h2>
|
|
<span class="mono">${data.top_courses.length}</span>
|
|
<div class="rule"></div>
|
|
</div>
|
|
${courseList(data.top_courses, mode)}` : '');
|
|
} else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
|
|
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('');
|
|
} else {
|
|
body = `<div class="chips">${chips}</div>` + (shown.length
|
|
? courseList(shown, mode)
|
|
: `<div class="empty"><h2>Nothing on this shelf</h2>
|
|
<p>No course is ${esc(SHELF_NAME[active].toLowerCase())} right now.</p>
|
|
<a class="btn" href="#/library">Show every course</a></div>`);
|
|
}
|
|
|
|
const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0);
|
|
const sub = [
|
|
`${data.courses.length} course${data.courses.length === 1 ? '' : 's'}`,
|
|
data.totals.categories ? `${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}` : '',
|
|
`${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine`,
|
|
].filter(Boolean).join(' · ');
|
|
|
|
const layoutBtn = (key, label) =>
|
|
`<button class="${layout === key ? 'is-on' : ''}" data-layout="${key}">${label}</button>`;
|
|
|
|
view.innerHTML = `
|
|
<div>
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>Library</h1>
|
|
<p class="page-sub">${sub}</p>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<div class="seg">
|
|
${data.categories.length ? layoutBtn('categories', 'Categories') : ''}
|
|
${layoutBtn('shelves', 'Shelves')}
|
|
${layoutBtn('catalog', 'Catalog')}
|
|
</div>
|
|
${viewToggle()}
|
|
</div>
|
|
</div>
|
|
${body}
|
|
</div>`;
|
|
|
|
}
|
|
|
|
/* ---------- folder ---------- */
|
|
|
|
/** What the catalogue (or the sidecar file) says about a course, under its title. */
|
|
function courseAbout(m) {
|
|
if (!m) return '';
|
|
const blurb = String(m.short_description || m.description || '');
|
|
const seen = new Set([m.difficulty, m.vendor].filter(Boolean).map((x) => x.toLowerCase()));
|
|
const chip = (text, cls) => {
|
|
if (!text || seen.has(text.toLowerCase())) return '';
|
|
seen.add(text.toLowerCase());
|
|
return `<span class="pill${cls ? ' ' + cls : ''}">${esc(text)}</span>`;
|
|
};
|
|
const chips = [
|
|
m.difficulty ? `<span class="pill">${esc(m.difficulty)}</span>` : '',
|
|
m.vendor ? `<span class="pill pill-quiet">${esc(m.vendor)}</span>` : '',
|
|
...(m.learning_paths || []).slice(0, 3).map((p) => chip(p, 'pill-quiet')),
|
|
...(m.tags || []).slice(0, 10).map((t) => chip(t, 'pill-quiet')),
|
|
].filter(Boolean).join('');
|
|
if (!blurb && !chips) return '';
|
|
return `<div class="course-about">
|
|
${blurb ? `<p>${esc(blurb.length > 420 ? blurb.slice(0, 419).trim() + '…' : blurb)}</p>` : ''}
|
|
${chips ? `<div class="chips-inline">${chips}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
async function renderFolder(id) {
|
|
spinner();
|
|
const data = await load(`api/library.php?view=folder&id=${id}`);
|
|
const mode = pref('view');
|
|
const hue = hueOf(data.course_id);
|
|
const isCategory = data.kind === 'category';
|
|
|
|
// A category holds courses (and possibly more categories), so it reads like
|
|
// a small library of its own; a course holds sections, which are just a
|
|
// list. Both come back on the same route — only the shape below differs.
|
|
const sections = isCategory
|
|
? (data.categories.length ? `
|
|
<div class="section-head">
|
|
<h2 class="h-section">Categories</h2>
|
|
${viewToggle()}
|
|
</div>
|
|
<div style="margin-bottom:32px">${categoryList(data.categories, mode)}</div>` : '')
|
|
+ (data.courses.length ? `
|
|
<div class="section-head">
|
|
<h2 class="h-section">Courses</h2>
|
|
${data.categories.length ? '' : viewToggle()}
|
|
</div>
|
|
${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '')
|
|
: (data.subfolders.length ? `
|
|
<div class="section-head">
|
|
<h2 class="h-section">Sections</h2>
|
|
${viewToggle()}
|
|
</div>
|
|
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '');
|
|
|
|
const items = [
|
|
...data.videos.map((v) => ({ ...v, kind: 'video' })),
|
|
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
|
|
].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' }));
|
|
|
|
const hasChildren = isCategory
|
|
? (data.categories.length + data.courses.length) > 0
|
|
: data.subfolders.length > 0;
|
|
|
|
const content = items.length ? `
|
|
<h2 class="h-section" style="margin-bottom:14px">Content</h2>
|
|
<div class="rows">${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}</div>`
|
|
: (!hasChildren
|
|
? `<div class="empty"><h2>This folder is empty</h2>
|
|
<p>No videos or quizzes were found here in the last scan.</p></div>` : '');
|
|
|
|
const courseCount = isCategory ? data.categories.reduce((n, c) => n + c.courses, data.courses.length) : 0;
|
|
const meta = [
|
|
courseCount ? `${courseCount} course${courseCount === 1 ? '' : 's'}` : '',
|
|
data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '',
|
|
data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '',
|
|
fmtLong(data.rollup.duration),
|
|
fmtBytes(data.rollup.bytes),
|
|
].filter(Boolean).join(' · ');
|
|
|
|
view.innerHTML = `
|
|
<div class="page">
|
|
${crumbs(data.breadcrumbs)}
|
|
<div class="page-head">
|
|
<div>
|
|
${isCategory ? '<span class="eyebrow eyebrow-sm">Category</span>' : ''}
|
|
<h1>${esc(data.folder.name)}</h1>
|
|
<p class="page-sub">${esc(meta || 'Empty folder')}</p>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:14px">
|
|
<span class="mono-sm">
|
|
${data.progress.completed} / ${data.progress.total} done
|
|
</span>
|
|
${data.progress.total ? resetBtn('folder', data.folder.id, data.folder.name) : ''}
|
|
</div>
|
|
</div>
|
|
${courseAbout(data.meta)}
|
|
<div class="bar-row" style="margin:0 0 32px">
|
|
${bar(data.progress.percent, hue)}
|
|
<span class="mono">${data.progress.percent}%</span>
|
|
</div>
|
|
${sections}${content}
|
|
</div>`;
|
|
|
|
}
|
|
|
|
/* ---------- search ---------- */
|
|
|
|
async function renderSearch(query) {
|
|
if (!query) {
|
|
view.innerHTML = `
|
|
<div class="page-mid">
|
|
<h1>Search the library</h1>
|
|
<p class="page-sub">Type to match course titles, folder paths, and individual video names.</p>
|
|
</div>`;
|
|
document.getElementById('search-input').focus();
|
|
return;
|
|
}
|
|
|
|
spinner();
|
|
const data = await load(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
|
|
|
|
const total = data.folders.length + data.videos.length + data.quizzes.length;
|
|
|
|
const section = (title, count, html) => !count ? '' : `
|
|
<div style="margin-bottom:32px">
|
|
<div class="eyebrow eyebrow-sm" style="margin-bottom:12px">${title}</div>
|
|
${html}
|
|
</div>`;
|
|
|
|
const courses = `<div class="stack">${data.folders.map((c) => `
|
|
<a class="row-card" href="#/folder/${c.id}">
|
|
<span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(c.name)}</div>
|
|
<div class="row-sub">${esc([KIND_NAME[c.kind], courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
|
|
</div>
|
|
<div class="row-bar" style="flex:none;width:120px">
|
|
${bar(c.progress.percent, hueOf(c.id))}
|
|
<span class="mono">${c.progress.percent}%</span>
|
|
</div>
|
|
</a>`).join('')}</div>`;
|
|
|
|
const videos = `<div class="rows">${data.videos.map((v) => videoRow(v, v.folder_path)).join('')}</div>`;
|
|
const quizzes = `<div class="rows">${data.quizzes.map((q) => quizRow(q, q.folder_path)).join('')}</div>`;
|
|
|
|
view.innerHTML = `
|
|
<div class="page-mid">
|
|
<h1>${total} result${total === 1 ? '' : 's'} for “${esc(query)}”</h1>
|
|
<p class="page-sub" style="margin-bottom:26px">Matches across course titles, folder paths, and video names.</p>
|
|
${total === 0 ? `
|
|
<div class="empty">
|
|
<h2>No files match “${esc(query)}”</h2>
|
|
<p>Try a different word, or scan the library for new files.</p>
|
|
<button class="btn btn-accent" data-scan>Scan library</button>
|
|
</div>`
|
|
: section('Courses and sections', data.folders.length, courses)
|
|
+ section('Videos', data.videos.length, videos)
|
|
+ section('Quizzes', data.quizzes.length, quizzes)}
|
|
</div>`;
|
|
|
|
}
|
|
|
|
/* ---------- watch ---------- */
|
|
|
|
async function renderWatch(id) {
|
|
spinner();
|
|
const data = await load(`api/library.php?view=video&id=${id}`);
|
|
|
|
const playlist = data.playlist.map((p) => {
|
|
const active = p.id === data.video.id;
|
|
const cls = p.completed ? ' is-done' : (p.position > 5 ? ' is-partial' : '');
|
|
return `<a class="row${active ? ' is-active' : ''}" href="#/watch/${p.id}">
|
|
<span class="play-dot${cls}">${iconFill('play', 11)}</span>
|
|
<div class="row-main"><div class="row-title">${esc(p.name)}</div></div>
|
|
<span class="row-num">${esc(fmt(p.duration))}</span>
|
|
</a>`;
|
|
}).join('');
|
|
|
|
const quizzes = data.quizzes.length ? `
|
|
<div class="playlist" style="margin-top:16px">
|
|
<div class="playlist-head">${icon('quiz', 15, 1.7)}Quizzes in this section</div>
|
|
<div>${data.quizzes.map((q) => quizRow(q)).join('')}</div>
|
|
</div>` : '';
|
|
|
|
view.innerHTML = `
|
|
<div class="page">
|
|
${crumbs(data.breadcrumbs, data.video.name)}
|
|
<div class="watch">
|
|
<div>
|
|
<div class="player-frame">
|
|
<video id="player" playsinline controls>
|
|
<source src="${data.stream_url}" type="video/mp4">
|
|
${data.captions_url
|
|
? `<track kind="captions" label="English" srclang="en" src="${data.captions_url}">` : ''}
|
|
</video>
|
|
</div>
|
|
<div class="watch-head">
|
|
<h1>${esc(data.video.name)}</h1>
|
|
<div class="watch-actions">
|
|
${data.prev_id ? `<a class="btn" href="#/watch/${data.prev_id}">${icon('prev', 15, 1.7)}Previous</a>` : ''}
|
|
${data.next_id ? `<a class="btn" href="#/watch/${data.next_id}">Next${icon('next', 15, 1.7)}</a>` : ''}
|
|
${resetBtn('video', data.video.id, data.video.name, 'Reset')}
|
|
</div>
|
|
</div>
|
|
${data.resume_at > 0
|
|
? `<div class="watch-note">${icon('clock', 14)}Resuming from ${fmt(data.resume_at)}</div>` : ''}
|
|
<div class="watch-note" id="save-status"></div>
|
|
</div>
|
|
<div>
|
|
<div class="playlist">
|
|
<div class="playlist-head">${icon('list_ol', 15, 1.7)}${esc(data.folder.name)}</div>
|
|
<div class="playlist-scroll">${playlist}</div>
|
|
</div>
|
|
${quizzes}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Bring the current entry into the playlist's own scroll box (not the
|
|
// page's — scrollIntoView would drag the viewport down to it on mobile).
|
|
const scroller = view.querySelector('.playlist-scroll');
|
|
const current = scroller && scroller.querySelector('.is-active');
|
|
if (current) {
|
|
const offset = current.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
scroller.scrollTop += offset - (scroller.clientHeight - current.offsetHeight) / 2;
|
|
}
|
|
|
|
cleanupView = initPlayer(data);
|
|
}
|
|
|
|
/* ---------- quiz ---------- */
|
|
|
|
async function renderQuiz(id) {
|
|
spinner();
|
|
const data = await load(`api/quiz.php?id=${id}`);
|
|
|
|
const answers = data.questions.map(() => []);
|
|
let graded = null; // server results once submitted
|
|
|
|
function optionClass(qi, oi) {
|
|
const picked = answers[qi].includes(oi);
|
|
if (!graded) return picked ? ' is-picked' : '';
|
|
const r = graded.results[qi];
|
|
if (r.correct_options.includes(oi)) return ' is-correct';
|
|
if (r.selected.includes(oi)) return ' is-missed';
|
|
return '';
|
|
}
|
|
|
|
function draw() {
|
|
const questions = data.questions.map((q, qi) => {
|
|
const r = graded ? graded.results[qi] : null;
|
|
const cardCls = r ? (r.correct ? ' is-right' : ' is-wrong') : '';
|
|
return `<div class="q-card${cardCls}" data-qi="${qi}">
|
|
<div class="q-head">
|
|
<span class="q-num">${qi + 1}</span>
|
|
<div style="flex:1">
|
|
<div class="q-text">${esc(q.text)}</div>
|
|
${q.image ? `<figure class="q-exhibit">
|
|
<img src="${esc(q.image.url)}" alt="${esc(q.image.alt)}" loading="lazy" referrerpolicy="no-referrer">
|
|
<figcaption>${esc(q.image.alt)} · <a href="${esc(q.image.url)}" target="_blank" rel="noopener">open</a></figcaption>
|
|
</figure>` : ''}
|
|
${q.multi && !r ? '<div class="q-note" style="color:var(--ink-3)">Select every answer that applies.</div>' : ''}
|
|
${r ? `<div class="q-note${r.correct ? '' : ' is-wrong'}">${r.correct
|
|
? 'Correct.' : 'Not quite — the right answer is highlighted below.'}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="q-options">
|
|
${q.options.map((o, oi) => {
|
|
const tag = graded
|
|
? (graded.results[qi].correct_options.includes(oi) ? 'Correct'
|
|
: (graded.results[qi].selected.includes(oi) ? 'Your answer' : ''))
|
|
: '';
|
|
return `<button type="button" class="q-option${q.multi ? ' multi' : ''}${optionClass(qi, oi)}"
|
|
data-qi="${qi}" data-oi="${oi}"${graded ? ' disabled' : ''}>
|
|
<span class="mark"></span>
|
|
<span class="label">${esc(o)}</span>
|
|
<span class="tag">${tag}</span>
|
|
</button>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const answered = answers.filter((a) => a.length).length;
|
|
const scoreBlock = graded ? (() => {
|
|
const pass = graded.completed;
|
|
return `<div class="quiz-score${pass ? '' : ' is-fail'}">
|
|
<div class="quiz-score-num">${graded.score}/${graded.total}</div>
|
|
<div class="quiz-score-body">
|
|
<div class="quiz-score-head">${pass ? 'Quiz passed' : 'Not passed yet'}</div>
|
|
<div class="quiz-score-sub">${pass
|
|
? 'Every question answered correctly — this quiz counts as complete.'
|
|
: `${graded.total - graded.score} question${graded.total - graded.score === 1 ? '' : 's'} to get right for a complete pass.`}</div>
|
|
</div>
|
|
<button class="btn" data-quiz-retry>Try again</button>
|
|
</div>`;
|
|
})() : '';
|
|
|
|
view.innerHTML = `
|
|
<div class="page-narrow">
|
|
<a class="back-link" href="#/folder/${data.folder.id}">${icon('back', 14, 1.8)}Back to ${esc(data.folder.name)}</a>
|
|
<div class="eyebrow eyebrow-sm">${esc(data.folder.name)}</div>
|
|
<h1 style="margin:10px 0 6px">${esc(data.quiz.name)}</h1>
|
|
<p class="page-sub" style="margin-bottom:24px">
|
|
${data.questions.length} question${data.questions.length === 1 ? '' : 's'}.
|
|
Answer them all, then submit — nothing is graded until you do.
|
|
${data.progress.attempts ? `Best so far ${data.progress.best_score}/${data.progress.total}.` : ''}
|
|
</p>
|
|
${scoreBlock}
|
|
<div class="stack" style="gap:14px">${questions}</div>
|
|
<div class="quiz-actions">
|
|
<button class="btn btn-accent btn-lg" data-quiz-submit${graded ? ' disabled' : ''}>Submit answers</button>
|
|
<span class="hint">${graded
|
|
? `${graded.score} of ${graded.total} correct`
|
|
: `${answered} of ${data.questions.length} answered`}</span>
|
|
${data.progress.attempts ? resetBtn('quiz', data.quiz.id, data.quiz.name, 'Reset attempts') : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
draw();
|
|
|
|
// Exhibits are external images; when one can't load, say so instead of
|
|
// leaving a blank box (the caption's link still opens the original).
|
|
const onImageError = (e) => {
|
|
const fig = e.target.closest && e.target.closest('.q-exhibit');
|
|
if (fig) fig.classList.add('is-broken');
|
|
};
|
|
view.addEventListener('error', onImageError, true); // error events don't bubble
|
|
|
|
let submitting = false;
|
|
// One delegated handler for the whole quiz view, re-bound on each draw.
|
|
const onClick = async (e) => {
|
|
const opt = e.target.closest('.q-option');
|
|
if (opt && !graded) {
|
|
const qi = +opt.dataset.qi, oi = +opt.dataset.oi;
|
|
const multi = data.questions[qi].multi;
|
|
if (multi) {
|
|
const at = answers[qi].indexOf(oi);
|
|
if (at === -1) answers[qi].push(oi); else answers[qi].splice(at, 1);
|
|
} else {
|
|
answers[qi] = [oi];
|
|
}
|
|
draw();
|
|
return;
|
|
}
|
|
const submit = e.target.closest('[data-quiz-submit]');
|
|
if (submit && !graded) {
|
|
if (submitting) return; // a second click while the first is in flight
|
|
submitting = true;
|
|
submit.disabled = true;
|
|
try {
|
|
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
|
|
draw();
|
|
toast(graded.completed
|
|
? `Perfect score — ${graded.score}/${graded.total}.`
|
|
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
|
|
loadMeta();
|
|
} catch (err) {
|
|
submit.disabled = false;
|
|
toast(err.message, 'error');
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
return;
|
|
}
|
|
if (e.target.closest('[data-quiz-retry]')) {
|
|
graded = null;
|
|
answers.forEach((a, i) => { answers[i] = []; });
|
|
draw();
|
|
}
|
|
};
|
|
view.addEventListener('click', onClick);
|
|
cleanupView = () => {
|
|
view.removeEventListener('click', onClick);
|
|
view.removeEventListener('error', onImageError, true);
|
|
};
|
|
}
|
|
|
|
/* ---------- settings ---------- */
|
|
|
|
async function renderSettings() {
|
|
spinner();
|
|
const data = await load('api/library.php?view=settings');
|
|
|
|
const toggles = [
|
|
{ key: 'autoplay', label: 'Autoplay next video',
|
|
sub: 'Starts the next lesson a moment after one ends.' },
|
|
{ key: 'list-default', label: 'Open the library in list view',
|
|
sub: 'Otherwise courses open as a grid of cards.' },
|
|
];
|
|
|
|
const isOn = (key) => key === 'list-default' ? pref('view') === 'list' : pref(key) !== false;
|
|
|
|
view.innerHTML = `
|
|
<div class="page-narrow">
|
|
<h1 style="margin-bottom:26px">Settings</h1>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Library folder</h3>
|
|
<p>Scanned for courses, videos and quizzes on demand.</p>
|
|
<div class="stack" style="margin-bottom:14px">
|
|
<div class="path-row">
|
|
${icon('folder', 16)}
|
|
<span class="path">${esc(data.root)}</span>
|
|
<span class="count">${data.totals.courses} course${data.totals.courses === 1 ? '' : 's'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="fact-row"><span>Indexed</span>
|
|
<span class="mono">${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}</span></div>
|
|
<div class="fact-row"><span>Courses found</span>
|
|
<span class="mono">${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}</span></div>
|
|
<div class="fact-row"><span>Shelves</span>
|
|
<span class="mono">${data.shelf_counts.progress} in progress ·
|
|
${data.shelf_counts.finished} finished ·
|
|
${data.shelf_counts.fresh} not started</span></div>
|
|
<div class="fact-row"><span>Total running time</span>
|
|
<span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div>
|
|
<div class="fact-row"><span>A video counts as watched at</span>
|
|
<span class="mono">${data.complete_threshold}%</span></div>
|
|
<div class="fact-row"><span>Last scan</span>
|
|
<span class="mono">${esc(relTime(data.last_scan))}</span></div>
|
|
<div class="fact-row"><span>Catalogue</span>
|
|
<span class="mono">${data.catalog_courses ? `${data.catalog_courses} courses seeded` : 'not seeded'}</span></div>
|
|
${data.course_meta_counts ? `<div class="fact-row"><span>Course details</span>
|
|
<span class="mono">${data.course_meta_counts.sidecar} from files ·
|
|
${data.course_meta_counts.catalog} from the catalogue ·
|
|
${data.course_meta_counts.none} without</span></div>` : ''}
|
|
${data.root_exists ? '' : `<div class="alert" style="margin-top:14px">
|
|
This folder is not readable right now — scanning will fail until it is back.</div>`}
|
|
<div style="display:flex;gap:10px;margin-top:16px">
|
|
<button class="btn btn-accent" data-scan>Scan library</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Playback and display</h3>
|
|
<p>Stored in this browser, for this device.</p>
|
|
${toggles.map((t) => `
|
|
<div class="toggle-row">
|
|
<div style="flex:1">
|
|
<div class="label">${esc(t.label)}</div>
|
|
<div class="sub">${esc(t.sub)}</div>
|
|
</div>
|
|
<button class="switch${isOn(t.key) ? ' is-on' : ''}" role="switch"
|
|
aria-checked="${isOn(t.key)}" aria-label="${esc(t.label)}"
|
|
data-toggle="${t.key}"><span></span></button>
|
|
</div>`).join('')}
|
|
</section>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Course advisor</h3>
|
|
${data.advisor && data.advisor.enabled ? `
|
|
<p>On — answers come from ${esc(data.advisor.model || 'the configured model')}.
|
|
You have used ${data.advisor.used_today} of ${data.advisor.limit} question${data.advisor.limit === 1 ? '' : 's'} today.</p>
|
|
<div class="toggle-row">
|
|
<div style="flex:1">
|
|
<div class="label">Show the advisor on the home page</div>
|
|
<div class="sub">Hides the box in this browser only; the server setting stays on.</div>
|
|
</div>
|
|
<button class="switch${pref('advisor') !== false ? ' is-on' : ''}" role="switch"
|
|
aria-checked="${pref('advisor') !== false}" aria-label="Show the advisor on the home page"
|
|
data-toggle="advisor"><span></span></button>
|
|
</div>`
|
|
: `<p>Off. Set AI_ENABLED and AI_API_KEY in config.php to turn it on — the app works the same without it,
|
|
and makes no outbound requests while it is off.</p>`}
|
|
</section>
|
|
|
|
<section class="card settings-section is-danger">
|
|
<h3>Reset progress</h3>
|
|
<p>You have progress on ${data.progress_courses} of ${data.courses} course${data.courses === 1 ? '' : 's'}.
|
|
Resetting clears watched marks, resume positions and quiz scores. Video files are never touched.</p>
|
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
|
${resetBtn('all', 0, '', 'Reset all progress')}
|
|
<span class="hint-inline">
|
|
Or reset one course from its row in the library list.</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card settings-section">
|
|
<h3>Account</h3>
|
|
<p>Signed in as ${esc(data.username || '')}.
|
|
${data.registration_open ? 'New accounts can be created from the sign-in page.'
|
|
: 'Registration is closed on this install.'}</p>
|
|
<button class="btn" data-logout>${icon('logout', 15)}Sign out</button>
|
|
</section>
|
|
</div>`;
|
|
|
|
}
|
|
|
|
/* ---------- player + progress tracking ---------- */
|
|
|
|
function initPlayer(data) {
|
|
const el = document.getElementById('player');
|
|
const statusEl = document.getElementById('save-status');
|
|
const player = new Plyr(el, {
|
|
// Plyr fetches its icon sprite and blank video from its CDN unless told
|
|
// otherwise — everything ships locally now.
|
|
iconUrl: 'assets/vendor/plyr.svg',
|
|
blankVideo: 'assets/vendor/blank.mp4',
|
|
keyboard: { focused: true, global: true },
|
|
seekTime: 10,
|
|
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
|
|
captions: { active: false, update: true },
|
|
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
|
|
'current-time', 'duration', 'mute', 'volume', 'captions',
|
|
'settings', 'pip', 'fullscreen'],
|
|
});
|
|
|
|
let resumed = data.resume_at <= 0;
|
|
el.addEventListener('loadedmetadata', () => {
|
|
if (!resumed && data.resume_at < el.duration - 2) el.currentTime = data.resume_at;
|
|
resumed = true;
|
|
});
|
|
|
|
// A play button elsewhere in the app (the home hero) asked for the video to
|
|
// start, not just for the player to open. Autoplay is only permitted off the
|
|
// gesture that got us here, so the flag is consumed once and never survives
|
|
// a reload — and it waits for the resume seek above, so play() picks up at
|
|
// the saved position rather than at zero.
|
|
if (state.playOnLoad === data.video.id) {
|
|
state.playOnLoad = null;
|
|
const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); };
|
|
if (el.readyState >= 1) start();
|
|
else el.addEventListener('loadedmetadata', start, { once: true });
|
|
}
|
|
|
|
let lastSaved = -1;
|
|
let saveTimer = null;
|
|
|
|
const payload = () => ({
|
|
video_id: data.video.id,
|
|
position: el.currentTime || 0,
|
|
duration: el.duration || 0,
|
|
});
|
|
|
|
const status = (html) => { if (statusEl) statusEl.innerHTML = html; };
|
|
|
|
async function save(force = false) {
|
|
const p = payload();
|
|
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
|
|
if (!p.duration || (!force && Math.abs(p.position - lastSaved) < 1)) return;
|
|
lastSaved = p.position;
|
|
try {
|
|
const res = await api('api/progress.php', { json: p, keepalive: true });
|
|
status(res.completed
|
|
? `${icon('check', 14, 2)}Completed`
|
|
: `${icon('clock', 14)}Progress saved`);
|
|
} catch (e) { /* transient — the next heartbeat retries */ }
|
|
}
|
|
|
|
function saveBeacon() {
|
|
const p = payload();
|
|
if (state.suppressSave || !p.duration) return;
|
|
navigator.sendBeacon('api/progress.php',
|
|
new URLSearchParams(Object.entries({ ...p, csrf: state.csrf || '' })
|
|
.map(([k, v]) => [k, String(v)])));
|
|
}
|
|
|
|
let nextTimer = null;
|
|
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(() => save(), 5000); });
|
|
player.on('pause', () => save());
|
|
player.on('seeked', () => save());
|
|
player.on('ended', () => {
|
|
save(true); // the final position always lands, even within 1 s of the last heartbeat
|
|
if (data.next_id && pref('autoplay') !== false) {
|
|
status(`${icon('next', 14, 1.7)}Playing next video…`);
|
|
// "Autoplay next" has to actually start it — navigating alone just
|
|
// opened the next video paused. The gesture that began this playback
|
|
// still counts, so the browser lets the next one start.
|
|
state.playOnLoad = data.next_id;
|
|
nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
|
|
}
|
|
});
|
|
|
|
const onHide = () => { if (document.visibilityState === 'hidden') saveBeacon(); };
|
|
document.addEventListener('visibilitychange', onHide);
|
|
window.addEventListener('pagehide', saveBeacon);
|
|
|
|
return function cleanup() {
|
|
saveBeacon();
|
|
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
|
|
// Autoplay-next arms playOnLoad before it navigates, and route() tears the
|
|
// old view down after the hash has already changed — so only disarm when
|
|
// we are leaving for somewhere other than the video it queued.
|
|
if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) {
|
|
state.playOnLoad = null;
|
|
}
|
|
if (saveTimer) clearInterval(saveTimer);
|
|
document.removeEventListener('visibilitychange', onHide);
|
|
window.removeEventListener('pagehide', saveBeacon);
|
|
try { player.destroy(); } catch (e) { /* already gone */ }
|
|
};
|
|
}
|
|
|
|
/* ---------- reset ---------- */
|
|
|
|
const RESET_COPY = {
|
|
all: () => ['Reset all progress?',
|
|
'Every saved position, watched mark and quiz result on your account is deleted. '
|
|
+ 'Your video files stay where they are.'],
|
|
folder: (label) => [`Reset progress on “${label}”?`,
|
|
'Saved positions, watched marks and quiz results inside this folder, including its '
|
|
+ 'subfolders, are deleted. Your video files stay where they are.'],
|
|
video: (label) => [`Reset progress on “${label}”?`,
|
|
'The saved position and watched mark for this video are deleted.'],
|
|
quiz: (label) => [`Reset attempts on “${label}”?`,
|
|
'Your attempts, best score and completion mark for this quiz are deleted.'],
|
|
};
|
|
|
|
async function doReset(scope, id, label) {
|
|
const copy = RESET_COPY[scope];
|
|
if (!copy) return;
|
|
const [title, body] = copy(label);
|
|
if (!await confirmDialog({ title, body, confirmLabel: 'Reset' })) return;
|
|
|
|
// The player's teardown would otherwise re-save the position just wiped.
|
|
state.suppressSave = true;
|
|
try {
|
|
const res = await api('api/reset.php', { json: { scope, id } });
|
|
const n = res.reset.videos + res.reset.quizzes;
|
|
toast(n
|
|
? `Progress reset — ${res.reset.videos} video${res.reset.videos === 1 ? '' : 's'}, `
|
|
+ `${res.reset.quizzes} quiz${res.reset.quizzes === 1 ? '' : 'zes'}.`
|
|
: 'Nothing to reset — no progress was saved here.');
|
|
await loadMeta();
|
|
route();
|
|
} catch (err) {
|
|
state.suppressSave = false;
|
|
toast(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
/* ---------- scan ---------- */
|
|
|
|
async function runScan() {
|
|
if (state.scanning) return;
|
|
state.scanning = true;
|
|
shell.classList.add('is-scanning');
|
|
document.getElementById('scan-label').textContent = 'Scanning…';
|
|
document.getElementById('scan-when').textContent = 'now';
|
|
document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = true; });
|
|
|
|
try {
|
|
const res = await api('api/scan.php', { method: 'POST' });
|
|
const s = res.stats;
|
|
toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed`
|
|
+ (s.meta_none === undefined ? '.' : `; details for ${s.courses - s.meta_none} of ${s.courses} courses.`));
|
|
await loadMeta();
|
|
route();
|
|
} catch (err) {
|
|
toast(err.message, 'error');
|
|
} finally {
|
|
state.scanning = false;
|
|
shell.classList.remove('is-scanning');
|
|
document.getElementById('scan-label').textContent = 'Scan library';
|
|
document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = false; });
|
|
renderChrome();
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ }
|
|
state.user = null;
|
|
state.meta = null;
|
|
location.hash = '#/';
|
|
showAuth();
|
|
}
|
|
|
|
/* ---------- router ---------- */
|
|
|
|
async function route() {
|
|
routeSeq++; // anything still loading for the previous view is now stale
|
|
if (cleanupView) { cleanupView(); cleanupView = null; }
|
|
state.suppressSave = false; // the teardown above is the only thing it guarded
|
|
closeMenu();
|
|
|
|
if (!state.user) { showAuth(); return; }
|
|
showShell();
|
|
|
|
const hash = location.hash || '#/';
|
|
const searchMatch = hash.match(/^#\/search\/(.*)$/);
|
|
const query = searchMatch ? safeDecode(searchMatch[1]) : '';
|
|
syncSearchInput(query);
|
|
renderChrome();
|
|
// Views replace each other in the same document, so the page keeps the old
|
|
// scroll offset unless told otherwise — except while a search is being refined.
|
|
if (!(searchMatch && document.activeElement === searchInput)) window.scrollTo(0, 0);
|
|
|
|
let m;
|
|
try {
|
|
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
|
|
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
|
|
else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]);
|
|
else if (searchMatch) await renderSearch(query);
|
|
else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null);
|
|
else if (hash.startsWith('#/settings')) await renderSettings();
|
|
else await renderHome();
|
|
} catch (err) {
|
|
if (err instanceof StaleRender) return; // a newer route is drawing; nothing to show
|
|
if (state.user) {
|
|
view.innerHTML = `<div class="empty">
|
|
<h2>That didn't load</h2>
|
|
<p>${esc(err.message)}</p>
|
|
<a class="btn btn-accent" href="#/">Back to home</a>
|
|
</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- search box ---------- */
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const searchClear = document.getElementById('btn-search-clear');
|
|
let searchTimer = null;
|
|
|
|
function syncSearchInput(query) {
|
|
if (document.activeElement !== searchInput && searchInput.value !== query) {
|
|
searchInput.value = query;
|
|
}
|
|
searchClear.hidden = !searchInput.value;
|
|
}
|
|
|
|
/** Search routes replace each other, so Back doesn't walk through keystrokes. */
|
|
function goSearch(query, replace) {
|
|
const target = query ? `#/search/${encodeURIComponent(query)}` : '#/search/';
|
|
if (location.hash === target) { route(); return; }
|
|
if (replace) {
|
|
history.replaceState(null, '', target);
|
|
route(); // replaceState doesn't fire hashchange
|
|
} else {
|
|
location.hash = target;
|
|
}
|
|
}
|
|
|
|
searchInput.addEventListener('input', () => {
|
|
searchClear.hidden = !searchInput.value;
|
|
clearTimeout(searchTimer);
|
|
const q = searchInput.value.trim();
|
|
if (q && q.length < 2) return; // one character matches nearly everything
|
|
searchTimer = setTimeout(() => {
|
|
const onSearch = location.hash.startsWith('#/search/');
|
|
if (!q && !onSearch) return;
|
|
goSearch(q, onSearch); // replace while refining a search
|
|
}, 350);
|
|
});
|
|
|
|
searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Enter') return;
|
|
e.preventDefault();
|
|
clearTimeout(searchTimer);
|
|
goSearch(searchInput.value.trim(), location.hash.startsWith('#/search/'));
|
|
});
|
|
|
|
searchClear.addEventListener('click', () => {
|
|
clearTimeout(searchTimer);
|
|
searchInput.value = '';
|
|
searchClear.hidden = true;
|
|
searchInput.focus();
|
|
if (location.hash.startsWith('#/search/')) goSearch('', true);
|
|
});
|
|
|
|
/* ---------- delegated actions ---------- */
|
|
|
|
document.addEventListener('click', (e) => {
|
|
// Not a preventDefault: the link still navigates to #/watch/N, this only
|
|
// tells the player it was reached from a play button and should start.
|
|
const playLink = e.target.closest('[data-play]');
|
|
if (playLink && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
|
|
state.playOnLoad = +playLink.dataset.play;
|
|
}
|
|
|
|
const scan = e.target.closest('[data-scan]');
|
|
if (scan) { e.preventDefault(); runScan(); return; }
|
|
|
|
const layout = e.target.closest('[data-view-mode]');
|
|
if (layout) { setPref('view', layout.dataset.viewMode); route(); return; }
|
|
|
|
const shelvesOrCatalog = e.target.closest('[data-layout]');
|
|
if (shelvesOrCatalog) {
|
|
setPref('layout', shelvesOrCatalog.dataset.layout);
|
|
if (location.hash.startsWith('#/library/')) { location.hash = '#/library'; return; }
|
|
route();
|
|
return;
|
|
}
|
|
|
|
const toggle = e.target.closest('[data-toggle]');
|
|
if (toggle) {
|
|
const key = toggle.dataset.toggle;
|
|
if (key === 'list-default') {
|
|
setPref('view', pref('view') === 'list' ? 'grid' : 'list');
|
|
} else {
|
|
setPref(key, pref(key) === false);
|
|
}
|
|
route();
|
|
return;
|
|
}
|
|
|
|
const reset = e.target.closest('[data-reset]');
|
|
if (reset) {
|
|
e.preventDefault();
|
|
doReset(reset.dataset.reset, +reset.dataset.id || 0, reset.dataset.label || '');
|
|
return;
|
|
}
|
|
|
|
if (e.target.closest('[data-logout]')) { logout(); return; }
|
|
});
|
|
|
|
document.getElementById('btn-account').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (document.getElementById('account-menu')) { closeMenu(); return; }
|
|
openMenu(e.currentTarget, `
|
|
<div class="menu-user">
|
|
<div class="who">${esc(state.user || '')}</div>
|
|
<div class="what">${state.meta ? state.meta.totals.videos : 0} videos in the library</div>
|
|
</div>
|
|
<button data-action="settings">${icon('settings', 15)}Settings</button>
|
|
<button data-action="reset">${icon('reset', 15, 1.7)}Reset all progress</button>
|
|
<button class="is-danger" data-action="logout">${icon('logout', 15)}Sign out</button>`,
|
|
(action) => {
|
|
if (action === 'settings') location.hash = '#/settings';
|
|
else if (action === 'reset') doReset('all', 0, '');
|
|
else if (action === 'logout') logout();
|
|
});
|
|
});
|
|
|
|
/* ---------- boot ---------- */
|
|
|
|
window.addEventListener('hashchange', route);
|
|
|
|
(async function boot() {
|
|
try {
|
|
const me = await fetch('api/auth.php').then((r) => r.json());
|
|
state.user = me.logged_in ? me.username : null;
|
|
state.csrf = me.csrf || null;
|
|
} catch (e) {
|
|
state.user = null;
|
|
}
|
|
|
|
if (!state.user) { showAuth(); return; }
|
|
showShell();
|
|
await loadMeta();
|
|
route();
|
|
})();
|
|
})();
|