feat: category filter in lobby settings
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
fa7c97c402
commit
b91d09b7e7
10 changed files with 104 additions and 8 deletions
|
|
@ -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<string[]> {
|
||||
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<QuizQuestion[]> {
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ async function loadGroup(
|
|||
room: ServerRoom,
|
||||
need: number,
|
||||
formats: QuizFormat[],
|
||||
isImage: boolean
|
||||
isImage: boolean,
|
||||
categories: string[]
|
||||
): Promise<QueueItem[]> {
|
||||
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<void> {
|
|||
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[] = []
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export function registerRoomHandlers(
|
|||
blindtestMode: payload.blindtestMode,
|
||||
roundDuration: payload.roundDuration,
|
||||
tracksPerPlayer: payload.tracksPerPlayer,
|
||||
categories: payload.categories,
|
||||
rounds: payload.rounds,
|
||||
}
|
||||
broadcastState(room)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Filtre de catégories (quiz / images) */}
|
||||
{(showQuiz || showImage) && allCategories.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Filter className="size-4" /> Catégories
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => updateSettings({ categories: [] })}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
categories.length === 0
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
Toutes
|
||||
</button>
|
||||
{allCategories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => toggleCategory(c)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
categories.includes(c)
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Réglages blindtest */}
|
||||
{showBlindtest && (
|
||||
<div className="flex flex-col gap-3 border-t pt-3">
|
||||
|
|
|
|||
15
apps/web/src/lib/categories.ts
Normal file
15
apps/web/src/lib/categories.ts
Normal file
|
|
@ -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<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/api/categories`)
|
||||
if (!res.ok) {
|
||||
return []
|
||||
}
|
||||
return (await res.json()) as string[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -149,6 +149,7 @@ export const useRoomStore = create<RoomState>((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,
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface UpdateSettingsPayload {
|
|||
blindtestMode: BlindtestMode
|
||||
roundDuration: number
|
||||
tracksPerPlayer: number
|
||||
categories: string[]
|
||||
rounds: RoundConfig[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue