From 62b536cc2b0546dc4765a84589c1c858e4f83c24 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 23:31:30 +0200 Subject: [PATCH] feat: "Images" as a distinct mode, refined lobby settings, upload button, app title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image_reveal becomes its own game mode: GameType gains "image"; the quiz pool is filtered by allowed formats derived from gameType (image → image_reveal only, quiz → mcq/truefalse/free, mixed → all). loadQuizPool takes a formats list; pool fallback respects it. - lobby settings reworked into a refined "Réglages" card: 4 mode tiles (icon + label, locked <3 players for blindtest/mixed), question count as a stepper, blindtest config grouped; dynamic label for image mode + hint. - admin: real upload button (hidden input + styled Button with Upload icon) instead of the bare file input. - app title set to "NerdWare …" in index.html (+ lang fr, description). Verified e2e: image mode serves only image_reveal questions (with imageUrl). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/db/quiz-repo.ts | 7 +- apps/server/src/game/modes/quiz/pool.ts | 34 ++++- apps/web/index.html | 8 +- apps/web/src/components/lobby-view.tsx | 192 +++++++++++++----------- apps/web/src/pages/admin.tsx | 24 ++- packages/shared/src/domain.ts | 2 +- 6 files changed, 164 insertions(+), 103 deletions(-) diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index 2bdb35a..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 @@ -37,7 +38,7 @@ export async function loadQuizPool( .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .where( and( - inArray(quizQuestion.format, ["mcq", "truefalse", "free", "image_reveal"]), + inArray(quizQuestion.format, formats), notInArray(quizQuestion.id, alreadyPlayed) ) ) diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts index 5a065fc..569e2a9 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 { GameType, QuizFormat } from "@nerdware/shared" import { hasDb } from "../../../db" import { loadQuizPool } from "../../../db/quiz-repo" import type { ServerRoom } from "../../../rooms" @@ -10,10 +12,23 @@ interface RoomPool { queue: QuizQuestion[] /** true si les questions viennent de la DB (→ marquer jouées). */ fromDb: boolean + formats: QuizFormat[] } const poolByRoom = new WeakMap() +/** Formats de quiz autorisés selon le type de partie. */ +function quizFormatsFor(gameType: GameType): QuizFormat[] { + if (gameType === "image") { + return ["image_reveal"] + } + if (gameType === "quiz") { + return ["mcq", "truefalse", "free"] + } + // mixte : tout (le blindtest n'a pas de manche quiz) + return ["mcq", "truefalse", "free", "image_reveal"] +} + function shuffle(items: T[]): T[] { const arr = [...items] for (let i = arr.length - 1; i > 0; i--) { @@ -25,13 +40,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.gameType) 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 +55,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 +71,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/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/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 360c277..272f666 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, @@ -22,12 +23,17 @@ 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: true }, + { 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 +106,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") { @@ -132,14 +138,16 @@ 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" + // Si le mode choisi a besoin du blindtest mais qu'on est <3, on retombe sur + // du quiz (sans toucher au réglage stocké) : se rétablit à 3 joueurs. + const needsBlindtest = gameType === "blindtest" || gameType === "mixed" + const effectiveType: GameType = + needsBlindtest && !blindtestAvailable ? "quiz" : gameType + const showQuestions = effectiveType !== "blindtest" const showBlindtest = effectiveType === "blindtest" || effectiveType === "mixed" - const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) + const rounds = buildRounds(effectiveType, showQuestions ? count : 0, totalTracks) // Blindtest : tout le monde doit avoir soumis son quota de titres. const allSubmitted = !showBlindtest || @@ -190,82 +198,98 @@ 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 && ( +

+ Blindtest & Mixte se débloquent à 3 joueurs. +

+ )}
-
- - Titres par joueur - - updateSettings({ tracksPerPlayer: v })} - /> -
-
+ + {/* 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/pages/admin.tsx b/apps/web/src/pages/admin.tsx index b70ce0f..1274411 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -1,7 +1,7 @@ -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 { @@ -197,6 +197,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { 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), @@ -338,14 +339,25 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { {format === "image_reveal" && (
onPickImage(e.target.files?.[0])} /> - {uploading && ( -

Upload…

- )} + {uploadError && (

{uploadError}

)} diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index d7ee165..9b84a73 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -8,7 +8,7 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended" export type RoundType = "blindtest" | "quiz" /** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */ -export type GameType = "mixed" | "quiz" | "blindtest" +export type GameType = "mixed" | "quiz" | "image" | "blindtest" /** Modes de blindtest, déterminent la forme du vote et le barème. */ export type BlindtestMode = "title_artist" | "who_added" | "mixed"