feat(quiz): image_reveal format (progressive blur) end-to-end

Server:
- quiz-round handles image_reveal like a text-answer format (tolerant match
  on acceptedAnswers), exposing imageUrl in the round payload; degressive
  scoring comes for free from the existing speed bonus (earlier = blurrier =
  more points)
- repo includes image_reveal in the pool (requires imageUrl + acceptedAnswers)
- back-office: image upload (@fastify/multipart) saved to UPLOADS_DIR and
  served statically on /uploads (@fastify/static); create supports image_reveal
- env: UPLOADS_DIR; gitignore uploads/

Client:
- admin form: "Image à deviner" — upload + preview + accepted answers
- in-game: ImageReveal shows the image with a time-cadenced decreasing blur
  (synced via server timestamps) + text input; reveal shows the clear image
  and the answer

shared: QuizQuestionPayload.imageUrl

Verified against real server+DB: upload (url), static serve (200), create
(201), validation (400).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-10 21:41:34 +02:00
parent 8b03879ee0
commit 0619c02cdf
14 changed files with 268 additions and 25 deletions

3
.gitignore vendored
View file

@ -32,3 +32,6 @@ dist-ssr
# typescript
*.tsbuildinfo
# uploads (image_reveal) servies en statique
uploads/

View file

@ -6,3 +6,5 @@ CORS_ORIGINS=http://localhost:5173
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
ADMIN_TOKEN=change-me
# Dossier de stockage des images (image_reveal), servi sur /uploads.
UPLOADS_DIR=./uploads

View file

@ -18,6 +18,8 @@
},
"dependencies": {
"@fastify/cors": "^11",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@nerdware/shared": "workspace:*",
"drizzle-orm": "^0.45",
"fastify": "^5",

View file

@ -1,14 +1,18 @@
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
// CRUD des questions manuelles écrites en base (source 'manual').
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { extname, resolve } from "node:path"
import type { FastifyInstance } from "fastify"
import { desc, eq } from "drizzle-orm"
import { env } from "../env"
import { db } from "../db"
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
const FORMATS = ["mcq", "truefalse", "free"] as const
const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const
type AdminFormat = (typeof FORMATS)[number]
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
interface CreateBody {
format?: string
@ -18,6 +22,7 @@ interface CreateBody {
choices?: string[]
correctIndex?: number
acceptedAnswers?: string[]
imageUrl?: string
}
/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */
@ -40,14 +45,23 @@ function validate(
const base = { categoryId, format, prompt, difficulty, source: "manual" }
if (format === "free") {
if (format === "free" || format === "image_reveal") {
const answers = (body.acceptedAnswers ?? [])
.map((a) => a.trim())
.filter(Boolean)
if (answers.length === 0) {
return { error: "Au moins une réponse acceptée est requise." }
}
return { value: { ...base, acceptedAnswers: answers } }
if (format === "image_reveal" && !body.imageUrl) {
return { error: "Une image est requise." }
}
return {
value: {
...base,
acceptedAnswers: answers,
imageUrl: format === "image_reveal" ? body.imageUrl : undefined,
},
}
}
// mcq / truefalse
@ -83,6 +97,22 @@ export async function adminRoutes(app: FastifyInstance) {
}
})
app.post("/upload", async (req, reply) => {
const file = await req.file()
if (!file) {
return reply.code(400).send({ error: "Aucun fichier reçu." })
}
if (!file.mimetype.startsWith("image/")) {
return reply.code(400).send({ error: "Le fichier doit être une image." })
}
const ext = extname(file.filename ?? "").toLowerCase()
const safeExt = IMAGE_EXT.includes(ext) ? ext : ".jpg"
const name = `${randomUUID()}${safeExt}`
const buffer = await file.toBuffer()
await writeFile(resolve(env.uploadsDir, name), buffer)
return { url: `/uploads/${name}` }
})
app.get("/categories", async () => {
const rows = await db!
.select({ id: quizCategory.id, name: quizCategory.name })
@ -102,6 +132,7 @@ export async function adminRoutes(app: FastifyInstance) {
choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
imageUrl: quizQuestion.imageUrl,
category: quizCategory.name,
})
.from(quizQuestion)

View file

@ -29,6 +29,7 @@ export async function loadQuizPool(
choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
imageUrl: quizQuestion.imageUrl,
difficulty: quizQuestion.difficulty,
category: quizCategory.name,
})
@ -36,7 +37,7 @@ export async function loadQuizPool(
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where(
and(
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
inArray(quizQuestion.format, ["mcq", "truefalse", "free", "image_reveal"]),
notInArray(quizQuestion.id, alreadyPlayed)
)
)
@ -44,11 +45,15 @@ export async function loadQuizPool(
.limit(limit)
return rows
.filter((r) =>
r.format === "free"
? (r.acceptedAnswers?.length ?? 0) > 0
: r.choices && r.correctIndex !== null
)
.filter((r) => {
if (r.format === "free") {
return (r.acceptedAnswers?.length ?? 0) > 0
}
if (r.format === "image_reveal") {
return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0
}
return r.choices && r.correctIndex !== null
})
.map((r) => ({
id: r.id,
format: r.format as QuizQuestion["format"],
@ -56,6 +61,7 @@ export async function loadQuizPool(
choices: r.choices ?? undefined,
correctIndex: r.correctIndex ?? undefined,
acceptedAnswers: r.acceptedAnswers ?? undefined,
imageUrl: r.imageUrl ?? undefined,
category: r.category ?? "Quiz",
difficulty: r.difficulty,
}))

View file

@ -14,6 +14,8 @@ export const env = {
databaseUrl: process.env.DATABASE_URL ?? "",
/** Jeton du back-office quiz. Vide → back-office désactivé. */
adminToken: process.env.ADMIN_TOKEN ?? "",
/** Dossier de stockage des images (image_reveal), servi en statique sur /uploads. */
uploadsDir: process.env.UPLOADS_DIR ?? "./uploads",
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
.split(",")

View file

@ -12,8 +12,10 @@ export interface QuizQuestion {
choices?: string[]
/** mcq/truefalse uniquement. */
correctIndex?: number
/** free : réponses acceptées (matching tolérant). */
/** free / image_reveal : réponses acceptées (matching tolérant). */
acceptedAnswers?: string[]
/** image_reveal : image à dévoiler (chemin relatif au serveur). */
imageUrl?: string
category: string
difficulty: number
}

View file

@ -36,12 +36,17 @@ function asText(answer: Answer): string | null {
return typeof v === "string" ? v : null
}
/** Formats à réponse libre (saisie texte + matching tolérant). */
function isTextFormat(question: QuizQuestion): boolean {
return question.format === "free" || question.format === "image_reveal"
}
/** Réponse correcte ? Selon le format de la question. */
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
if (!answer) {
return false
}
if (question.format === "free") {
if (isTextFormat(question)) {
const text = asText(answer)
if (!text) {
return false
@ -68,9 +73,12 @@ export class QuizRound implements GameRound {
category: question.category,
difficulty: question.difficulty,
}
if (question.format !== "free") {
if (question.format === "mcq" || question.format === "truefalse") {
payload.choices = question.choices
}
if (question.format === "image_reveal") {
payload.imageUrl = question.imageUrl
}
const data: QuizRoundData = { question, votedAt: new Map() }
return { djId: null, payload, data }
}
@ -81,7 +89,7 @@ export class QuizRound implements GameRound {
return
}
const { question, votedAt } = ctx.data as QuizRoundData
if (question.format === "free") {
if (isTextFormat(question)) {
const text = asText(answer)
if (text === null || text.trim().length === 0) {
return
@ -111,10 +119,9 @@ export class QuizRound implements GameRound {
correct: isCorrect(question, vote),
}
}
const truth: QuizRevealTruth =
question.format === "free"
? { answer: question.acceptedAnswers?.[0] ?? "" }
: { correctIndex: question.correctIndex }
const truth: QuizRevealTruth = isTextFormat(question)
? { answer: question.acceptedAnswers?.[0] ?? "" }
: { correctIndex: question.correctIndex }
return { truth, perPlayerResult }
}

View file

@ -1,5 +1,9 @@
import { mkdirSync } from "node:fs"
import { resolve } from "node:path"
import Fastify from "fastify"
import cors from "@fastify/cors"
import multipart from "@fastify/multipart"
import fastifyStatic from "@fastify/static"
import { env, isDev } from "./env"
import { createSocketServer } from "./socket"
import { adminRoutes } from "./admin/routes"
@ -13,6 +17,12 @@ const app = Fastify({
await app.register(cors, { origin: env.corsOrigins })
// Images (image_reveal) : dossier servi en statique sur /uploads.
const uploadsRoot = resolve(env.uploadsDir)
mkdirSync(uploadsRoot, { recursive: true })
await app.register(multipart, { limits: { fileSize: 8 * 1024 * 1024 } })
await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
await app.register(adminRoutes, { prefix: "/api/admin" })

View file

@ -1,4 +1,4 @@
import { useState } from "react"
import { useEffect, useState } from "react"
import type {
QuizPerPlayerResult,
QuizQuestionPayload,
@ -12,6 +12,9 @@ import { Countdown } from "@/components/countdown"
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
const MAX_BLUR = 22
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
@ -31,7 +34,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const question = round.payload as QuizQuestionPayload
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
const showReveal = truth !== null
const isFree = question.format === "free"
const isImage = question.format === "image_reveal"
const isText = question.format === "free" || isImage
const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined
@ -74,7 +78,20 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
{question.prompt}
</h2>
{isFree ? (
{isImage && question.imageUrl && (
<ImageReveal
src={
question.imageUrl.startsWith("http")
? question.imageUrl
: `${SERVER_URL}${question.imageUrl}`
}
startsAt={round.startsAt}
endsAt={round.endsAt}
revealed={showReveal}
/>
)}
{isText ? (
<FreeAnswer
disabled={showReveal || hasVoted}
onSubmit={voteText}
@ -110,7 +127,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
{showReveal && (
<div className="flex flex-col items-center gap-1">
{isFree && (
{isText && (
<p className="text-sm">
<span className="text-muted-foreground">Réponse : </span>
<span className="font-medium">{truth?.answer}</span>
@ -131,6 +148,51 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
)
}
function ImageReveal({
src,
startsAt,
endsAt,
revealed,
}: {
src: string
startsAt: number
endsAt: number
revealed: boolean
}) {
// Le flou décroît avec le temps (cadencé par les timestamps serveur) : tous
// les clients voient le même niveau de flou au même instant.
const [blur, setBlur] = useState(MAX_BLUR)
useEffect(() => {
if (revealed) {
return
}
const id = setInterval(() => {
const now = Date.now()
const p =
now < startsAt ? 0 : Math.min(1, (now - startsAt) / (endsAt - startsAt))
setBlur(MAX_BLUR * (1 - p))
}, 200)
return () => clearInterval(id)
}, [revealed, startsAt, endsAt])
const displayBlur = revealed ? 0 : blur
return (
<div className="bg-card relative mx-auto aspect-video w-full overflow-hidden rounded-2xl">
<img
src={src}
alt=""
className="size-full object-cover transition-[filter] duration-300"
style={{
filter: `blur(${displayBlur}px)`,
transform: "scale(1.1)", // évite les bords flous
}}
/>
</div>
)
}
function FreeAnswer({
disabled,
onSubmit,

View file

@ -24,6 +24,7 @@ export interface AdminQuestion {
choices: string[] | null
correctIndex: number | null
acceptedAnswers: string[] | null
imageUrl: string | null
category: string | null
}
@ -35,6 +36,12 @@ export interface NewQuestionInput {
choices?: string[]
correctIndex?: number
acceptedAnswers?: string[]
imageUrl?: string
}
/** Construit l'URL absolue d'un asset servi par le serveur (ex: /uploads/x.jpg). */
export function assetUrl(path: string): string {
return path.startsWith("http") ? path : `${SERVER_URL}${path}`
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
@ -53,8 +60,24 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return res.status === 204 ? (undefined as T) : ((await res.json()) as T)
}
async function uploadImage(file: File): Promise<{ url: string }> {
const form = new FormData()
form.append("file", file)
const res = await fetch(`${SERVER_URL}/api/admin/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` },
body: form,
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `Erreur ${res.status}`)
}
return (await res.json()) as { url: string }
}
export const adminApi = {
listQuestions: () => request<AdminQuestion[]>("/questions"),
uploadImage,
createQuestion: (input: NewQuestionInput) =>
request<{ id: string }>("/questions", {
method: "POST",

View file

@ -6,6 +6,7 @@ import type { QuizFormat } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import {
adminApi,
assetUrl,
clearAdminToken,
getAdminToken,
setAdminToken,
@ -147,12 +148,19 @@ function QuestionRow({
}) {
return (
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3">
{q.format === "image_reveal" && q.imageUrl && (
<img
src={assetUrl(q.imageUrl)}
alt=""
className="h-12 w-12 shrink-0 rounded object-cover"
/>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{q.prompt}</p>
<p className="text-muted-foreground text-xs">
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}
</p>
{q.format === "free" ? (
{q.format === "free" || q.format === "image_reveal" ? (
<p className="text-muted-foreground text-xs">
{q.acceptedAnswers?.join(", ")}
</p>
@ -186,6 +194,9 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
const [correctIndex, setCorrectIndex] = useState(0)
const [answers, setAnswers] = useState("")
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const create = useMutation({
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
@ -194,10 +205,27 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
setChoices(EMPTY_CHOICES)
setCorrectIndex(0)
setAnswers("")
setImageUrl(null)
onCreated()
},
})
async function onPickImage(file: File | undefined) {
if (!file) {
return
}
setUploading(true)
setUploadError(null)
try {
const { url } = await adminApi.uploadImage(file)
setImageUrl(url)
} catch (err) {
setUploadError((err as { message?: string }).message ?? "Échec de l'upload")
} finally {
setUploading(false)
}
}
function submit() {
const input: NewQuestionInput = { format, prompt, category, difficulty }
if (format === "free") {
@ -205,6 +233,12 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
.split("\n")
.map((a) => a.trim())
.filter(Boolean)
} else if (format === "image_reveal") {
input.acceptedAnswers = answers
.split("\n")
.map((a) => a.trim())
.filter(Boolean)
input.imageUrl = imageUrl ?? undefined
} else if (format === "truefalse") {
input.choices = ["Vrai", "Faux"]
input.correctIndex = correctIndex
@ -231,6 +265,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
<option value="mcq">QCM</option>
<option value="truefalse">Vrai / Faux</option>
<option value="free">Réponse libre</option>
<option value="image_reveal">Image à deviner</option>
</select>
<select
className={inputClass}
@ -300,7 +335,31 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
</div>
)}
{format === "free" && (
{format === "image_reveal" && (
<div className="flex flex-col gap-2">
<input
type="file"
accept="image/*"
className="text-sm"
onChange={(e) => onPickImage(e.target.files?.[0])}
/>
{uploading && (
<p className="text-muted-foreground text-xs">Upload</p>
)}
{uploadError && (
<p className="text-destructive text-xs">{uploadError}</p>
)}
{imageUrl && (
<img
src={assetUrl(imageUrl)}
alt=""
className="max-h-40 w-fit rounded-md object-contain"
/>
)}
</div>
)}
{(format === "free" || format === "image_reveal") && (
<textarea
className={`${inputClass} h-24 py-2`}
placeholder="Réponses acceptées (une par ligne)"
@ -314,7 +373,13 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
)}
<Button
disabled={create.isPending || !prompt.trim() || !category.trim()}
disabled={
create.isPending ||
uploading ||
!prompt.trim() ||
!category.trim() ||
(format === "image_reveal" && !imageUrl)
}
onClick={submit}
>
{create.isPending ? "Ajout…" : "Ajouter la question"}

View file

@ -16,6 +16,8 @@
"version": "0.0.1",
"dependencies": {
"@fastify/cors": "^11",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@nerdware/shared": "workspace:*",
"drizzle-orm": "^0.45",
"fastify": "^5",
@ -260,10 +262,16 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="],
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
@ -272,8 +280,14 @@
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/multipart": ["@fastify/multipart@10.0.0", "", { "dependencies": { "@fastify/busboy": "^3.0.0", "@fastify/deepmerge": "^3.0.0", "@fastify/error": "^4.0.0", "fastify-plugin": "^5.0.0", "secure-json-parse": "^4.0.0" } }, "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="],
"@fastify/static": ["@fastify/static@9.1.3", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@ -342,6 +356,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
@ -938,6 +954,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
@ -1084,7 +1102,7 @@
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
@ -1102,6 +1120,8 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@ -1114,6 +1134,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
@ -1180,6 +1202,8 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@ -1474,6 +1498,8 @@
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],

View file

@ -10,6 +10,8 @@ export interface QuizQuestionPayload {
prompt: string
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
choices?: string[]
/** Image à dévoiler progressivement (image_reveal). Chemin relatif au serveur. */
imageUrl?: string
category?: string
/** 1 (facile) .. 3 (difficile). */
difficulty?: number