Show quiz exhibits, keep sign-ins alive, and close a batch of edge cases

- A quiz question can now carry the diagram it refers to: a Markdown
  image line anywhere inside the question block is parsed out of the
  text and rendered under it (referrerpolicy="no-referrer", lazy). The
  URL points at the course provider, so an offline install gets a note
  and a link instead of an empty box.
- New optional SESSION_LIFETIME (30 days in the example config) replaces
  the browser-session cookie plus php.ini's 24-minute idle limit, which
  signed people out over a long pause. Configs that predate it behave as
  before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie
  is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy
  no longer costs the flag.
- Error responses no longer echo the exception message unless DEBUG: a
  PDOException names the DB user, host and tables, and that handler also
  runs for visitors who never signed in.
- The .htaccess denials are anchored to a path segment rather than the
  root, so videos/ and includes/ stay unreachable under a prefix such as
  /coursehub/ instead of silently opening up.
- A rescan takes a MySQL advisory lock; a second one answers "already
  running" rather than racing the first and flagging its rows missing. A
  file swapped in at the same path with a different size drops its
  cached duration so the probe reads the new length.
- stream.php discards output buffering and zlib compression (both break
  the promised Content-Length) and answers HEAD with headers alone.
- A progress save with no duration keeps the length already stored
  instead of nulling it, and the folder view builds its rollup once.
- In the SPA, a view whose data arrives after the route moved on is
  discarded — previously a slow load could paint over a newer screen or
  start a heartbeat for a video nobody was watching. Also: the quiz
  submit button can't be double-fired, leaving a video during the
  autoplay countdown no longer jumps ahead later, the final position is
  always saved on 'ended', the playlist scrolls the current entry into
  its own box, the login field takes focus, views start at the top, and
  a stray "%" in the hash no longer throws.
- Dropped the unused requireLogin(); registerUser() answers "taken"
  instead of a 500 when two people race for the same name.

Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
This commit is contained in:
Harwinder Sekhon
2026-09-06 12:31:21 -04:00
parent 66e4905796
commit 6a4c972c47
12 changed files with 233 additions and 50 deletions
+77 -23
View File
@@ -20,7 +20,6 @@
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
searchSeq: 0,
scanning: false,
};
let cleanupView = null;
@@ -166,6 +165,23 @@
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 ---------- */
@@ -457,6 +473,7 @@
</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', () => {
@@ -546,7 +563,7 @@
async function renderHome() {
spinner();
const data = await api('api/library.php?view=home');
const data = await load('api/library.php?view=home');
const inProgress = data.in_progress.slice(0, 4);
const progressBlock = inProgress.length
@@ -591,8 +608,9 @@
/* ---------- library ---------- */
async function renderLibrary(filter) {
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
spinner();
const data = await api('api/library.php?view=library');
const data = await load('api/library.php?view=library');
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
const mode = pref('view');
@@ -657,7 +675,7 @@
async function renderFolder(id) {
spinner();
const data = await api(`api/library.php?view=folder&id=${id}`);
const data = await load(`api/library.php?view=folder&id=${id}`);
const mode = pref('view');
const hue = hueOf(data.course_id);
@@ -714,8 +732,6 @@
/* ---------- search ---------- */
async function renderSearch(query) {
const seq = ++state.searchSeq;
if (!query) {
view.innerHTML = `
<div class="page-mid">
@@ -727,8 +743,7 @@
}
spinner();
const data = await api(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
if (seq !== state.searchSeq) return; // a newer keystroke already won
const data = await load(`api/library.php?view=search&q=${encodeURIComponent(query)}`);
const total = data.folders.length + data.videos.length + data.quizzes.length;
@@ -775,7 +790,7 @@
async function renderWatch(id) {
spinner();
const data = await api(`api/library.php?view=video&id=${id}`);
const data = await load(`api/library.php?view=video&id=${id}`);
const playlist = data.playlist.map((p) => {
const active = p.id === data.video.id;
@@ -827,6 +842,15 @@
</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);
}
@@ -834,7 +858,7 @@
async function renderQuiz(id) {
spinner();
const data = await api(`api/quiz.php?id=${id}`);
const data = await load(`api/quiz.php?id=${id}`);
const answers = data.questions.map(() => []);
let graded = null; // server results once submitted
@@ -857,6 +881,10 @@
<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>` : ''}
@@ -916,9 +944,17 @@
</div>`;
}
view.onclick = null;
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');
@@ -934,7 +970,11 @@
draw();
return;
}
if (e.target.closest('[data-quiz-submit]') && !graded) {
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();
@@ -943,7 +983,10 @@
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
loadMeta();
} catch (err) {
submit.disabled = false;
toast(err.message, 'error');
} finally {
submitting = false;
}
return;
}
@@ -954,14 +997,17 @@
}
};
view.addEventListener('click', onClick);
cleanupView = () => view.removeEventListener('click', onClick);
cleanupView = () => {
view.removeEventListener('click', onClick);
view.removeEventListener('error', onImageError, true);
};
}
/* ---------- settings ---------- */
async function renderSettings() {
spinner();
const data = await api('api/library.php?view=settings');
const data = await load('api/library.php?view=settings');
const toggles = [
{ key: 'autoplay', label: 'Autoplay next video',
@@ -1074,10 +1120,10 @@
const status = (html) => { if (statusEl) statusEl.innerHTML = html; };
async function save() {
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 || Math.abs(p.position - lastSaved) < 1) return;
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 });
@@ -1095,14 +1141,15 @@
.map(([k, v]) => [k, String(v)])));
}
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); });
player.on('pause', save);
player.on('seeked', save);
let nextTimer = null;
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(() => save(), 5000); });
player.on('pause', () => save());
player.on('seeked', () => save());
player.on('ended', () => {
save();
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…`);
setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
}
});
@@ -1112,6 +1159,7 @@
return function cleanup() {
saveBeacon();
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
if (saveTimer) clearInterval(saveTimer);
document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', saveBeacon);
@@ -1195,6 +1243,7 @@
/* ---------- 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();
@@ -1204,19 +1253,24 @@
const hash = location.hash || '#/';
const searchMatch = hash.match(/^#\/search\/(.*)$/);
syncSearchInput(searchMatch ? decodeURIComponent(searchMatch[1]) : '');
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(decodeURIComponent(searchMatch[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>
+13
View File
@@ -840,6 +840,19 @@ button, input { font-family: inherit; font-size: inherit; }
.q-text { font: 600 17px/1.4 var(--font-ui); text-wrap: pretty; }
.q-note { font: 500 15px/1.4 var(--font-ui); margin-top: 6px; color: var(--success); }
.q-note.is-wrong { color: var(--danger); }
/* The diagram a question refers to. It lives on the course provider's server,
so it may not load on an offline install — then the caption's link remains. */
.q-exhibit { margin: 12px 0 0; }
.q-exhibit img {
display: block; max-width: 100%; height: auto;
border: 1px solid var(--line); border-radius: 8px; background: var(--paper);
}
.q-exhibit figcaption { margin-top: 6px; font: 500 13px/1.4 var(--font-ui); color: var(--ink-4); }
.q-exhibit figcaption a { color: var(--ink-3); }
.q-exhibit.is-broken img { display: none; }
.q-exhibit.is-broken figcaption::before { content: 'Exhibit could not be loaded — '; color: var(--danger); }
.q-options { display: flex; flex-direction: column; gap: 8px; padding-left: 40px; }
.q-option {