From 879bc5b38880581599e78889fcda5888d0afb50b Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 15:10:09 +0200 Subject: [PATCH 1/3] feat: per-submode mixed settings, fastest award, wider desktop layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mixed settings fix: - RoundConfig carries a `pool` hint (quiz | image) per quiz round; lobby shows a separate count for each selected sub-mode (Quiz, Images) and the blindtest config — not just the quiz one - server pool composes the queue per round's pool (quiz formats vs image_reveal), in order; verified e2e (2 quiz + 2 images honored) Awards: - "⚡ Le plus rapide" — derived client-side from the speed bonus baked into quiz/image points (points above the 100 base on correct answers) Desktop restyle: - room page gets an ambient halo background and a responsive container (lobby max-w-3xl with a 2-column players|settings grid, game/end max-w-lg) - game/end views widened to max-w-lg Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/modes/quiz/pool.ts | 112 +++++++++++---------- apps/web/src/components/blindtest-view.tsx | 2 +- apps/web/src/components/game-end-view.tsx | 31 +++++- apps/web/src/components/lobby-view.tsx | 98 ++++++++++-------- apps/web/src/components/quiz-view.tsx | 2 +- apps/web/src/pages/room.tsx | 13 ++- packages/shared/src/domain.ts | 2 + 7 files changed, 155 insertions(+), 105 deletions(-) diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts index aadb522..794182c 100644 --- a/apps/server/src/game/modes/quiz/pool.ts +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -1,42 +1,22 @@ -// 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). +// File d'attente de questions par room. Préchargée au lancement : on respecte +// le sous-pool de chaque manche quiz (quiz "classique" vs images), dans l'ordre +// de la séquence. DB si dispo, sinon banque en dur (pas d'images en dur). -import type { QuizFormat, RoomSettings } from "@nerdware/shared" +import type { QuizFormat } from "@nerdware/shared" import { hasDb } from "../../../db" import { loadQuizPool } from "../../../db/quiz-repo" import type { ServerRoom } from "../../../rooms" import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" -interface RoomPool { - queue: QuizQuestion[] - /** true si les questions viennent de la DB (→ marquer jouées). */ +interface QueueItem { + question: QuizQuestion fromDb: boolean - formats: QuizFormat[] } -const poolByRoom = new WeakMap() +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 -} +const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"] function shuffle(items: T[]): T[] { const arr = [...items] @@ -47,44 +27,68 @@ function shuffle(items: T[]): T[] { return arr } -/** 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 - +/** Charge un sous-pool (DB puis complément banque en dur) pour `need` manches. */ +async function loadGroup( + room: ServerRoom, + need: number, + formats: QuizFormat[], + isImage: boolean +): Promise { + if (need <= 0) { + return [] + } + let items: QueueItem[] = [] if (hasDb) { try { - queue = await loadQuizPool(room.code, Math.max(needed, 1), formats) - fromDb = queue.length > 0 + const rows = await loadQuizPool(room.code, need, formats) + items = rows.map((question) => ({ question, fromDb: true })) } catch (err) { console.error("[quiz] chargement DB échoué, fallback banque en dur", err) } } - - if (queue.length === 0) { - queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))) - fromDb = false + if (items.length < need && !isImage) { + // Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur). + const bank = shuffle( + QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)) + ) + for (const question of bank) { + if (items.length >= need) break + items.push({ question, fromDb: false }) + } } - - poolByRoom.set(room, { queue, fromDb, formats }) + return items } -/** Pioche la prochaine question. Fallback aléatoire (formats autorisés) si vide. */ +/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ +export async function prepareQuizForRoom(room: ServerRoom): Promise { + const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz") + const needImage = quizRounds.filter((r) => r.pool === "image").length + const needQuiz = quizRounds.length - needImage + + const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false) + const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true) + + // On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool). + const queue: QueueItem[] = [] + for (const r of quizRounds) { + const group = r.pool === "image" ? imageGroup : quizGroup + const item = group.shift() + if (item) { + queue.push(item) + } + } + poolByRoom.set(room, queue) +} + +/** Pioche la prochaine question. Fallback aléatoire (banque en dur) si vide. */ export function takeQuestion(room: ServerRoom): { question: QuizQuestion fromDb: boolean } { - const pool = poolByRoom.get(room) - if (pool && pool.queue.length > 0) { - return { question: pool.queue.shift()!, fromDb: pool.fromDb } - } - 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, + const queue = poolByRoom.get(room) + if (queue && queue.length > 0) { + return queue.shift()! } + const q = QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)] + return { question: q, fromDb: false } } diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index a96ea40..cfc2a76 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -65,7 +65,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { }, [showReveal, ready, api]) return ( -
+
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds} diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 1f2a5a4..577f6ef 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -180,7 +180,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const rest = ranked.slice(3) return ( -
+

Partie terminée @@ -354,8 +354,12 @@ interface PlayerStat { correct: number answered: number decoy: number + speed: number } +// Points de base d'une bonne réponse quiz/image ; le surplus = bonus de vitesse. +const QUIZ_BASE_POINTS = 100 + function FunStats({ recap, snapshot, @@ -364,15 +368,25 @@ function FunStats({ snapshot: RoomSnapshot }) { const stats = new Map( - snapshot.players.map((p) => [p.id, { correct: 0, answered: 0, decoy: 0 }]) + snapshot.players.map((p) => [ + p.id, + { correct: 0, answered: 0, decoy: 0, speed: 0 }, + ]) ) for (const r of recap) { const answered = new Set(r.answers.map((a) => a.playerId)) + const pointsBy = new Map(r.scorers.map((sc) => [sc.playerId, sc.points])) for (const a of r.answers) { const s = stats.get(a.playerId) if (s) { s.answered++ - if (a.correct) s.correct++ + if (a.correct) { + s.correct++ + // Bonus de vitesse = points au-dessus de la base (quiz/images). + if (r.type !== "blindtest") { + s.speed += Math.max(0, (pointsBy.get(a.playerId) ?? 0) - QUIZ_BASE_POINTS) + } + } } } if (r.type === "blindtest") { @@ -388,7 +402,8 @@ function FunStats({ const players = snapshot.players const total = recap.length const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?" - const stat = (id: string) => stats.get(id) ?? { correct: 0, answered: 0, decoy: 0 } + const stat = (id: string) => + stats.get(id) ?? { correct: 0, answered: 0, decoy: 0, speed: 0 } const winners = (pick: (s: PlayerStat) => number, min = 1) => { const max = Math.max(0, ...players.map((p) => pick(stat(p.id)))) return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : [] @@ -405,6 +420,14 @@ function FunStats({ if (brains.length && brains.length < players.length) { awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) }) } + const fastest = winners((s) => s.speed) + if (fastest.length && fastest.length < players.length) { + awards.push({ + emoji: "⚡", + label: "Le plus rapide", + ids: fastest.map((p) => p.id), + }) + } const decoy = winners((s) => s.decoy) if (decoy.length) { awards.push({ diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 215ba33..de06407 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -107,24 +107,25 @@ function shuffle(items: T[]): T[] { return arr } -/** Construit la séquence de manches selon le type de partie. */ -function buildRounds( - gameType: GameType, - quizCount: number, - totalTracks: number -): RoundConfig[] { - if (gameType === "quiz" || gameType === "image") { - return Array.from({ length: quizCount }, () => ({ type: "quiz" })) - } - if (gameType === "blindtest") { - return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) - } - // Mixte : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite. +/** Construit la séquence de manches (chaque manche quiz porte son sous-pool). */ +function buildRounds(opts: { + quiz: number + image: number + tracks: number + shuffleOrder: boolean +}): RoundConfig[] { const rounds: RoundConfig[] = [ - ...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })), - ...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })), + ...Array.from({ length: opts.quiz }, () => ({ + type: "quiz" as const, + pool: "quiz" as const, + })), + ...Array.from({ length: opts.image }, () => ({ + type: "quiz" as const, + pool: "image" as const, + })), + ...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })), ] - return shuffle(rounds) + return opts.shuffleOrder ? shuffle(rounds) : rounds } export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { @@ -135,7 +136,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const { gameType, mixedModes, blindtestMode, tracksPerPlayer } = snapshot.settings - const [count, setCount] = useState(5) + const [quizCount, setQuizCount] = useState(5) + const [imageCount, setImageCount] = useState(5) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -152,21 +154,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType const isMixed = effectiveType === "mixed" - // Sous-modes effectivement actifs. - const wantsQuestions = - effectiveType === "quiz" || - effectiveType === "image" || - (isMixed && (mixedModes.includes("quiz") || mixedModes.includes("image"))) - const showQuestions = wantsQuestions + // Sous-modes effectivement actifs (un réglage par sous-mode). + const showQuiz = + effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz")) + const showImage = + effectiveType === "image" || (isMixed && mixedModes.includes("image")) const showBlindtest = effectiveType === "blindtest" || (isMixed && mixedModes.includes("blindtest") && blindtestAvailable) - const rounds = buildRounds( - effectiveType, - showQuestions ? count : 0, - showBlindtest ? totalTracks : 0 - ) + const rounds = buildRounds({ + quiz: showQuiz ? quizCount : 0, + image: showImage ? imageCount : 0, + tracks: showBlindtest ? totalTracks : 0, + shuffleOrder: isMixed, + }) // Blindtest : tout le monde doit avoir soumis son quota de titres. const allSubmitted = !showBlindtest || @@ -197,7 +199,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { } return ( -

+

Joueurs ({snapshot.players.length}) @@ -226,6 +228,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {

+
{isHost && (

@@ -292,25 +295,33 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {

)} - {/* Nombre de questions (quiz / images / mixte) */} - {showQuestions && ( + {/* Nombre de questions de quiz */} + {showQuiz && ( +
+ + Questions de quiz + + +
+ )} + + {/* Nombre d'images */} + {showImage && (
- {effectiveType === "image" ? ( - - ) : ( - - )} - {effectiveType === "image" ? "Images" : "Questions"} + Images à deviner - +
- {effectiveType === "image" && ( -

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

- )} +

+ Nécessite des questions « Image » créées dans le back-office. +

)} @@ -373,6 +384,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { )} {error &&

{error}

} +
) } diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index 4a5d31b..124f89c 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -59,7 +59,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { } return ( -
+
Question {snapshot.currentRound + 1} / {snapshot.totalRounds} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index f9edea7..c5238e9 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -63,8 +63,17 @@ export function RoomPage({ code }: { code: string }) { : "game" return ( -
-
+
+ {/* Halo d'ambiance en fond */} +
+
Date: Thu, 11 Jun 2026 15:24:34 +0200 Subject: [PATCH 2/3] feat(web): richer desktop layout for end screen and back-office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - end screen: wider container (max-w-4xl), podium/awards stay centered, and the two long sections (musiques + récap des manches) sit side-by-side on desktop - back-office: 2-column layout (form | questions list) on desktop, wider Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/game-end-view.tsx | 23 +++++++++++++++-------- apps/web/src/pages/admin.tsx | 6 +++--- apps/web/src/pages/room.tsx | 6 +++++- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 577f6ef..5e5bd39 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -180,7 +180,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const rest = ranked.slice(3) return ( -
+
+

Partie terminée @@ -253,16 +254,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { {gameRecap && gameRecap.length > 0 && ( )} +

- {gameTracks && gameTracks.length > 0 && ( - + {/* Détails (musiques + récap) côte à côte sur desktop. */} + {((gameTracks && gameTracks.length > 0) || + (gameRecap && gameRecap.length > 0)) && ( +
+ {gameTracks && gameTracks.length > 0 && ( + + )} + {gameRecap && gameRecap.length > 0 && ( + + )} +
)} - {gameRecap && gameRecap.length > 0 && ( - - )} - -
+

) : ( - <> +
qc.invalidateQueries({ queryKey: ["admin-questions"] }) @@ -131,7 +131,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) { ))} - +
)}
) diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index c5238e9..81bfb56 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -71,7 +71,11 @@ export function RoomPage({ code }: { code: string }) { />
From b91d09b7e77fcecb4375ec8cec09047b28730d56 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 15:37:33 +0200 Subject: [PATCH 3/3] feat: category filter in lobby settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings carry `categories` (empty = all); quiz/image pool filters by category (DB + in-code bank), threaded through prepareQuizForRoom/loadGroup/loadQuizPool - public GET /api/categories lists available categories - lobby: category chips (Toutes + each), shown when quiz/images is active - shared: RoomSettings.categories + UpdateSettingsPayload Verified e2e: /api/categories returns the list; filtering by ["Pokémon"] serves only Pokémon questions. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/db/quiz-repo.ts | 18 ++++++- .../game/modes/blindtest/blindtest-round.ts | 2 +- apps/server/src/game/modes/quiz/pool.ts | 16 ++++-- apps/server/src/handlers.ts | 1 + apps/server/src/index.ts | 4 ++ apps/web/src/components/lobby-view.tsx | 51 ++++++++++++++++++- apps/web/src/lib/categories.ts | 15 ++++++ apps/web/src/store/room.ts | 1 + packages/shared/src/domain.ts | 3 ++ packages/shared/src/events.ts | 1 + 10 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/categories.ts diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index f27136e..1c39a17 100644 --- a/apps/server/src/db/quiz-repo.ts +++ b/apps/server/src/db/quiz-repo.ts @@ -9,10 +9,23 @@ import type { QuizQuestion } from "../game/modes/quiz/questions" * Pool de questions mcq/truefalse non encore jouées par cette room, * mélangées et limitées. Tableau vide si pas de DB ou rien à servir. */ +/** Catégories existantes (pour le filtre du lobby). */ +export async function listCategories(): Promise { + if (!db) { + return [] + } + const rows = await db + .select({ name: quizCategory.name }) + .from(quizCategory) + .orderBy(quizCategory.name) + return rows.map((r) => r.name) +} + export async function loadQuizPool( roomCode: string, limit: number, - formats: string[] + formats: string[], + categories: string[] = [] ): Promise { if (!db || formats.length === 0) { return [] @@ -39,6 +52,9 @@ export async function loadQuizPool( .where( and( inArray(quizQuestion.format, formats), + categories.length > 0 + ? inArray(quizCategory.name, categories) + : undefined, notInArray(quizQuestion.id, alreadyPlayed) ) ) diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 20a46c9..c78ae68 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -175,7 +175,7 @@ export class BlindtestRound implements GameRound { artist?: string guessedPlayerId?: string } - let text = "" + let text: string if (mode === "who_added") { text = nameOf(a.guessedPlayerId) } else if (mode === "title_artist") { diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts index 794182c..ccefa1f 100644 --- a/apps/server/src/game/modes/quiz/pool.ts +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -32,7 +32,8 @@ async function loadGroup( room: ServerRoom, need: number, formats: QuizFormat[], - isImage: boolean + isImage: boolean, + categories: string[] ): Promise { if (need <= 0) { return [] @@ -40,7 +41,7 @@ async function loadGroup( let items: QueueItem[] = [] if (hasDb) { try { - const rows = await loadQuizPool(room.code, need, formats) + const rows = await loadQuizPool(room.code, need, formats, categories) items = rows.map((question) => ({ question, fromDb: true })) } catch (err) { console.error("[quiz] chargement DB échoué, fallback banque en dur", err) @@ -49,7 +50,11 @@ async function loadGroup( if (items.length < need && !isImage) { // Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur). const bank = shuffle( - QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)) + QUIZ_QUESTIONS.filter( + (q) => + formats.includes(q.format) && + (categories.length === 0 || categories.includes(q.category)) + ) ) for (const question of bank) { if (items.length >= need) break @@ -65,8 +70,9 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise { const needImage = quizRounds.filter((r) => r.pool === "image").length const needQuiz = quizRounds.length - needImage - const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false) - const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true) + const cats = room.settings.categories + const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats) + const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats) // On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool). const queue: QueueItem[] = [] diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 3184db6..2a39854 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -110,6 +110,7 @@ export function registerRoomHandlers( blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, tracksPerPlayer: payload.tracksPerPlayer, + categories: payload.categories, rounds: payload.rounds, } broadcastState(room) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6d5c675..f0f9390 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -7,6 +7,7 @@ import fastifyStatic from "@fastify/static" import { env, isDev } from "./env" import { createSocketServer } from "./socket" import { adminRoutes } from "./admin/routes" +import { listCategories } from "./db/quiz-repo" import "./game/modes" // enregistre les épreuves (registerRound) const app = Fastify({ @@ -25,6 +26,9 @@ await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" }) app.get("/health", async () => ({ status: "ok", uptime: process.uptime() })) +// Catégories de quiz disponibles (public, pour le filtre du lobby). +app.get("/api/categories", async () => listCategories()) + await app.register(adminRoutes, { prefix: "/api/admin" }) // Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify. diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index de06407..c25f227 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,6 +1,8 @@ import { useState } from "react" +import { useQuery } from "@tanstack/react-query" import { Brain, + Filter, Headphones, Image as ImageIcon, ListMusic, @@ -13,6 +15,7 @@ import { type LucideIcon, } from "lucide-react" import { SiYoutube } from "@icons-pack/react-simple-icons" +import { fetchCategories } from "@/lib/categories" import type { BlindtestMode, GameType, @@ -133,8 +136,10 @@ 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, mixedModes, blindtestMode, tracksPerPlayer } = + const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } = snapshot.settings + const allCategories = + useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? [] const [quizCount, setQuizCount] = useState(5) const [imageCount, setImageCount] = useState(5) @@ -186,6 +191,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { updateSettings({ mixedModes: [...set] }) } + function toggleCategory(name: string) { + const set = new Set(categories) + if (set.has(name)) { + set.delete(name) + } else { + set.add(name) + } + updateSettings({ categories: [...set] }) + } + async function start() { setError(null) setBusy(true) @@ -325,6 +340,40 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
)} + {/* Filtre de catégories (quiz / images) */} + {(showQuiz || showImage) && allCategories.length > 0 && ( +
+ + Catégories + +
+ + {allCategories.map((c) => ( + + ))} +
+
+ )} + {/* Réglages blindtest */} {showBlindtest && (
diff --git a/apps/web/src/lib/categories.ts b/apps/web/src/lib/categories.ts new file mode 100644 index 0000000..69913d4 --- /dev/null +++ b/apps/web/src/lib/categories.ts @@ -0,0 +1,15 @@ +// Liste publique des catégories de quiz (pour le filtre du lobby). + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" + +export async function fetchCategories(): Promise { + try { + const res = await fetch(`${SERVER_URL}/api/categories`) + if (!res.ok) { + return [] + } + return (await res.json()) as string[] + } catch { + return [] + } +} diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 404f97d..c8ecbfe 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -149,6 +149,7 @@ export const useRoomStore = create((set, get) => ({ blindtestMode: partial.blindtestMode ?? current.blindtestMode, roundDuration: partial.roundDuration ?? current.roundDuration, tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer, + categories: partial.categories ?? current.categories, rounds: partial.rounds ?? current.rounds, }) }, diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index c16fb84..8c98ea2 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -44,6 +44,8 @@ export interface RoomSettings { roundDuration: number /** Nombre de titres que chaque joueur soumet (blindtest). */ tracksPerPlayer: number + /** Catégories de quiz autorisées (vide = toutes). */ + categories: string[] /** Séquence d'épreuves de la partie. */ rounds: RoundConfig[] } @@ -55,6 +57,7 @@ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { blindtestMode: "title_artist", roundDuration: 60, tracksPerPlayer: 2, + categories: [], rounds: [], } diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index c52c11e..62cd0df 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -40,6 +40,7 @@ export interface UpdateSettingsPayload { blindtestMode: BlindtestMode roundDuration: number tracksPerPlayer: number + categories: string[] rounds: RoundConfig[] }