feat: native quiz pages

This commit is contained in:
electron-rare
2026-06-11 00:16:10 +02:00
parent 1fe2b18e97
commit eca10b9f27
5 changed files with 279 additions and 4 deletions
+18 -2
View File
@@ -1,7 +1,7 @@
---
import type { BookModule } from '../lib/moodle';
interface Props { slug: string; modules: BookModule[]; readSet?: Set<string>; }
const { slug, modules, readSet } = Astro.props;
interface Props { slug: string; modules: BookModule[]; readSet?: Set<string>; quizPassed?: Set<number>; }
const { slug, modules, readSet, quizPassed } = Astro.props;
---
<div id="sommaire" class="max-w-content mx-auto grid md:grid-cols-2 gap-6">
{modules.map((m, mi) => (
@@ -27,6 +27,22 @@ const { slug, modules, readSet } = Astro.props;
</li>
))}
</ol>
{mi + 1 <= 4 && (
<a href={`/cours/${slug}/quiz-${mi + 1}`}
class="mt-4 pt-4 border-t border-white/10 text-sm text-slate-300 hover:text-white transition-colors flex gap-3">
{quizPassed?.has(mi + 1) ? (
<>
<span class="text-copper font-mono text-xs pt-0.5">✓</span>
<span class="text-slate-200">Quiz du module →</span>
</>
) : (
<>
<span class="text-copper/60 font-mono text-xs pt-0.5">?</span>
<span>Quiz du module →</span>
</>
)}
</a>
)}
</section>
))}
</div>
+30
View File
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
import { wsCall, getCourseBooks, type BookModule } from './moodle';
import { getReads, markSynced, isBookComplete, type ReadMark } from './grist';
import { bySlug } from '../config/courses';
import { getQuizzes, getAttempts, isPassed, markAttemptsSynced } from './quiz';
/** Token of the dedicated `formations_sync` WS service (wssync user).
* Allowed functions ONLY: core_user_get_users_by_field, core_user_create_users,
@@ -102,3 +103,32 @@ export async function syncToMoodle(
console.error('[moodle-sync]', (err as Error).message);
}
}
/** Best-effort: mirror a passed quiz into Moodle completion (quiz cmid)
* and mark the user's passed attempts on that quiz as synced. Never throws. */
export async function syncQuizToMoodle(
user: { sub: string; email: string; name: string },
courseSlug: string,
quizN: number,
): Promise<void> {
try {
const course = bySlug(courseSlug);
if (!course) return;
const quiz = (await getQuizzes(courseSlug)).find((q) => q.quizN === quizN);
if (!quiz) return;
const pending = (await getAttempts(user.sub, courseSlug))
.filter((a) => a.quiz_n === quizN && !a.moodle_synced && isPassed(quiz, a.score, a.total));
if (!pending.length) return;
const userId = await ensureMoodleUser(user.email, user.name);
await ensureEnrolled(userId, course.id);
await sync('core_completion_override_activity_completion_status', {
cmid: String(quiz.cmid),
userid: String(userId),
newstate: '1',
});
await markAttemptsSynced(pending.map((a) => a.id));
} catch (err) {
console.error('[moodle-sync quiz]', (err as Error).message);
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { APIRoute } from 'astro';
import { verifySession, SESSION_COOKIE } from '../../lib/session';
import { bySlug } from '../../config/courses';
import { getQuizzes, gradeQuiz, isPassed, addAttempt, type Quiz } from '../../lib/quiz';
import { syncQuizToMoodle } from '../../lib/moodle-sync';
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const user = verifySession(cookies.get(SESSION_COOKIE)?.value);
if (!user) return json(401, { error: 'auth' });
const ct = request.headers.get('content-type') ?? '';
const isForm = ct.includes('application/x-www-form-urlencoded');
let slug = '', quizRaw = '';
const answers: Record<string, string[]> = {};
try {
if (isForm) {
const form = await request.formData();
slug = String(form.get('slug') ?? '');
quizRaw = String(form.get('quiz') ?? '');
for (const key of new Set(form.keys()))
if (/^q\d+$/.test(key)) answers[key.slice(1)] = form.getAll(key).map(String);
} else {
const body = (await request.json()) as Record<string, unknown>;
slug = String(body.slug ?? '');
quizRaw = String(body.quiz ?? '');
for (const [key, val] of Object.entries(body))
if (/^q\d+$/.test(key)) answers[key.slice(1)] = (Array.isArray(val) ? val : [val]).map(String);
}
} catch {
return json(400, { error: 'body' });
}
const course = bySlug(slug);
const n = Number(quizRaw);
if (!course || !course.published || !Number.isInteger(n) || n < 1 || n > 4)
return json(400, { error: 'params' });
let quiz: Quiz | undefined;
try {
quiz = (await getQuizzes(slug)).find((q) => q.quizN === n);
} catch {
return json(503, { error: 'grist' });
}
if (!quiz) return json(400, { error: 'params' });
const { score, total } = gradeQuiz(quiz, answers);
const passed = isPassed(quiz, score, total);
const back = `/cours/${slug}/quiz-${n}`;
try {
await addAttempt({ sub: user.sub, email: user.email }, slug, n, score, total, answers);
} catch (err) {
console.error('[quiz] grist addAttempt failed:', (err as Error).message);
return isForm ? redirect(`${back}?erreur=progression`, 303) : json(502, { error: 'grist' });
}
if (passed)
syncQuizToMoodle({ sub: user.sub, email: user.email, name: user.name }, slug, n).catch(() => {});
return isForm ? redirect(`${back}?fait=1`, 303) : json(200, { ok: true, score, total, passed });
};
+20 -2
View File
@@ -6,7 +6,8 @@ import { bySlug } from '../../../config/courses';
import { getCourseBooks } from '../../../lib/moodle';
import { verifySession, SESSION_COOKIE } from '../../../lib/session';
import { getReads, type ReadMark } from '../../../lib/grist';
import { syncToMoodle } from '../../../lib/moodle-sync';
import { getQuizzes, getAttempts, isPassed, type Quiz, type QuizAttempt } from '../../../lib/quiz';
import { syncToMoodle, syncQuizToMoodle } from '../../../lib/moodle-sync';
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
const course = bySlug(Astro.params.slug ?? '');
@@ -20,14 +21,30 @@ try {
}
let reads: ReadMark[] = [];
let quizzes: Quiz[] = [];
let attempts: QuizAttempt[] = [];
if (user) {
try { reads = await getReads(user.sub, course.slug); } catch { reads = []; }
try { quizzes = await getQuizzes(course.slug); } catch { quizzes = []; }
if (quizzes.length) {
try { attempts = await getAttempts(user.sub, course.slug); } catch { attempts = []; }
}
// Best-effort retry of pending Moodle syncs — fire-and-forget, never blocks render.
if (reads.some((r) => !r.moodle_synced))
syncToMoodle({ sub: user.sub, email: user.email, name: user.name }, course.slug).catch(() => {});
}
const readSet = new Set(reads.map((r) => `${r.module_n}-${r.chapitre_n}`));
const quizPassed = new Set<number>();
for (const qz of quizzes) {
const passedAttempts = attempts.filter((a) => a.quiz_n === qz.quizN && isPassed(qz, a.score, a.total));
if (!passedAttempts.length) continue;
quizPassed.add(qz.quizN);
// Retry unsynced passed quiz attempts — fire-and-forget too.
if (user && passedAttempts.some((a) => !a.moodle_synced))
syncQuizToMoodle({ sub: user.sub, email: user.email, name: user.name }, course.slug, qz.quizN).catch(() => {});
}
const hubData = {
slug: course.slug,
modules: modules.map((m, mi) => ({
@@ -51,9 +68,10 @@ const pct = total ? Math.round((nbLus / total) * 100) : 0;
<h1 class="text-4xl md:text-5xl font-semibold tracking-tight text-white">{course.title}</h1>
<p class="text-lg text-slate-400 mt-3 max-w-2xl">{course.blurb}</p>
{user && <p class="mt-4 text-copper-soft text-sm">{nbLus} / {total} chapitres lus · {pct} %</p>}
{user && quizzes.length > 0 && <p class="mt-1 text-copper-soft text-sm">Quiz : {quizPassed.size}/{quizzes.length} réussis</p>}
</header>
<div id="atom-mount" class="hidden max-w-content mx-auto" data-hub={JSON.stringify(hubData)}></div>
<Sommaire slug={course.slug} modules={modules} readSet={readSet} />
<Sommaire slug={course.slug} modules={modules} readSet={readSet} quizPassed={quizPassed} />
</main>
</Base>
+149
View File
@@ -0,0 +1,149 @@
---
import Base from '../../../layouts/Base.astro';
import Nav from '../../../components/Nav.astro';
import { bySlug } from '../../../config/courses';
import { verifySession, SESSION_COOKIE } from '../../../lib/session';
import { sanitizeHtml } from '../../../lib/sanitize';
import {
getQuizzes, getAttempts, toPublic, gradeQuiz, isPassed,
type Quiz, type QuizAttempt, type QuestionResult,
} from '../../../lib/quiz';
const course = bySlug(Astro.params.slug ?? '');
const nRaw = Astro.params.n ?? '';
if (!course || !course.published || !/^[1-4]$/.test(nRaw)) return Astro.rewrite('/404');
const quizN = Number(nRaw);
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
if (!user) return Astro.redirect('/connexion?next=' + encodeURIComponent(Astro.url.pathname));
let quizzes: Quiz[];
try {
quizzes = await getQuizzes(course.slug);
} catch {
return new Response('Quiz momentanément indisponible — réessayez dans quelques minutes.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
const quiz = quizzes.find((q) => q.quizN === quizN);
if (!quiz) return Astro.rewrite('/404');
const progressionErreur = Astro.url.searchParams.get('erreur') === 'progression';
const fait = Astro.url.searchParams.get('fait') === '1';
// ?fait=1 → correction view of the newest attempt (falls back to the form
// when no attempt is found, e.g. direct navigation).
let attempt: QuizAttempt | null = null;
let detail: QuestionResult[] = [];
let passed = false;
if (fait) {
try {
attempt = (await getAttempts(user.sub, course.slug))
.filter((a) => a.quiz_n === quizN)
.sort((a, b) => b.date - a.date || b.id - a.id)[0] ?? null;
} catch {
attempt = null;
}
if (attempt) {
let answers: Record<string, string[]> = {};
try { answers = JSON.parse(attempt.reponses || '{}') as Record<string, string[]>; } catch { answers = {}; }
detail = gradeQuiz(quiz, answers).detail;
passed = isPassed(quiz, attempt.score, attempt.total);
}
}
const byQuestion = new Map(detail.map((d) => [d.questionId, d]));
const pub = toPublic(quiz); // never ship correct flags / feedback to the form
---
<Base title={`${quiz.name} — ${course.title}`} description={`${quiz.name} · ${course.title}`}>
<Nav user={{ name: user.name }} currentPath={Astro.url.pathname} />
<main class="pt-24 pb-20 px-6">
<div class="max-w-prose mx-auto">
<nav class="text-sm text-slate-500 mb-8 flex flex-wrap gap-2">
<a href="/" class="hover:text-white">Formations</a><span>/</span>
<a href={`/cours/${course.slug}`} class="hover:text-white">{course.title}</a><span>/</span>
<span class="text-slate-300">{quiz.name}</span>
</nav>
<h1 class="text-3xl md:text-4xl font-semibold tracking-tight text-white mb-8">{quiz.name}</h1>
{progressionErreur && (
<div class="mb-8 rounded-xl border border-amber-400/40 bg-amber-400/10 px-5 py-3 text-sm text-amber-200">
Votre tentative n'a pas pu être enregistrée — réessayez dans quelques minutes.
</div>
)}
{attempt ? (
<>
{passed ? (
<div class="mb-10 rounded-2xl border border-copper/40 bg-copper/10 px-6 py-5">
<p class="text-copper text-lg font-semibold">Quiz réussi · {attempt.score}/{attempt.total} ✓</p>
</div>
) : (
<div class="mb-10 rounded-2xl border border-white/10 bg-night-soft px-6 py-5">
<p class="text-slate-200 text-lg font-semibold">{attempt.score}/{attempt.total} — réessayez</p>
</div>
)}
<ol class="space-y-8">
{quiz.questions.map((q) => {
const d = byQuestion.get(q.id);
const chosen = new Set(d?.chosenIds ?? []);
const correct = new Set(d?.correctIds ?? []);
return (
<li class="rounded-2xl border border-white/10 bg-night-soft p-6">
<div class="flex gap-3 mb-4">
<span class={`font-mono text-xs pt-1 ${d?.ok ? 'text-copper' : 'text-red-400'}`}>{d?.ok ? '✓' : '✗'}</span>
<div class="quiz-html text-slate-200" set:html={sanitizeHtml(q.text)} />
</div>
<ul class="space-y-2 ml-6">
{q.choices.map((c) => (
<li class={`text-sm rounded-lg px-3 py-2 flex gap-2 ${correct.has(c.id) ? 'bg-copper/10 text-copper-soft' : chosen.has(c.id) ? 'bg-red-400/10 text-red-300' : 'text-slate-400'}`}>
<span class="font-mono text-xs pt-0.5">{correct.has(c.id) ? '✓' : chosen.has(c.id) ? '✗' : '·'}</span>
<span class="quiz-html" set:html={sanitizeHtml(c.text)} />
{chosen.has(c.id) && <span class="text-xs text-slate-500 pt-0.5 whitespace-nowrap">(votre réponse)</span>}
</li>
))}
</ul>
{d?.feedback && <p class="mt-3 ml-6 text-sm text-slate-400 italic">{d.feedback}</p>}
</li>
);
})}
</ol>
<div class="mt-12 flex flex-wrap gap-4">
<a href={`/cours/${course.slug}/quiz-${quizN}`} class="px-5 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90">Rejouer</a>
<a href={`/cours/${course.slug}`} class="px-5 py-3 rounded-full bg-white/10 text-white text-sm font-medium hover:bg-white/20">Retour au cours →</a>
</div>
</>
) : (
<form method="POST" action="/api/quiz" class="space-y-8">
<input type="hidden" name="slug" value={course.slug} />
<input type="hidden" name="quiz" value={String(quizN)} />
<ol class="space-y-8">
{pub.questions.map((q, qi) => (
<li class="rounded-2xl border border-white/10 bg-night-soft p-6">
<fieldset>
<legend class="flex gap-3 mb-4">
<span class="text-copper/60 font-mono text-xs pt-1">{qi + 1}</span>
<div class="quiz-html text-slate-200" set:html={sanitizeHtml(q.text)} />
</legend>
<div class="space-y-2 ml-6">
{q.choices.map((c) => (
<label class="flex gap-3 items-start text-sm text-slate-300 hover:text-white cursor-pointer rounded-lg px-3 py-2 hover:bg-white/5">
<input type={q.single ? 'radio' : 'checkbox'} name={`q${q.id}`} value={String(c.id)} required={q.single ? true : undefined} class="mt-0.5 accent-[#c2540a]" />
<span class="quiz-html" set:html={sanitizeHtml(c.text)} />
</label>
))}
</div>
</fieldset>
</li>
))}
</ol>
<button type="submit" class="px-6 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90 cursor-pointer">Valider mes réponses</button>
</form>
)}
</div>
</main>
</Base>
<style is:global>
.quiz-html p { margin: 0; }
.quiz-html p + p { margin-top: 0.5em; }
.quiz-html code { font-family: 'JetBrains Mono', monospace; background: rgba(255, 255, 255, 0.08); border-radius: 4px; padding: 0.1em 0.35em; font-size: 0.9em; }
.quiz-html pre { background: #0b0f17; color: #e2e8f0; border-radius: 10px; padding: 0.8em 1em; overflow-x: auto; font-size: 0.9em; margin: 0.6em 0; }
</style>