feature/image-reveal #10

Merged
ayoub merged 5 commits from feature/image-reveal into dev 2026-06-10 22:38:34 +00:00
29 changed files with 1000 additions and 183 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

@ -0,0 +1,3 @@
ALTER TABLE "quiz_question" DROP CONSTRAINT "quiz_question_prompt_unique";--> statement-breakpoint
CREATE UNIQUE INDEX "quiz_question_prompt_unique" ON "quiz_question" USING btree ("prompt") WHERE "quiz_question"."format" <> 'image_reveal';--> statement-breakpoint
CREATE UNIQUE INDEX "quiz_question_image_unique" ON "quiz_question" USING btree ("image_url") WHERE "quiz_question"."image_url" is not null;

View file

@ -0,0 +1,288 @@
{
"id": "6de8c2ee-3634-455b-ba4b-372368fc2e66",
"prevId": "c68e2f59-3d1b-4fca-8443-17fe243e2919",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"columns": [
{
"expression": "prompt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"format\" <> 'image_reveal'",
"concurrently": false,
"method": "btree",
"with": {}
},
"quiz_question_image_unique": {
"name": "quiz_question_image_unique",
"columns": [
{
"expression": "image_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"image_url\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1781085893179,
"tag": "0000_loose_doctor_spectrum",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781129455626,
"tag": "0001_fancy_captain_stacy",
"breakpoints": true
}
]
}

View file

@ -14,10 +14,13 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "bun src/db/migrate.ts",
"db:seed": "bun src/db/seed.ts",
"db:seed:images": "bun src/db/seed-images.ts",
"db:studio": "drizzle-kit studio"
},
"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 { mkdir, 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. */
@ -29,7 +34,10 @@ function validate(
if (!FORMATS.includes(format)) {
return { error: "Format invalide." }
}
const prompt = (body.prompt ?? "").trim()
// L'intitulé est optionnel pour image_reveal (défaut générique).
const prompt =
(body.prompt ?? "").trim() ||
(format === "image_reveal" ? "Qui est-ce ?" : "")
if (prompt.length === 0) {
return { error: "L'intitulé est obligatoire." }
}
@ -40,14 +48,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 +100,24 @@ 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()
// Crée le dossier si besoin (résilient s'il a été supprimé après le démarrage).
await mkdir(resolve(env.uploadsDir), { recursive: true })
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 +137,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

@ -11,9 +11,10 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
*/
export async function loadQuizPool(
roomCode: string,
limit: number
limit: number,
formats: string[]
): Promise<QuizQuestion[]> {
if (!db) {
if (!db || formats.length === 0) {
return []
}
const alreadyPlayed = db
@ -29,6 +30,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 +38,7 @@ export async function loadQuizPool(
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where(
and(
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
inArray(quizQuestion.format, formats),
notInArray(quizQuestion.id, alreadyPlayed)
)
)
@ -44,11 +46,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 +62,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

@ -9,6 +9,7 @@ import {
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core"
@ -20,26 +21,38 @@ export const quizCategory = pgTable("quiz_category", {
.default(sql`now()`),
})
export const quizQuestion = pgTable("quiz_question", {
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id").references(() => quizCategory.id),
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
format: text("format").notNull(),
/** Unique → seed idempotent (onConflictDoNothing). */
prompt: text("prompt").notNull().unique(),
/** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' */
source: text("source").notNull(),
// Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
})
export const quizQuestion = pgTable(
"quiz_question",
{
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id").references(() => quizCategory.id),
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
format: text("format").notNull(),
prompt: text("prompt").notNull(),
/** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' */
source: text("source").notNull(),
// Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
},
// Unique sur l'intitulé SAUF pour les images (même intitulé "Qui est-ce ?"
// pour des images différentes). Les images se dédupliquent par image_url.
(t) => [
uniqueIndex("quiz_question_prompt_unique")
.on(t.prompt)
.where(sql`${t.format} <> 'image_reveal'`),
uniqueIndex("quiz_question_image_unique")
.on(t.imageUrl)
.where(sql`${t.imageUrl} is not null`),
]
)
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
export const quizPlayed = pgTable(

View file

@ -0,0 +1,94 @@
// Seed du mode Images depuis PokéAPI : "devine le Pokémon" à partir de
// l'artwork officiel (URL distante, pas d'upload). Réponses acceptées FR + EN.
// bun run db:seed:images (gen 1, 151 Pokémon)
// IMAGE_SEED_COUNT=50 bun run db:seed:images
// Idempotent : déduplication par image_url (onConflictDoNothing).
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../env"
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — impossible de seeder.")
process.exit(1)
}
const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
const CATEGORY = "Pokémon"
const PROMPT = "Quel est ce Pokémon ?"
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
async function upsertCategory(name: string): Promise<string> {
await db.insert(quizCategory).values({ name }).onConflictDoNothing()
const [row] = await db
.select({ id: quizCategory.id })
.from(quizCategory)
.where(eq(quizCategory.name, name))
return row.id
}
interface Pokemon {
name: string
sprites: { other?: { ["official-artwork"]?: { front_default: string | null } } }
}
interface Species {
names: { name: string; language: { name: string } }[]
}
async function fetchPokemon(id: number): Promise<NewQuizQuestion | null> {
try {
const [pRes, sRes] = await Promise.all([
fetch(`https://pokeapi.co/api/v2/pokemon/${id}`),
fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`),
])
if (!pRes.ok || !sRes.ok) {
return null
}
const p = (await pRes.json()) as Pokemon
const s = (await sRes.json()) as Species
const art = p.sprites.other?.["official-artwork"]?.front_default
if (!art) {
return null
}
const fr = s.names.find((n) => n.language.name === "fr")?.name
const en = s.names.find((n) => n.language.name === "en")?.name ?? p.name
const answers = [...new Set([fr, en].filter(Boolean) as string[])]
return {
categoryId: "", // rempli plus bas
format: "image_reveal",
prompt: PROMPT,
difficulty: 2,
source: "pokeapi",
acceptedAnswers: answers,
imageUrl: art,
}
} catch {
return null
}
}
console.log(`Seed Images depuis PokéAPI (${COUNT} Pokémon)…`)
const categoryId = await upsertCategory(CATEGORY)
let inserted = 0
for (let id = 1; id <= COUNT; id++) {
const q = await fetchPokemon(id)
if (q) {
const res = await db
.insert(quizQuestion)
.values({ ...q, categoryId })
.onConflictDoNothing()
.returning({ id: quizQuestion.id })
inserted += res.length
}
if (id % 25 === 0) {
console.log(` ${id}/${COUNT}`)
}
await sleep(40) // courtoisie envers l'API
}
console.log(`Terminé : ${inserted} Pokémon ajoutés (mode Images).`)
await client.end()

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

@ -1,6 +1,8 @@
// File d'attente de questions par room. Préchargée au lancement de la partie
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche.
// Les formats servis dépendent du type de partie (quiz / images / mixte).
import type { QuizFormat, RoomSettings } from "@nerdware/shared"
import { hasDb } from "../../../db"
import { loadQuizPool } from "../../../db/quiz-repo"
import type { ServerRoom } from "../../../rooms"
@ -10,10 +12,32 @@ interface RoomPool {
queue: QuizQuestion[]
/** true si les questions viennent de la DB (→ marquer jouées). */
fromDb: boolean
formats: QuizFormat[]
}
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
/** Formats de quiz autorisés selon le type de partie (et les sous-modes mixtes). */
function quizFormatsFor(settings: RoomSettings): QuizFormat[] {
if (settings.gameType === "image") {
return ["image_reveal"]
}
if (settings.gameType === "mixed") {
const formats: QuizFormat[] = []
if (settings.mixedModes.includes("quiz")) {
formats.push(...QUIZ_FORMATS)
}
if (settings.mixedModes.includes("image")) {
formats.push("image_reveal")
}
return formats.length > 0 ? formats : QUIZ_FORMATS
}
// quiz, ou blindtest retombé sur du quiz (<3 joueurs)
return QUIZ_FORMATS
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
@ -25,13 +49,14 @@ function shuffle<T>(items: T[]): T[] {
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
const formats = quizFormatsFor(room.settings)
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
let queue: QuizQuestion[] = []
let fromDb = false
if (hasDb) {
try {
queue = await loadQuizPool(room.code, Math.max(needed, 1))
queue = await loadQuizPool(room.code, Math.max(needed, 1), formats)
fromDb = queue.length > 0
} catch (err) {
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
@ -39,14 +64,14 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
}
if (queue.length === 0) {
queue = shuffle(QUIZ_QUESTIONS)
queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)))
fromDb = false
}
poolByRoom.set(room, { queue, fromDb })
poolByRoom.set(room, { queue, fromDb, formats })
}
/** Pioche la prochaine question. Fallback aléatoire si la file est vide. */
/** Pioche la prochaine question. Fallback aléatoire (formats autorisés) si vide. */
export function takeQuestion(room: ServerRoom): {
question: QuizQuestion
fromDb: boolean
@ -55,7 +80,11 @@ export function takeQuestion(room: ServerRoom): {
if (pool && pool.queue.length > 0) {
return { question: pool.queue.shift()!, fromDb: pool.fromDb }
}
const question =
QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
return { question, fromDb: false }
const formats = pool?.formats ?? ["mcq", "truefalse", "free"]
const candidates = QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))
const bank = candidates.length > 0 ? candidates : QUIZ_QUESTIONS
return {
question: bank[Math.floor(Math.random() * bank.length)],
fromDb: false,
}
}

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

@ -106,6 +106,7 @@ export function registerRoomHandlers(
}
room.settings = {
gameType: payload.gameType,
mixedModes: payload.mixedModes,
blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer,

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,10 +1,14 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-monorepo</title>
<title>NerdWare — Party game culture geek</title>
<meta
name="description"
content="Party game web multi-épreuves (quiz culture geek + blindtest YouTube), à jouer entre potes en temps réel."
/>
</head>
<body>
<div id="root"></div>

View file

@ -6,8 +6,9 @@ formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
Rangement par mode (recommandé pour des transitions personnalisées) :
- `quiz/` → jouées avant une manche de **quiz**
- `image/` → jouées avant une manche du mode **images**
- `blindtest/` → jouées avant une manche de **blindtest**
- racine (`./`) → **communs**, fallback pour les deux modes
- racine (`./`) → **communs**, fallback pour tous les modes
Règles :

View file

@ -2,6 +2,7 @@ import { useState } from "react"
import {
Brain,
Headphones,
Image as ImageIcon,
ListMusic,
Minus,
Music,
@ -15,19 +16,31 @@ import { SiYoutube } from "@icons-pack/react-simple-icons"
import type {
BlindtestMode,
GameType,
MixMode,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
const MIX_LABELS: Record<MixMode, string> = {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
}
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10]
const MAX_TRACKS = 10
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
{ value: "mixed", label: "Mixte", Icon: Shuffle },
{ value: "quiz", label: "Quiz", Icon: Brain },
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
const GAME_TYPES: {
value: GameType
label: string
Icon: LucideIcon
needsThree: boolean
}[] = [
{ value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: false },
{ value: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
{ value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
]
const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste",
@ -100,7 +113,7 @@ function buildRounds(
quizCount: number,
totalTracks: number
): RoundConfig[] {
if (gameType === "quiz") {
if (gameType === "quiz" || gameType === "image") {
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
}
if (gameType === "blindtest") {
@ -119,7 +132,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const updateSettings = useRoomStore((s) => s.updateSettings)
const startGame = useRoomStore((s) => s.startGame)
const isHost = snapshot.hostId === playerId
const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
snapshot.settings
const [count, setCount] = useState(5)
const [busy, setBusy] = useState(false)
@ -132,14 +146,27 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
const connectedCount = snapshot.players.filter((p) => p.connected).length
const blindtestAvailable = connectedCount >= 3
// Tant que le blindtest est indisponible, on retombe sur du quiz (sans
// toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs.
const effectiveType: GameType = blindtestAvailable ? gameType : "quiz"
const showQuiz = effectiveType === "quiz" || effectiveType === "mixed"
const showBlindtest =
effectiveType === "blindtest" || effectiveType === "mixed"
// Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
const effectiveType: GameType =
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
const isMixed = effectiveType === "mixed"
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks)
// Sous-modes effectivement actifs.
const wantsQuestions =
effectiveType === "quiz" ||
effectiveType === "image" ||
(isMixed && (mixedModes.includes("quiz") || mixedModes.includes("image")))
const showQuestions = wantsQuestions
const showBlindtest =
effectiveType === "blindtest" ||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
const rounds = buildRounds(
effectiveType,
showQuestions ? count : 0,
showBlindtest ? totalTracks : 0
)
// Blindtest : tout le monde doit avoir soumis son quota de titres.
const allSubmitted =
!showBlindtest ||
@ -147,6 +174,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
const canStart = rounds.length > 0 && allSubmitted
function toggleMix(mode: MixMode) {
const set = new Set(mixedModes)
if (set.has(mode)) {
set.delete(mode)
} else {
set.add(mode)
}
updateSettings({ mixedModes: [...set] })
}
async function start() {
setError(null)
setBusy(true)
@ -190,82 +227,126 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</section>
{isHost && (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
{GAME_TYPES.map(({ value, label, Icon }) => {
const needsThree = value !== "quiz" && !blindtestAvailable
return (
<Button
key={value}
className="flex-1"
variant={effectiveType === value ? "default" : "secondary"}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: value })}
>
<Icon /> {label}
</Button>
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-center text-xs">
Le blindtest se débloque à 3 joueurs.
</p>
)}
</div>
)}
<section className="flex flex-col gap-4 rounded-xl border p-4">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Réglages de la partie
</h2>
{isHost && showQuiz && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="size-4" /> Questions de quiz
</span>
<div className="flex gap-1">
{QUESTION_OPTIONS.map((n) => (
<Button
key={n}
size="sm"
variant={count === n ? "default" : "secondary"}
onClick={() => setCount(n)}
>
{n}
</Button>
))}
</div>
</div>
)}
{isHost && showBlindtest && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> Mode blindtest
</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
key={m}
size="sm"
className="flex-1"
variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => updateSettings({ blindtestMode: m })}
>
{MODE_LABELS[m]}
</Button>
))}
{/* Mode de jeu */}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Mode de jeu</span>
<div className="grid grid-cols-4 gap-2">
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
const locked = needsThree && !blindtestAvailable
const active = effectiveType === value
return (
<button
key={value}
disabled={locked}
title={locked ? "3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: value })}
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
active
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
<Icon className="size-5" />
{label}
</button>
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-xs">
Le blindtest se débloque à 3 joueurs.
</p>
)}
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<ListMusic className="size-4" /> Titres par joueur
</span>
<Stepper
value={tracksPerPlayer}
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
/>
</div>
</div>
{/* Sous-modes du mixte (toggles) */}
{isMixed && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Sous-modes inclus</span>
<div className="flex gap-2">
{(["quiz", "image", "blindtest"] as const).map((m) => {
const locked = m === "blindtest" && !blindtestAvailable
const on = mixedModes.includes(m) && !locked
return (
<button
key={m}
disabled={locked}
title={locked ? "3 joueurs minimum" : undefined}
onClick={() => toggleMix(m)}
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
on
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{MIX_LABELS[m]}
</button>
)
})}
</div>
</div>
)}
{/* Nombre de questions (quiz / images / mixte) */}
{showQuestions && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
{effectiveType === "image" ? (
<ImageIcon className="size-4" />
) : (
<Brain className="size-4" />
)}
{effectiveType === "image" ? "Images" : "Questions"}
</span>
<Stepper value={count} min={1} max={20} onChange={setCount} />
</div>
{effectiveType === "image" && (
<p className="text-muted-foreground text-xs">
Crée des questions « Image » dans le back-office au préalable.
</p>
)}
</div>
)}
{/* Réglages blindtest */}
{showBlindtest && (
<div className="flex flex-col gap-3 border-t pt-3">
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> Mode blindtest
</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
key={m}
size="sm"
className="flex-1"
variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => updateSettings({ blindtestMode: m })}
>
{MODE_LABELS[m]}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<ListMusic className="size-4" /> Titres par joueur
</span>
<Stepper
value={tracksPerPlayer}
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
/>
</div>
</div>
)}
</section>
)}
{showBlindtest && (

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,48 @@ 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 flex w-full justify-center overflow-hidden rounded-2xl p-2">
<img
src={src}
alt=""
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
style={{ filter: `blur(${displayBlur}px)` }}
/>
</div>
)
}
function FreeAnswer({
disabled,
onSubmit,

View file

@ -1,12 +1,15 @@
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "framer-motion"
import type { RoundType } from "@nerdware/shared"
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
export type TransitionKind = "quiz" | "image" | "blindtest"
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
// Médias custom déposés par l'utilisateur, rangés par mode :
// src/assets/transitions/quiz/* → transitions de quiz
// src/assets/transitions/image/* → transitions du mode images
// src/assets/transitions/blindtest/* → transitions de blindtest
// src/assets/transitions/* → communs (fallback)
// Détectés au build ; sinon, animation Framer par défaut.
@ -15,17 +18,17 @@ const ALL_MEDIA = import.meta.glob(
{ eager: true, import: "default" }
) as Record<string, string>
const QUIZ_MEDIA: string[] = []
const BLINDTEST_MEDIA: string[] = []
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
const SHARED_MEDIA: string[] = []
for (const [path, url] of Object.entries(ALL_MEDIA)) {
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url)
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url)
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
else SHARED_MEDIA.push(url)
}
const THEME: Record<
RoundType,
TransitionKind,
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
> = {
quiz: {
@ -35,6 +38,13 @@ const THEME: Record<
kind: "Question",
emoji: "🧠",
},
image: {
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
accent: "text-lime-300",
label: "IMAGE",
kind: "Image",
emoji: "🖼️",
},
blindtest: {
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
accent: "text-yellow-300",
@ -44,29 +54,28 @@ const THEME: Record<
},
}
function mediaFor(type: RoundType): string[] {
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA
return [...own, ...SHARED_MEDIA]
function mediaFor(kind: TransitionKind): string[] {
return [...MEDIA[kind], ...SHARED_MEDIA]
}
/**
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
* il change (quiz blindtest), sinon affiche un teaser léger de la manche.
* Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest).
* Annonce le mode quand il change, sinon affiche un teaser léger de la manche.
* Montée avec une `key` qui change rejoue à chaque manche.
*/
export function RoundTransition({
type,
kind,
index,
modeChanged,
}: {
type: RoundType
kind: TransitionKind
index: number
modeChanged: boolean
}) {
const [show, setShow] = useState(true)
const theme = THEME[type]
const theme = THEME[kind]
const [media] = useState(() => {
const pool = mediaFor(type)
const pool = mediaFor(kind)
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
})

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

@ -1,11 +1,12 @@
import { useState } from "react"
import { useRef, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "wouter"
import { Trash2 } from "lucide-react"
import { Trash2, Upload } from "lucide-react"
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,10 @@ 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 fileInputRef = useRef<HTMLInputElement>(null)
const create = useMutation({
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
@ -194,10 +206,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 +234,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 +266,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}
@ -251,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
/>
<input
className={inputClass}
placeholder="Intitulé de la question"
placeholder={
format === "image_reveal"
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
: "Intitulé de la question"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
@ -300,7 +340,42 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
</div>
)}
{format === "free" && (
{format === "image_reveal" && (
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onPickImage(e.target.files?.[0])}
/>
<Button
type="button"
variant="secondary"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
<Upload />
{uploading
? "Upload…"
: imageUrl
? "Changer l'image"
: "Choisir une image"}
</Button>
{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 +389,13 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
)}
<Button
disabled={create.isPending || !prompt.trim() || !category.trim()}
disabled={
create.isPending ||
uploading ||
(!prompt.trim() && format !== "image_reveal") ||
!category.trim() ||
(format === "image_reveal" && !imageUrl)
}
onClick={submit}
>
{create.isPending ? "Ajout…" : "Ajouter la question"}

View file

@ -22,6 +22,7 @@ export function RoomPage({ code }: { code: string }) {
const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round)
const roundKind = useRoomStore((s) => s.roundKind)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
@ -106,7 +107,7 @@ export function RoomPage({ code }: { code: string }) {
{roundKey > 0 && round && (
<RoundTransition
key={roundKey}
type={round.type}
kind={roundKind}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>

View file

@ -54,7 +54,9 @@ interface RoomState {
mediaSync: MediaSyncPayload | null
boomKey: number
roundKey: number
/** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */
/** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
roundKind: "quiz" | "image" | "blindtest"
/** Le mode a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean
createRoom: () => Promise<RoomCreatedPayload>
@ -91,6 +93,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
createRoom: () =>
@ -139,6 +142,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
socket.emit("lobby:updateSettings", {
gameType: partial.gameType ?? current.gameType,
mixedModes: partial.mixedModes ?? current.mixedModes,
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
roundDuration: partial.roundDuration ?? current.roundDuration,
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
@ -220,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
}),
}))
@ -247,23 +252,33 @@ socket.on("room:state", (snapshot) =>
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("round:start", (payload) =>
useRoomStore.setState((s) => ({
round: {
type: payload.type,
djId: payload.djId,
mine: payload.mine,
startsAt: payload.startsAt,
endsAt: payload.endsAt,
payload: payload.payload,
},
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1,
roundModeChanged: (s.round?.type ?? null) !== payload.type,
}))
useRoomStore.setState((s) => {
const fmt = (payload.payload as { format?: string } | null)?.format
const kind =
payload.type === "blindtest"
? "blindtest"
: fmt === "image_reveal"
? "image"
: "quiz"
return {
round: {
type: payload.type,
djId: payload.djId,
mine: payload.mine,
startsAt: payload.startsAt,
endsAt: payload.endsAt,
payload: payload.payload,
},
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1,
roundKind: kind,
roundModeChanged: s.roundKind !== kind,
}
})
)
socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress })

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

@ -8,7 +8,10 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
export type RoundType = "blindtest" | "quiz"
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
export type GameType = "mixed" | "quiz" | "blindtest"
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
/** Sous-modes activables quand la partie est "mixed". */
export type MixMode = "quiz" | "image" | "blindtest"
/** Modes de blindtest, déterminent la forme du vote et le barème. */
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
@ -32,6 +35,8 @@ export interface RoundConfig {
export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: GameType
/** Sous-modes actifs quand gameType === "mixed". */
mixedModes: MixMode[]
blindtestMode: BlindtestMode
/** Durée d'une manche en secondes (def. 60). */
roundDuration: number
@ -44,6 +49,7 @@ export interface RoomSettings {
/** Réglages par défaut d'une nouvelle room. */
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
gameType: "mixed",
mixedModes: ["quiz", "image"],
blindtestMode: "title_artist",
roundDuration: 60,
tracksPerPlayer: 2,

View file

@ -5,6 +5,7 @@ import type {
Answer,
BlindtestMode,
GameType,
MixMode,
PlayerScore,
RoomSnapshot,
RoundConfig,
@ -34,6 +35,7 @@ export interface SetNamePayload {
export interface UpdateSettingsPayload {
gameType: GameType
mixedModes: MixMode[]
blindtestMode: BlindtestMode
roundDuration: number
tracksPerPlayer: number

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