Files
coursehub/assets/app.js
T
Harwinder Sekhon deda7c3c18 Add quiz support: parse *_quiz_solved.txt files, take and grade quizzes
Scanner indexes quiz files found next to videos ([&] questions, "- "
options, "-- Correct" markers; _notsolved twins ignored). Quizzes appear
in the folder content list interleaved with videos by filename order.
New api/quiz.php serves questions without answers and grades attempts
server-side; per-user best/last score and attempts stored in
quiz_progress. Folder/course progress now aggregates videos + quizzes
with partial credit for partial scores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:24:37 -04:00

540 lines
22 KiB
JavaScript

/* CourseHub SPA — hash router + fetch API + Plyr player.
* Routes: #/ home (courses, continue watching)
* #/folder/N browse a folder
* #/watch/N video player
*/
(function () {
'use strict';
const app = document.getElementById('app');
const state = { user: null };
let cleanupView = null; // teardown hook for the active view (player etc.)
/* ---------- helpers ---------- */
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
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')}`;
}
function toast(message, kind = 'success') {
const wrap = document.createElement('div');
wrap.innerHTML = `
<div class="toast align-items-center text-bg-${kind} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${esc(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`;
const el = wrap.firstElementChild;
document.getElementById('toasts').appendChild(el);
const t = new bootstrap.Toast(el, { delay: 4000 });
el.addEventListener('hidden.bs.toast', () => el.remove());
t.show();
}
async function api(url, options = {}) {
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;
updateNav();
renderLogin();
throw new Error(data.error || 'Not logged in');
}
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
function spinner() {
app.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
}
function progressBar(percent, cls = 'bg-success') {
return `<div class="progress progress-thin">
<div class="progress-bar ${cls}" style="width:${percent}%"></div>
</div>`;
}
function breadcrumbs(crumbs, tail = '') {
const items = crumbs.map((c, i) =>
(i === crumbs.length - 1 && !tail)
? `<li class="breadcrumb-item active">${esc(c.name)}</li>`
: `<li class="breadcrumb-item"><a href="#/folder/${c.id}">${esc(c.name)}</a></li>`
).join('');
return `<nav><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#/"><i class="bi bi-house"></i> Courses</a></li>
${items}${tail ? `<li class="breadcrumb-item active">${esc(tail)}</li>` : ''}
</ol></nav>`;
}
/* ---------- nav / auth ---------- */
function updateNav() {
const navUser = document.getElementById('nav-user');
navUser.classList.toggle('d-none', !state.user);
document.getElementById('nav-username').textContent = state.user || '';
}
function renderLogin(error = '') {
if (cleanupView) { cleanupView(); cleanupView = null; }
app.innerHTML = `
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card shadow-sm">
<div class="card-body p-4">
<h1 class="h4 mb-3 text-center"><i class="bi bi-collection-play-fill text-primary"></i> CourseHub</h1>
<div id="login-error">${error ? `<div class="alert alert-danger py-2">${esc(error)}</div>` : ''}</div>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" name="username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" required>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary" data-action="login">Sign in</button>
<button class="btn btn-outline-secondary" data-action="register">Create account</button>
</div>
</form>
</div>
</div>
</div>
</div>`;
const form = document.getElementById('login-form');
let action = 'login';
form.querySelectorAll('button[data-action]').forEach((b) =>
b.addEventListener('click', () => { action = b.dataset.action; }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
try {
const data = await api('api/auth.php', {
json: { action, username: fd.get('username'), password: fd.get('password') },
});
state.user = data.username;
updateNav();
toast(action === 'register' ? 'Account created — welcome!' : `Welcome back, ${data.username}!`);
route();
} catch (err) {
document.getElementById('login-error').innerHTML =
`<div class="alert alert-danger py-2">${esc(err.message)}</div>`;
}
});
}
/* ---------- views ---------- */
async function renderHome() {
spinner();
const data = await api('api/library.php?view=home');
const cont = data.continue.length ? `
<h2 class="h5 mb-3"><i class="bi bi-play-circle me-1"></i>Continue watching</h2>
<div class="row g-3 mb-4">
${data.continue.map((r) => {
const pct = r.duration ? Math.min(100, Math.round(100 * r.position / r.duration)) : 0;
return `
<div class="col-sm-6 col-lg-4">
<a href="#/watch/${r.id}" class="card card-hover h-100 text-decoration-none">
<div class="card-body py-3">
<div class="fw-semibold text-truncate">${esc(r.name)}</div>
<div class="small text-secondary text-truncate mb-2">${esc(r.folder_path)}</div>
${progressBar(pct, '')}
<div class="small text-secondary mt-1">${fmt(r.position)} / ${fmt(r.duration)}</div>
</div>
</a>
</div>`;
}).join('')}
</div>` : '';
const courses = data.courses.length ? data.courses.map((c) => `
<div class="col-sm-6 col-lg-4">
<a href="#/folder/${c.id}" class="card card-hover h-100 text-decoration-none">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-folder-fill text-warning fs-3 me-2"></i>
<div class="fw-semibold">${esc(c.name)}</div>
</div>
${progressBar(c.progress.percent)}
<div class="small text-secondary mt-1">
${c.progress.completed} / ${c.progress.total} completed · ${c.progress.percent}%
</div>
</div>
</a>
</div>`).join('')
: `<div class="col-12"><div class="alert alert-info">
No courses found yet. Add course folders to the videos directory and click
<strong>Rescan library</strong>.</div></div>`;
app.innerHTML = `${cont}
<h2 class="h5 mb-3"><i class="bi bi-mortarboard me-1"></i>Courses</h2>
<div class="row g-3">${courses}</div>`;
}
async function renderFolder(id) {
spinner();
const data = await api(`api/library.php?view=folder&id=${id}`);
const subfolders = data.subfolders.length ? `
<h2 class="h6 text-secondary text-uppercase">Sections</h2>
<div class="list-group mb-4">
${data.subfolders.map((f) => `
<a href="#/folder/${f.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
<i class="bi bi-folder-fill text-warning fs-4"></i>
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold text-truncate">${esc(f.name)}</div>
<div class="mt-1">${progressBar(f.progress.percent)}</div>
</div>
<span class="badge text-bg-secondary">${f.progress.completed}/${f.progress.total}</span>
<i class="bi bi-chevron-right text-secondary"></i>
</a>`).join('')}
</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 content = items.length ? `
<h2 class="h6 text-secondary text-uppercase">Content</h2>
<div class="list-group">
${items.map((it) => {
if (it.kind === 'quiz') {
const icon = it.completed
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
: '<i class="bi bi-patch-question-fill text-info fs-4"></i>';
return `
<a href="#/quiz/${it.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
${icon}
<div class="flex-grow-1 min-w-0">
<div class="text-truncate">${esc(it.name)}</div>
</div>
${it.attempts ? `<span class="badge ${it.completed ? 'text-bg-success' : 'text-bg-secondary'}">
${it.best_score}/${it.questions}</span>` : ''}
<span class="text-secondary small">Quiz · ${it.questions} questions</span>
</a>`;
}
const icon = it.completed
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
: (it.position > 5
? '<i class="bi bi-play-circle-fill text-primary fs-4"></i>'
: '<i class="bi bi-circle text-secondary fs-4"></i>');
return `
<a href="#/watch/${it.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
${icon}
<div class="flex-grow-1 min-w-0">
<div class="text-truncate">${esc(it.name)}</div>
${!it.completed && it.percent > 0 ? `<div class="mt-1">${progressBar(it.percent, '')}</div>` : ''}
</div>
<span class="text-secondary small">${fmt(it.duration)}</span>
</a>`;
}).join('')}
</div>` : (!data.subfolders.length ? '<div class="alert alert-secondary">This folder is empty.</div>' : '');
app.innerHTML = `
${breadcrumbs(data.breadcrumbs)}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h1 class="h4 mb-0"><i class="bi bi-folder2-open text-warning me-2"></i>${esc(data.folder.name)}</h1>
<div class="text-secondary small">${data.progress.completed} / ${data.progress.total} completed</div>
</div>
<div class="mb-4">${progressBar(data.progress.percent)}</div>
${subfolders}${content}`;
}
async function renderQuiz(id) {
spinner();
const data = await api(`api/quiz.php?id=${id}`);
const qHtml = data.questions.map((q, qi) => `
<div class="card mb-3" data-qi="${qi}">
<div class="card-body">
<div class="fw-semibold mb-2">
${qi + 1}. ${esc(q.text)}
${q.multi ? '<span class="badge text-bg-info ms-1">multiple answers</span>' : ''}
</div>
${q.options.map((o, oi) => `
<div class="form-check" data-oi="${oi}">
<input class="form-check-input" type="${q.multi ? 'checkbox' : 'radio'}"
name="q${qi}" id="q${qi}o${oi}" value="${oi}">
<label class="form-check-label" for="q${qi}o${oi}">${esc(o)}</label>
<span class="ms-1 quiz-mark"></span>
</div>`).join('')}
<div class="small mt-2 quiz-feedback"></div>
</div>
</div>`).join('');
const best = data.progress.attempts
? `Best score: ${data.progress.best_score}/${data.progress.total}` +
(data.progress.completed ? ' <i class="bi bi-check-circle-fill text-success"></i>' : '')
: 'Not attempted yet';
app.innerHTML = `
${breadcrumbs(data.breadcrumbs, data.quiz.name)}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h1 class="h4 mb-0"><i class="bi bi-patch-question-fill text-info me-2"></i>${esc(data.quiz.name)}</h1>
<div class="text-secondary small">${best}</div>
</div>
<form id="quiz-form">
${qHtml}
<div class="d-flex gap-2 align-items-center flex-wrap">
<button class="btn btn-primary" type="submit" id="quiz-submit">
<i class="bi bi-send me-1"></i>Submit answers
</button>
<a class="btn btn-outline-secondary" href="#/folder/${data.folder.id}">
Back to ${esc(data.folder.name)}
</a>
<span id="quiz-score" class="fw-semibold"></span>
</div>
</form>`;
const form = document.getElementById('quiz-form');
let graded = false;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (graded) { renderQuiz(id); return; } // "Try again" re-renders fresh
const answers = data.questions.map((q, qi) =>
[...form.querySelectorAll(`input[name="q${qi}"]:checked`)].map((i) => +i.value));
const res = await api('api/quiz.php', { json: { quiz_id: id, answers } });
graded = true;
res.results.forEach((r, qi) => {
const card = form.querySelector(`[data-qi="${qi}"]`);
r.correct_options.forEach((oi) => {
const row = card.querySelector(`[data-oi="${oi}"]`);
row.querySelector('label').classList.add('text-success', 'fw-semibold');
row.querySelector('.quiz-mark').innerHTML = '<i class="bi bi-check-lg text-success"></i>';
});
r.selected.filter((oi) => !r.correct_options.includes(oi)).forEach((oi) => {
const row = card.querySelector(`[data-oi="${oi}"]`);
row.querySelector('label').classList.add('text-danger');
row.querySelector('.quiz-mark').innerHTML = '<i class="bi bi-x-lg text-danger"></i>';
});
const fb = card.querySelector('.quiz-feedback');
fb.innerHTML = r.correct
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Correct</span>'
: '<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Incorrect</span>';
card.classList.add('border', r.correct ? 'border-success' : 'border-danger');
});
form.querySelectorAll('input').forEach((i) => { i.disabled = true; });
document.getElementById('quiz-score').textContent = `Score: ${res.score}/${res.total}`;
const btn = document.getElementById('quiz-submit');
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Try again';
btn.classList.replace('btn-primary', 'btn-outline-primary');
toast(res.completed
? `Perfect score — ${res.score}/${res.total}!`
: `Scored ${res.score}/${res.total}.`,
res.completed ? 'success' : 'secondary');
});
}
async function renderWatch(id) {
spinner();
const data = await api(`api/library.php?view=video&id=${id}`);
const playlist = data.playlist.map((p) => {
const active = p.id === data.video.id;
const icon = p.completed
? `<i class="bi bi-check-circle-fill ${active ? '' : 'text-success'}"></i>`
: (active ? '<i class="bi bi-play-fill"></i>' : '<i class="bi bi-circle text-secondary"></i>');
return `
<a href="#/watch/${p.id}"
class="list-group-item list-group-item-action d-flex align-items-center gap-2 ${active ? 'active' : ''}">
${icon}
<span class="text-truncate flex-grow-1">${esc(p.name)}</span>
<span class="small opacity-75">${fmt(p.duration)}</span>
</a>`;
}).join('');
app.innerHTML = `
${breadcrumbs(data.breadcrumbs, data.video.name)}
<div class="row g-4">
<div class="col-lg-8">
<div class="ratio ratio-16x9 bg-black rounded overflow-hidden mb-3">
<video id="player" playsinline controls>
<source src="${data.stream_url}" type="video/mp4">
</video>
</div>
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 class="h5 mb-0">${esc(data.video.name)}</h1>
<div class="btn-group">
${data.prev_id ? `<a class="btn btn-sm btn-outline-secondary" href="#/watch/${data.prev_id}">
<i class="bi bi-skip-backward-fill me-1"></i>Previous</a>` : ''}
${data.next_id ? `<a class="btn btn-sm btn-outline-secondary" href="#/watch/${data.next_id}">
Next<i class="bi bi-skip-forward-fill ms-1"></i></a>` : ''}
</div>
</div>
${data.resume_at > 0
? `<div class="text-secondary small mt-2">
<i class="bi bi-clock-history me-1"></i>Resuming from ${fmt(data.resume_at)}</div>` : ''}
<div id="save-status" class="text-secondary small mt-1"></div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-list-ol me-1"></i>${esc(data.folder.name)}</div>
<div class="list-group list-group-flush playlist-scroll">${playlist}</div>
</div>
</div>
</div>`;
cleanupView = initPlayer(data);
}
/* ---------- player + progress tracking ---------- */
function initPlayer(data) {
const el = document.getElementById('player');
const statusEl = document.getElementById('save-status');
const player = new Plyr(el, {
keyboard: { focused: true, global: true },
seekTime: 10,
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
'current-time', 'duration', 'mute', 'volume', '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;
});
let lastSaved = -1;
let saveTimer = null;
const payload = () => ({
video_id: data.video.id,
position: el.currentTime || 0,
duration: el.duration || 0,
});
async function save() {
const p = payload();
if (!p.duration || Math.abs(p.position - lastSaved) < 1) return;
lastSaved = p.position;
try {
const res = await api('api/progress.php', { json: p, keepalive: true });
if (statusEl) {
statusEl.innerHTML = res.completed
? '<i class="bi bi-check-circle-fill text-success me-1"></i>Completed'
: '<i class="bi bi-cloud-check me-1"></i>Progress saved';
}
} catch (e) { /* transient — next heartbeat retries */ }
}
function saveBeacon() {
const p = payload();
if (!p.duration) return;
navigator.sendBeacon('api/progress.php',
new URLSearchParams(Object.entries(p).map(([k, v]) => [k, String(v)])));
}
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); });
player.on('pause', save);
player.on('seeked', save);
player.on('ended', () => {
save();
if (data.next_id) {
if (statusEl) statusEl.textContent = 'Playing next video…';
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() { // called when navigating away
saveBeacon();
if (saveTimer) clearInterval(saveTimer);
document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', saveBeacon);
try { player.destroy(); } catch (e) { /* already gone */ }
};
}
/* ---------- router ---------- */
async function route() {
if (cleanupView) { cleanupView(); cleanupView = null; }
if (!state.user) { renderLogin(); return; }
const hash = location.hash || '#/';
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 await renderHome();
} catch (err) {
if (state.user) {
app.innerHTML = `<div class="alert alert-danger">${esc(err.message)}</div>
<a class="btn btn-primary" href="#/">Back to courses</a>`;
}
}
}
/* ---------- global buttons ---------- */
document.getElementById('btn-logout').addEventListener('click', async () => {
try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ }
state.user = null;
updateNav();
location.hash = '#/';
renderLogin();
});
document.getElementById('btn-rescan').addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Scanning…';
try {
const res = await api('api/scan.php', { method: 'POST' });
toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos, `
+ `${res.stats.quizzes} quizzes indexed.`);
route(); // refresh current view with new data
} catch (err) {
toast(err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Rescan library';
}
});
/* ---------- 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;
} catch (e) {
state.user = null;
}
updateNav();
route();
})();
})();