c6a970880c
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.
540 lines
22 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
|
})();
|
|
})();
|