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.
This commit is contained in:
+126
-13
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
${progressBar(c.progress.percent)}
|
||||
<div class="small text-secondary mt-1">
|
||||
${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}%
|
||||
${c.progress.completed} / ${c.progress.total} completed · ${c.progress.percent}%
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -206,23 +206,44 @@
|
||||
</a>`).join('')}
|
||||
</div>` : '';
|
||||
|
||||
const videos = data.videos.length ? `
|
||||
<h2 class="h6 text-secondary text-uppercase">Videos</h2>
|
||||
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">
|
||||
${data.videos.map((v) => {
|
||||
const icon = v.completed
|
||||
${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>'
|
||||
: (v.position > 5
|
||||
: (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/${v.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
|
||||
<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(v.name)}</div>
|
||||
${!v.completed && v.percent > 0 ? `<div class="mt-1">${progressBar(v.percent, '')}</div>` : ''}
|
||||
<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(v.duration)}</span>
|
||||
<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>' : '');
|
||||
@@ -231,10 +252,100 @@
|
||||
${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} videos completed</div>
|
||||
<div class="text-secondary small">${data.progress.completed} / ${data.progress.total} completed</div>
|
||||
</div>
|
||||
<div class="mb-4">${progressBar(data.progress.percent)}</div>
|
||||
${subfolders}${videos}`;
|
||||
${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) {
|
||||
@@ -374,6 +485,7 @@
|
||||
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) {
|
||||
@@ -399,7 +511,8 @@
|
||||
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 indexed.`);
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user