feat: native quiz pages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user