From b91d09b7e77fcecb4375ec8cec09047b28730d56 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 15:37:33 +0200 Subject: [PATCH] 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[] }