Compare commits

..

4 commits

Author SHA1 Message Date
ed1747543b Merge pull request 'feature/mixed-settings-restyle' (#12) from feature/mixed-settings-restyle into dev
Reviewed-on: #12
2026-06-11 13:41:59 +00:00
AyoubBenziza
b91d09b7e7 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>
2026-06-11 15:37:33 +02:00
AyoubBenziza
fa7c97c402 feat(web): richer desktop layout for end screen and back-office
- 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) <noreply@anthropic.com>
2026-06-11 15:24:34 +02:00
AyoubBenziza
879bc5b388 feat: per-submode mixed settings, fastest award, wider desktop layout
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) <noreply@anthropic.com>
2026-06-11 15:10:09 +02:00
15 changed files with 275 additions and 118 deletions

View file

@ -9,10 +9,23 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
* Pool de questions mcq/truefalse non encore jouées par cette room, * 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. * 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( export async function loadQuizPool(
roomCode: string, roomCode: string,
limit: number, limit: number,
formats: string[] formats: string[],
categories: string[] = []
): Promise<QuizQuestion[]> { ): Promise<QuizQuestion[]> {
if (!db || formats.length === 0) { if (!db || formats.length === 0) {
return [] return []
@ -39,6 +52,9 @@ export async function loadQuizPool(
.where( .where(
and( and(
inArray(quizQuestion.format, formats), inArray(quizQuestion.format, formats),
categories.length > 0
? inArray(quizCategory.name, categories)
: undefined,
notInArray(quizQuestion.id, alreadyPlayed) notInArray(quizQuestion.id, alreadyPlayed)
) )
) )

View file

@ -175,7 +175,7 @@ export class BlindtestRound implements GameRound {
artist?: string artist?: string
guessedPlayerId?: string guessedPlayerId?: string
} }
let text = "" let text: string
if (mode === "who_added") { if (mode === "who_added") {
text = nameOf(a.guessedPlayerId) text = nameOf(a.guessedPlayerId)
} else if (mode === "title_artist") { } else if (mode === "title_artist") {

View file

@ -1,42 +1,22 @@
// File d'attente de questions par room. Préchargée au lancement de la partie // File d'attente de questions par room. Préchargée au lancement : on respecte
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche. // le sous-pool de chaque manche quiz (quiz "classique" vs images), dans l'ordre
// Les formats servis dépendent du type de partie (quiz / images / mixte). // 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 { hasDb } from "../../../db"
import { loadQuizPool } from "../../../db/quiz-repo" import { loadQuizPool } from "../../../db/quiz-repo"
import type { ServerRoom } from "../../../rooms" import type { ServerRoom } from "../../../rooms"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
interface RoomPool { interface QueueItem {
queue: QuizQuestion[] question: QuizQuestion
/** true si les questions viennent de la DB (→ marquer jouées). */
fromDb: boolean fromDb: boolean
formats: QuizFormat[]
} }
const poolByRoom = new WeakMap<ServerRoom, RoomPool>() const poolByRoom = new WeakMap<ServerRoom, QueueItem[]>()
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"] const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
/** Formats de quiz autorisés selon le type de partie (et les sous-modes mixtes). */
function quizFormatsFor(settings: RoomSettings): QuizFormat[] {
if (settings.gameType === "image") {
return ["image_reveal"]
}
if (settings.gameType === "mixed") {
const formats: QuizFormat[] = []
if (settings.mixedModes.includes("quiz")) {
formats.push(...QUIZ_FORMATS)
}
if (settings.mixedModes.includes("image")) {
formats.push("image_reveal")
}
return formats.length > 0 ? formats : QUIZ_FORMATS
}
// quiz, ou blindtest retombé sur du quiz (<3 joueurs)
return QUIZ_FORMATS
}
function shuffle<T>(items: T[]): T[] { function shuffle<T>(items: T[]): T[] {
const arr = [...items] const arr = [...items]
@ -47,44 +27,74 @@ function shuffle<T>(items: T[]): T[] {
return arr return arr
} }
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ /** Charge un sous-pool (DB puis complément banque en dur) pour `need` manches. */
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> { async function loadGroup(
const formats = quizFormatsFor(room.settings) room: ServerRoom,
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length need: number,
let queue: QuizQuestion[] = [] formats: QuizFormat[],
let fromDb = false isImage: boolean,
categories: string[]
): Promise<QueueItem[]> {
if (need <= 0) {
return []
}
let items: QueueItem[] = []
if (hasDb) { if (hasDb) {
try { try {
queue = await loadQuizPool(room.code, Math.max(needed, 1), formats) const rows = await loadQuizPool(room.code, need, formats, categories)
fromDb = queue.length > 0 items = rows.map((question) => ({ question, fromDb: true }))
} catch (err) { } catch (err) {
console.error("[quiz] chargement DB échoué, fallback banque en dur", err) console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
} }
} }
if (items.length < need && !isImage) {
if (queue.length === 0) { // Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))) const bank = shuffle(
fromDb = false 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
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<void> {
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 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[] = []
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): { export function takeQuestion(room: ServerRoom): {
question: QuizQuestion question: QuizQuestion
fromDb: boolean fromDb: boolean
} { } {
const pool = poolByRoom.get(room) const queue = poolByRoom.get(room)
if (pool && pool.queue.length > 0) { if (queue && queue.length > 0) {
return { question: pool.queue.shift()!, fromDb: pool.fromDb } return queue.shift()!
}
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 q = QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
return { question: q, fromDb: false }
} }

View file

@ -110,6 +110,7 @@ export function registerRoomHandlers(
blindtestMode: payload.blindtestMode, blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration, roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer, tracksPerPlayer: payload.tracksPerPlayer,
categories: payload.categories,
rounds: payload.rounds, rounds: payload.rounds,
} }
broadcastState(room) broadcastState(room)

View file

@ -7,6 +7,7 @@ import fastifyStatic from "@fastify/static"
import { env, isDev } from "./env" import { env, isDev } from "./env"
import { createSocketServer } from "./socket" import { createSocketServer } from "./socket"
import { adminRoutes } from "./admin/routes" import { adminRoutes } from "./admin/routes"
import { listCategories } from "./db/quiz-repo"
import "./game/modes" // enregistre les épreuves (registerRound) import "./game/modes" // enregistre les épreuves (registerRound)
const app = Fastify({ const app = Fastify({
@ -25,6 +26,9 @@ await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() })) 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" }) await app.register(adminRoutes, { prefix: "/api/admin" })
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify. // Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.

View file

@ -65,7 +65,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
}, [showReveal, ready, api]) }, [showReveal, ready, api])
return ( return (
<div className="flex w-full max-w-md flex-col gap-5"> <div className="flex w-full max-w-lg flex-col gap-5">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase"> <span className="text-muted-foreground text-xs uppercase">
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds} Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}

View file

@ -180,7 +180,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const rest = ranked.slice(3) const rest = ranked.slice(3)
return ( return (
<div className="flex w-full max-w-md flex-col gap-6 text-center"> <div className="flex w-full flex-col items-center gap-6">
<div className="flex w-full max-w-xl flex-col gap-6 text-center">
<header> <header>
<p className="text-muted-foreground text-xs uppercase"> <p className="text-muted-foreground text-xs uppercase">
Partie terminée Partie terminée
@ -253,16 +254,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
{gameRecap && gameRecap.length > 0 && ( {gameRecap && gameRecap.length > 0 && (
<FunStats recap={gameRecap} snapshot={snapshot} /> <FunStats recap={gameRecap} snapshot={snapshot} />
)} )}
</div>
{/* Détails (musiques + récap) côte à côte sur desktop. */}
{((gameTracks && gameTracks.length > 0) ||
(gameRecap && gameRecap.length > 0)) && (
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
{gameTracks && gameTracks.length > 0 && ( {gameTracks && gameTracks.length > 0 && (
<TracksRecap tracks={gameTracks} /> <TracksRecap tracks={gameTracks} />
)} )}
{gameRecap && gameRecap.length > 0 && ( {gameRecap && gameRecap.length > 0 && (
<RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} /> <RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} />
)} )}
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex w-full max-w-sm flex-col gap-2">
<Button variant="secondary" className="w-full" onClick={copyResults}> <Button variant="secondary" className="w-full" onClick={copyResults}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />} {copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? "Copié !" : "Copier les résultats"} {copied ? "Copié !" : "Copier les résultats"}
@ -354,8 +361,12 @@ interface PlayerStat {
correct: number correct: number
answered: number answered: number
decoy: 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({ function FunStats({
recap, recap,
snapshot, snapshot,
@ -364,15 +375,25 @@ function FunStats({
snapshot: RoomSnapshot snapshot: RoomSnapshot
}) { }) {
const stats = new Map<string, PlayerStat>( const stats = new Map<string, PlayerStat>(
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) { for (const r of recap) {
const answered = new Set(r.answers.map((a) => a.playerId)) 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) { for (const a of r.answers) {
const s = stats.get(a.playerId) const s = stats.get(a.playerId)
if (s) { if (s) {
s.answered++ 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") { if (r.type === "blindtest") {
@ -388,7 +409,8 @@ function FunStats({
const players = snapshot.players const players = snapshot.players
const total = recap.length const total = recap.length
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?" 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 winners = (pick: (s: PlayerStat) => number, min = 1) => {
const max = Math.max(0, ...players.map((p) => pick(stat(p.id)))) const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : [] return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
@ -405,6 +427,14 @@ function FunStats({
if (brains.length && brains.length < players.length) { if (brains.length && brains.length < players.length) {
awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) }) 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) const decoy = winners((s) => s.decoy)
if (decoy.length) { if (decoy.length) {
awards.push({ awards.push({

View file

@ -1,6 +1,8 @@
import { useState } from "react" import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { import {
Brain, Brain,
Filter,
Headphones, Headphones,
Image as ImageIcon, Image as ImageIcon,
ListMusic, ListMusic,
@ -13,6 +15,7 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { SiYoutube } from "@icons-pack/react-simple-icons" import { SiYoutube } from "@icons-pack/react-simple-icons"
import { fetchCategories } from "@/lib/categories"
import type { import type {
BlindtestMode, BlindtestMode,
GameType, GameType,
@ -107,24 +110,25 @@ function shuffle<T>(items: T[]): T[] {
return arr return arr
} }
/** Construit la séquence de manches selon le type de partie. */ /** Construit la séquence de manches (chaque manche quiz porte son sous-pool). */
function buildRounds( function buildRounds(opts: {
gameType: GameType, quiz: number
quizCount: number, image: number
totalTracks: number tracks: number
): RoundConfig[] { shuffleOrder: boolean
if (gameType === "quiz" || gameType === "image") { }): RoundConfig[] {
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.
const rounds: RoundConfig[] = [ const rounds: RoundConfig[] = [
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })), ...Array.from({ length: opts.quiz }, () => ({
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })), 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 }) { export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
@ -132,10 +136,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const updateSettings = useRoomStore((s) => s.updateSettings) const updateSettings = useRoomStore((s) => s.updateSettings)
const startGame = useRoomStore((s) => s.startGame) const startGame = useRoomStore((s) => s.startGame)
const isHost = snapshot.hostId === playerId const isHost = snapshot.hostId === playerId
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } = const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } =
snapshot.settings snapshot.settings
const allCategories =
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
const [count, setCount] = useState(5) const [quizCount, setQuizCount] = useState(5)
const [imageCount, setImageCount] = useState(5)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -152,21 +159,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
const isMixed = effectiveType === "mixed" const isMixed = effectiveType === "mixed"
// Sous-modes effectivement actifs. // Sous-modes effectivement actifs (un réglage par sous-mode).
const wantsQuestions = const showQuiz =
effectiveType === "quiz" || effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
effectiveType === "image" || const showImage =
(isMixed && (mixedModes.includes("quiz") || mixedModes.includes("image"))) effectiveType === "image" || (isMixed && mixedModes.includes("image"))
const showQuestions = wantsQuestions
const showBlindtest = const showBlindtest =
effectiveType === "blindtest" || effectiveType === "blindtest" ||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable) (isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
const rounds = buildRounds( const rounds = buildRounds({
effectiveType, quiz: showQuiz ? quizCount : 0,
showQuestions ? count : 0, image: showImage ? imageCount : 0,
showBlindtest ? totalTracks : 0 tracks: showBlindtest ? totalTracks : 0,
) shuffleOrder: isMixed,
})
// Blindtest : tout le monde doit avoir soumis son quota de titres. // Blindtest : tout le monde doit avoir soumis son quota de titres.
const allSubmitted = const allSubmitted =
!showBlindtest || !showBlindtest ||
@ -184,6 +191,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
updateSettings({ mixedModes: [...set] }) 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() { async function start() {
setError(null) setError(null)
setBusy(true) setBusy(true)
@ -197,7 +214,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
} }
return ( return (
<div className="flex w-full max-w-md flex-col gap-6"> <div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium"> <h2 className="text-muted-foreground text-sm font-medium">
Joueurs ({snapshot.players.length}) Joueurs ({snapshot.players.length})
@ -226,6 +243,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</ul> </ul>
</section> </section>
<div className="flex flex-col gap-6">
{isHost && ( {isHost && (
<section className="flex flex-col gap-4 rounded-xl border p-4"> <section className="flex flex-col gap-4 rounded-xl border p-4">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide"> <h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
@ -292,25 +310,67 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div> </div>
)} )}
{/* Nombre de questions (quiz / images / mixte) */} {/* Nombre de questions de quiz */}
{showQuestions && ( {showQuiz && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="size-4" /> Questions de quiz
</span>
<Stepper value={quizCount} min={1} max={20} onChange={setQuizCount} />
</div>
)}
{/* Nombre d'images */}
{showImage && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
{effectiveType === "image" ? ( <ImageIcon className="size-4" /> Images à deviner
<ImageIcon className="size-4" />
) : (
<Brain className="size-4" />
)}
{effectiveType === "image" ? "Images" : "Questions"}
</span> </span>
<Stepper value={count} min={1} max={20} onChange={setCount} /> <Stepper
value={imageCount}
min={1}
max={20}
onChange={setImageCount}
/>
</div> </div>
{effectiveType === "image" && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Crée des questions « Image » dans le back-office au préalable. Nécessite des questions « Image » créées dans le back-office.
</p> </p>
</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> </div>
)} )}
@ -374,6 +434,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{error && <p className="text-destructive text-center text-sm">{error}</p>} {error && <p className="text-destructive text-center text-sm">{error}</p>}
</div> </div>
</div>
) )
} }

View file

@ -59,7 +59,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
} }
return ( return (
<div className="flex w-full max-w-md flex-col gap-5"> <div className="flex w-full max-w-lg flex-col gap-5">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase"> <span className="text-muted-foreground text-xs uppercase">
Question {snapshot.currentRound + 1} / {snapshot.totalRounds} Question {snapshot.currentRound + 1} / {snapshot.totalRounds}

View 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 []
}
}

View file

@ -90,7 +90,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
questions.isError && /401|autoris/i.test(String(questions.error)) questions.isError && /401|autoris/i.test(String(questions.error))
return ( return (
<div className="mx-auto flex min-h-svh w-full max-w-2xl flex-col gap-6 p-6"> <div className="mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-6 p-6">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h1 className="font-heading text-2xl font-bold">Back-office quiz</h1> <h1 className="font-heading text-2xl font-bold">Back-office quiz</h1>
<Button size="sm" variant="secondary" onClick={onLogout}> <Button size="sm" variant="secondary" onClick={onLogout}>
@ -106,7 +106,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
</button> </button>
</p> </p>
) : ( ) : (
<> <div className="grid gap-6 md:grid-cols-2 md:items-start">
<QuestionForm <QuestionForm
onCreated={() => onCreated={() =>
qc.invalidateQueries({ queryKey: ["admin-questions"] }) qc.invalidateQueries({ queryKey: ["admin-questions"] })
@ -131,7 +131,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
))} ))}
</ul> </ul>
</section> </section>
</> </div>
)} )}
</div> </div>
) )

View file

@ -63,8 +63,21 @@ export function RoomPage({ code }: { code: string }) {
: "game" : "game"
return ( return (
<div className="flex min-h-svh items-start justify-center p-6 pt-12"> <div className="relative flex min-h-svh items-start justify-center overflow-hidden p-4 pt-10 sm:p-6 sm:pt-12">
<div className="flex w-full max-w-md flex-col gap-6"> {/* Halo d'ambiance en fond */}
<div
aria-hidden
className="pointer-events-none absolute top-[-20vmin] -z-10 size-[90vmin] rounded-full bg-gradient-to-br from-fuchsia-500/10 via-purple-500/5 to-cyan-400/10 blur-3xl"
/>
<div
className={`flex w-full flex-col gap-6 transition-[max-width] ${
phase === "lobby"
? "max-w-3xl"
: phase === "ended"
? "max-w-4xl"
: "max-w-lg"
}`}
>
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<RoomCode code={snapshot.code} /> <RoomCode code={snapshot.code} />
<span <span

View file

@ -149,6 +149,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
blindtestMode: partial.blindtestMode ?? current.blindtestMode, blindtestMode: partial.blindtestMode ?? current.blindtestMode,
roundDuration: partial.roundDuration ?? current.roundDuration, roundDuration: partial.roundDuration ?? current.roundDuration,
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer, tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
categories: partial.categories ?? current.categories,
rounds: partial.rounds ?? current.rounds, rounds: partial.rounds ?? current.rounds,
}) })
}, },

View file

@ -29,6 +29,8 @@ export interface Player {
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */ /** Configuration d'une épreuve planifiée dans la séquence de la partie. */
export interface RoundConfig { export interface RoundConfig {
type: RoundType type: RoundType
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique" ou images). */
pool?: "quiz" | "image"
} }
/** Réglages de la room, modifiables dans le lobby par l'hôte. */ /** Réglages de la room, modifiables dans le lobby par l'hôte. */
@ -42,6 +44,8 @@ export interface RoomSettings {
roundDuration: number roundDuration: number
/** Nombre de titres que chaque joueur soumet (blindtest). */ /** Nombre de titres que chaque joueur soumet (blindtest). */
tracksPerPlayer: number tracksPerPlayer: number
/** Catégories de quiz autorisées (vide = toutes). */
categories: string[]
/** Séquence d'épreuves de la partie. */ /** Séquence d'épreuves de la partie. */
rounds: RoundConfig[] rounds: RoundConfig[]
} }
@ -53,6 +57,7 @@ export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
blindtestMode: "title_artist", blindtestMode: "title_artist",
roundDuration: 60, roundDuration: 60,
tracksPerPlayer: 2, tracksPerPlayer: 2,
categories: [],
rounds: [], rounds: [],
} }

View file

@ -40,6 +40,7 @@ export interface UpdateSettingsPayload {
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
roundDuration: number roundDuration: number
tracksPerPlayer: number tracksPerPlayer: number
categories: string[]
rounds: RoundConfig[] rounds: RoundConfig[]
} }