diff --git a/.gitignore b/.gitignore index 19d9751..1fbeef7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ dist-ssr # typescript *.tsbuildinfo + +# uploads (image_reveal) servies en statique +uploads/ diff --git a/apps/server/.env.example b/apps/server/.env.example index f7af472..7dc6e6f 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -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 diff --git a/apps/server/drizzle/0001_fancy_captain_stacy.sql b/apps/server/drizzle/0001_fancy_captain_stacy.sql new file mode 100644 index 0000000..63bef25 --- /dev/null +++ b/apps/server/drizzle/0001_fancy_captain_stacy.sql @@ -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; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a125720 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 4a441f0..985351f 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 31db470..c2e5525 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index 32491d0..acc961f 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -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) diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index b9512f2..f27136e 100644 --- a/apps/server/src/db/quiz-repo.ts +++ b/apps/server/src/db/quiz-repo.ts @@ -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 { - 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, })) diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 668257f..778bc75 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -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(), - correctIndex: integer("correct_index"), - acceptedAnswers: jsonb("accepted_answers").$type(), - 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(), + correctIndex: integer("correct_index"), + acceptedAnswers: jsonb("accepted_answers").$type(), + 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( diff --git a/apps/server/src/db/seed-images.ts b/apps/server/src/db/seed-images.ts new file mode 100644 index 0000000..8cfcb7c --- /dev/null +++ b/apps/server/src/db/seed-images.ts @@ -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 { + 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 { + 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() diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index e7e1220..f138043 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -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(",") diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts index 5a065fc..aadb522 100644 --- a/apps/server/src/game/modes/quiz/pool.ts +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -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() +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(items: T[]): T[] { const arr = [...items] for (let i = arr.length - 1; i > 0; i--) { @@ -25,13 +49,14 @@ function shuffle(items: T[]): T[] { /** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ export async function prepareQuizForRoom(room: ServerRoom): Promise { + 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 { } 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, + } } diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts index 271157e..4887f25 100644 --- a/apps/server/src/game/modes/quiz/questions.ts +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -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 } diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 310b046..6ee1bc9 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -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 } } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 4b15c83..3184db6 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -106,6 +106,7 @@ export function registerRoomHandlers( } room.settings = { gameType: payload.gameType, + mixedModes: payload.mixedModes, blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, tracksPerPlayer: payload.tracksPerPlayer, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 35b9fc7..6d5c675 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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" }) diff --git a/apps/web/index.html b/apps/web/index.html index de8cc51..4c20e8b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,10 +1,14 @@ - + - vite-monorepo + NerdWare — Party game culture geek +
diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md index a1675d9..8240354 100644 --- a/apps/web/src/assets/transitions/README.md +++ b/apps/web/src/assets/transitions/README.md @@ -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 : diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 360c277..215ba33 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -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 = { + 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 = { 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 }) { {isHost && ( -
-
- {GAME_TYPES.map(({ value, label, Icon }) => { - const needsThree = value !== "quiz" && !blindtestAvailable - return ( - - ) - })} -
- {!blindtestAvailable && ( -

- Le blindtest se débloque à 3 joueurs. -

- )} -
- )} +
+

+ Réglages de la partie +

- {isHost && showQuiz && ( -
- - Questions de quiz - -
- {QUESTION_OPTIONS.map((n) => ( - - ))} -
-
- )} - - {isHost && showBlindtest && ( -
-
- - Mode blindtest - -
- {(["title_artist", "who_added", "mixed"] as const).map((m) => ( - - ))} + {/* Mode de jeu */} +
+ Mode de jeu +
+ {GAME_TYPES.map(({ value, label, Icon, needsThree }) => { + const locked = needsThree && !blindtestAvailable + const active = effectiveType === value + return ( + + ) + })}
+ {!blindtestAvailable && ( +

+ Le blindtest se débloque à 3 joueurs. +

+ )}
-
- - Titres par joueur - - updateSettings({ tracksPerPlayer: v })} - /> -
-
+ + {/* Sous-modes du mixte (toggles) */} + {isMixed && ( +
+ Sous-modes inclus +
+ {(["quiz", "image", "blindtest"] as const).map((m) => { + const locked = m === "blindtest" && !blindtestAvailable + const on = mixedModes.includes(m) && !locked + return ( + + ) + })} +
+
+ )} + + {/* Nombre de questions (quiz / images / mixte) */} + {showQuestions && ( +
+
+ + {effectiveType === "image" ? ( + + ) : ( + + )} + {effectiveType === "image" ? "Images" : "Questions"} + + +
+ {effectiveType === "image" && ( +

+ Crée des questions « Image » dans le back-office au préalable. +

+ )} +
+ )} + + {/* Réglages blindtest */} + {showBlindtest && ( +
+
+ + Mode blindtest + +
+ {(["title_artist", "who_added", "mixed"] as const).map((m) => ( + + ))} +
+
+
+ + Titres par joueur + + updateSettings({ tracksPerPlayer: v })} + /> +
+
+ )} +
)} {showBlindtest && ( diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index ddc401a..4a5d31b 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -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} - {isFree ? ( + {isImage && question.imageUrl && ( + + )} + + {isText ? ( - {isFree && ( + {isText && (

Réponse : {truth?.answer} @@ -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 ( +

+ +
+ ) +} + function FreeAnswer({ disabled, onSubmit, diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 937123c..6969c29 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -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 -const QUIZ_MEDIA: string[] = [] -const BLINDTEST_MEDIA: string[] = [] +const MEDIA: Record = { 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 }) diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts index 73735b8..5a277a8 100644 --- a/apps/web/src/lib/admin-api.ts +++ b/apps/web/src/lib/admin-api.ts @@ -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(path: string, init?: RequestInit): Promise { @@ -53,8 +60,24 @@ async function request(path: string, init?: RequestInit): Promise { 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("/questions"), + uploadImage, createQuestion: (input: NewQuestionInput) => request<{ id: string }>("/questions", { method: "POST", diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 91523ea..149b17c 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -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 (
  • + {q.format === "image_reveal" && q.imageUrl && ( + + )}

    {q.prompt}

    {q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}

    - {q.format === "free" ? ( + {q.format === "free" || q.format === "image_reveal" ? (

    ✔ {q.acceptedAnswers?.join(", ")}

    @@ -186,6 +194,10 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { const [choices, setChoices] = useState(EMPTY_CHOICES) const [correctIndex, setCorrectIndex] = useState(0) const [answers, setAnswers] = useState("") + const [imageUrl, setImageUrl] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const fileInputRef = useRef(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 }) { + setPrompt(e.target.value)} /> @@ -300,7 +340,42 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
    )} - {format === "free" && ( + {format === "image_reveal" && ( +
    + onPickImage(e.target.files?.[0])} + /> + + {uploadError && ( +

    {uploadError}

    + )} + {imageUrl && ( + + )} +
    + )} + + {(format === "free" || format === "image_reveal") && (