feat: bigger image_reveal, optional image prompt, mixed sub-mode toggles, Pokémon seed

- image_reveal display: larger, object-contain (whole image up to 58vh), no crop
- image prompt optional in back-office (defaults to "Qui est-ce ?"), overridable
- mixed mode reworked: select "Mixte" then toggle sub-modes (Quiz/Images/
  Blindtest). Blindtest sub-mode locked under 3 players; mixed itself no longer
  needs 3. Server quiz pool formats derived from gameType + mixedModes.
  shared: MixMode + RoomSettings.mixedModes (default quiz+image).
- DB: prompt uniqueness is now a partial index (excludes image_reveal) + unique
  index on image_url; lets many images share the generic prompt, dedup by URL.
- seed: db:seed:images pulls Pokémon (PokéAPI) as image_reveal questions with
  official artwork (remote URL) + FR/EN accepted answers; idempotent by image_url.

Verified: migration applied, Pokémon seed inserts FR+EN, image mode serves only
image_reveal, all checks green (23 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-11 00:20:28 +02:00
parent 9c1413744d
commit 8bc184fab7
15 changed files with 535 additions and 49 deletions

View file

@ -0,0 +1,3 @@
ALTER TABLE "quiz_question" DROP CONSTRAINT "quiz_question_prompt_unique";--> statement-breakpoint
CREATE UNIQUE INDEX "quiz_question_prompt_unique" ON "quiz_question" USING btree ("prompt") WHERE "quiz_question"."format" <> 'image_reveal';--> statement-breakpoint
CREATE UNIQUE INDEX "quiz_question_image_unique" ON "quiz_question" USING btree ("image_url") WHERE "quiz_question"."image_url" is not null;

View file

@ -0,0 +1,288 @@
{
"id": "6de8c2ee-3634-455b-ba4b-372368fc2e66",
"prevId": "c68e2f59-3d1b-4fca-8443-17fe243e2919",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"columns": [
{
"expression": "prompt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"format\" <> 'image_reveal'",
"concurrently": false,
"method": "btree",
"with": {}
},
"quiz_question_image_unique": {
"name": "quiz_question_image_unique",
"columns": [
{
"expression": "image_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"image_url\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1781085893179, "when": 1781085893179,
"tag": "0000_loose_doctor_spectrum", "tag": "0000_loose_doctor_spectrum",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781129455626,
"tag": "0001_fancy_captain_stacy",
"breakpoints": true
} }
] ]
} }

View file

@ -14,6 +14,7 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "bun src/db/migrate.ts", "db:migrate": "bun src/db/migrate.ts",
"db:seed": "bun src/db/seed.ts", "db:seed": "bun src/db/seed.ts",
"db:seed:images": "bun src/db/seed-images.ts",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {

View file

@ -34,7 +34,10 @@ function validate(
if (!FORMATS.includes(format)) { if (!FORMATS.includes(format)) {
return { error: "Format invalide." } return { error: "Format invalide." }
} }
const prompt = (body.prompt ?? "").trim() // L'intitulé est optionnel pour image_reveal (défaut générique).
const prompt =
(body.prompt ?? "").trim() ||
(format === "image_reveal" ? "Qui est-ce ?" : "")
if (prompt.length === 0) { if (prompt.length === 0) {
return { error: "L'intitulé est obligatoire." } return { error: "L'intitulé est obligatoire." }
} }

View file

@ -9,6 +9,7 @@ import {
primaryKey, primaryKey,
text, text,
timestamp, timestamp,
uniqueIndex,
uuid, uuid,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
@ -20,26 +21,38 @@ export const quizCategory = pgTable("quiz_category", {
.default(sql`now()`), .default(sql`now()`),
}) })
export const quizQuestion = pgTable("quiz_question", { export const quizQuestion = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "quiz_question",
categoryId: uuid("category_id").references(() => quizCategory.id), {
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */ id: uuid("id").primaryKey().defaultRandom(),
format: text("format").notNull(), categoryId: uuid("category_id").references(() => quizCategory.id),
/** Unique → seed idempotent (onConflictDoNothing). */ /** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
prompt: text("prompt").notNull().unique(), format: text("format").notNull(),
/** 1 (facile) .. 3 (difficile). */ prompt: text("prompt").notNull(),
difficulty: integer("difficulty").notNull().default(1), /** 1 (facile) .. 3 (difficile). */
/** 'opentdb' | 'manual' */ difficulty: integer("difficulty").notNull().default(1),
source: text("source").notNull(), /** 'opentdb' | 'manual' */
// Champs selon le format (NULL si non applicable) : source: text("source").notNull(),
choices: jsonb("choices").$type<string[]>(), // Champs selon le format (NULL si non applicable) :
correctIndex: integer("correct_index"), choices: jsonb("choices").$type<string[]>(),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(), correctIndex: integer("correct_index"),
imageUrl: text("image_url"), acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
createdAt: timestamp("created_at", { withTimezone: true }) imageUrl: text("image_url"),
.notNull() createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`now()`), .notNull()
}) .default(sql`now()`),
},
// Unique sur l'intitulé SAUF pour les images (même intitulé "Qui est-ce ?"
// pour des images différentes). Les images se dédupliquent par image_url.
(t) => [
uniqueIndex("quiz_question_prompt_unique")
.on(t.prompt)
.where(sql`${t.format} <> 'image_reveal'`),
uniqueIndex("quiz_question_image_unique")
.on(t.imageUrl)
.where(sql`${t.imageUrl} is not null`),
]
)
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */ /** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
export const quizPlayed = pgTable( export const quizPlayed = pgTable(

View file

@ -0,0 +1,94 @@
// Seed du mode Images depuis PokéAPI : "devine le Pokémon" à partir de
// l'artwork officiel (URL distante, pas d'upload). Réponses acceptées FR + EN.
// bun run db:seed:images (gen 1, 151 Pokémon)
// IMAGE_SEED_COUNT=50 bun run db:seed:images
// Idempotent : déduplication par image_url (onConflictDoNothing).
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../env"
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — impossible de seeder.")
process.exit(1)
}
const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
const CATEGORY = "Pokémon"
const PROMPT = "Quel est ce Pokémon ?"
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
async function upsertCategory(name: string): Promise<string> {
await db.insert(quizCategory).values({ name }).onConflictDoNothing()
const [row] = await db
.select({ id: quizCategory.id })
.from(quizCategory)
.where(eq(quizCategory.name, name))
return row.id
}
interface Pokemon {
name: string
sprites: { other?: { ["official-artwork"]?: { front_default: string | null } } }
}
interface Species {
names: { name: string; language: { name: string } }[]
}
async function fetchPokemon(id: number): Promise<NewQuizQuestion | null> {
try {
const [pRes, sRes] = await Promise.all([
fetch(`https://pokeapi.co/api/v2/pokemon/${id}`),
fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`),
])
if (!pRes.ok || !sRes.ok) {
return null
}
const p = (await pRes.json()) as Pokemon
const s = (await sRes.json()) as Species
const art = p.sprites.other?.["official-artwork"]?.front_default
if (!art) {
return null
}
const fr = s.names.find((n) => n.language.name === "fr")?.name
const en = s.names.find((n) => n.language.name === "en")?.name ?? p.name
const answers = [...new Set([fr, en].filter(Boolean) as string[])]
return {
categoryId: "", // rempli plus bas
format: "image_reveal",
prompt: PROMPT,
difficulty: 2,
source: "pokeapi",
acceptedAnswers: answers,
imageUrl: art,
}
} catch {
return null
}
}
console.log(`Seed Images depuis PokéAPI (${COUNT} Pokémon)…`)
const categoryId = await upsertCategory(CATEGORY)
let inserted = 0
for (let id = 1; id <= COUNT; id++) {
const q = await fetchPokemon(id)
if (q) {
const res = await db
.insert(quizQuestion)
.values({ ...q, categoryId })
.onConflictDoNothing()
.returning({ id: quizQuestion.id })
inserted += res.length
}
if (id % 25 === 0) {
console.log(` ${id}/${COUNT}`)
}
await sleep(40) // courtoisie envers l'API
}
console.log(`Terminé : ${inserted} Pokémon ajoutés (mode Images).`)
await client.end()

View file

@ -2,7 +2,7 @@
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche. // 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). // Les formats servis dépendent du type de partie (quiz / images / mixte).
import type { GameType, QuizFormat } from "@nerdware/shared" import type { QuizFormat, RoomSettings } 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"
@ -17,16 +17,25 @@ interface RoomPool {
const poolByRoom = new WeakMap<ServerRoom, RoomPool>() const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
/** Formats de quiz autorisés selon le type de partie. */ const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
function quizFormatsFor(gameType: GameType): QuizFormat[] {
if (gameType === "image") { /** 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"] return ["image_reveal"]
} }
if (gameType === "quiz") { if (settings.gameType === "mixed") {
return ["mcq", "truefalse", "free"] 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
} }
// mixte : tout (le blindtest n'a pas de manche quiz) // quiz, ou blindtest retombé sur du quiz (<3 joueurs)
return ["mcq", "truefalse", "free", "image_reveal"] return QUIZ_FORMATS
} }
function shuffle<T>(items: T[]): T[] { function shuffle<T>(items: T[]): T[] {
@ -40,7 +49,7 @@ function shuffle<T>(items: T[]): T[] {
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ /** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> { export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
const formats = quizFormatsFor(room.settings.gameType) const formats = quizFormatsFor(room.settings)
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
let queue: QuizQuestion[] = [] let queue: QuizQuestion[] = []
let fromDb = false let fromDb = false

View file

@ -106,6 +106,7 @@ export function registerRoomHandlers(
} }
room.settings = { room.settings = {
gameType: payload.gameType, gameType: payload.gameType,
mixedModes: payload.mixedModes,
blindtestMode: payload.blindtestMode, blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration, roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer, tracksPerPlayer: payload.tracksPerPlayer,

View file

@ -16,9 +16,16 @@ import { SiYoutube } from "@icons-pack/react-simple-icons"
import type { import type {
BlindtestMode, BlindtestMode,
GameType, GameType,
MixMode,
RoomSnapshot, RoomSnapshot,
RoundConfig, RoundConfig,
} from "@nerdware/shared" } from "@nerdware/shared"
const MIX_LABELS: Record<MixMode, string> = {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
}
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
@ -30,7 +37,7 @@ const GAME_TYPES: {
Icon: LucideIcon Icon: LucideIcon
needsThree: boolean needsThree: boolean
}[] = [ }[] = [
{ value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: true }, { value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: false },
{ value: "quiz", label: "Quiz", Icon: Brain, needsThree: false }, { value: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false }, { value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
{ value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true }, { value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
@ -125,7 +132,8 @@ 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, blindtestMode, tracksPerPlayer } = snapshot.settings const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
snapshot.settings
const [count, setCount] = useState(5) const [count, setCount] = useState(5)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -138,16 +146,27 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs). // Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
const connectedCount = snapshot.players.filter((p) => p.connected).length const connectedCount = snapshot.players.filter((p) => p.connected).length
const blindtestAvailable = connectedCount >= 3 const blindtestAvailable = connectedCount >= 3
// Si le mode choisi a besoin du blindtest mais qu'on est <3, on retombe sur // Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
// du quiz (sans toucher au réglage stocké) : se rétablit à 3 joueurs. // (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
const needsBlindtest = gameType === "blindtest" || gameType === "mixed"
const effectiveType: GameType = const effectiveType: GameType =
needsBlindtest && !blindtestAvailable ? "quiz" : gameType gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
const showQuestions = effectiveType !== "blindtest" const isMixed = effectiveType === "mixed"
const showBlindtest =
effectiveType === "blindtest" || effectiveType === "mixed"
const rounds = buildRounds(effectiveType, showQuestions ? count : 0, totalTracks) // Sous-modes effectivement actifs.
const wantsQuestions =
effectiveType === "quiz" ||
effectiveType === "image" ||
(isMixed && (mixedModes.includes("quiz") || mixedModes.includes("image")))
const showQuestions = wantsQuestions
const showBlindtest =
effectiveType === "blindtest" ||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
const rounds = buildRounds(
effectiveType,
showQuestions ? count : 0,
showBlindtest ? totalTracks : 0
)
// 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 ||
@ -155,6 +174,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
snapshot.submissions.every((s) => s.count >= tracksPerPlayer)) snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
const canStart = rounds.length > 0 && allSubmitted const canStart = rounds.length > 0 && allSubmitted
function toggleMix(mode: MixMode) {
const set = new Set(mixedModes)
if (set.has(mode)) {
set.delete(mode)
} else {
set.add(mode)
}
updateSettings({ mixedModes: [...set] })
}
async function start() { async function start() {
setError(null) setError(null)
setBusy(true) setBusy(true)
@ -230,11 +259,39 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div> </div>
{!blindtestAvailable && ( {!blindtestAvailable && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Blindtest & Mixte se débloquent à 3 joueurs. Le blindtest se débloque à 3 joueurs.
</p> </p>
)} )}
</div> </div>
{/* Sous-modes du mixte (toggles) */}
{isMixed && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Sous-modes inclus</span>
<div className="flex gap-2">
{(["quiz", "image", "blindtest"] as const).map((m) => {
const locked = m === "blindtest" && !blindtestAvailable
const on = mixedModes.includes(m) && !locked
return (
<button
key={m}
disabled={locked}
title={locked ? "3 joueurs minimum" : undefined}
onClick={() => toggleMix(m)}
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
on
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{MIX_LABELS[m]}
</button>
)
})}
</div>
</div>
)}
{/* Nombre de questions (quiz / images / mixte) */} {/* Nombre de questions (quiz / images / mixte) */}
{showQuestions && ( {showQuestions && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View file

@ -179,15 +179,12 @@ function ImageReveal({
const displayBlur = revealed ? 0 : blur const displayBlur = revealed ? 0 : blur
return ( return (
<div className="bg-card relative mx-auto aspect-video w-full overflow-hidden rounded-2xl"> <div className="bg-card relative flex w-full justify-center overflow-hidden rounded-2xl p-2">
<img <img
src={src} src={src}
alt="" alt=""
className="size-full object-cover transition-[filter] duration-300" className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
style={{ style={{ filter: `blur(${displayBlur}px)` }}
filter: `blur(${displayBlur}px)`,
transform: "scale(1.1)", // évite les bords flous
}}
/> />
</div> </div>
) )

View file

@ -287,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
/> />
<input <input
className={inputClass} className={inputClass}
placeholder="Intitulé de la question" placeholder={
format === "image_reveal"
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
: "Intitulé de la question"
}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
/> />
@ -388,7 +392,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
disabled={ disabled={
create.isPending || create.isPending ||
uploading || uploading ||
!prompt.trim() || (!prompt.trim() && format !== "image_reveal") ||
!category.trim() || !category.trim() ||
(format === "image_reveal" && !imageUrl) (format === "image_reveal" && !imageUrl)
} }

View file

@ -139,6 +139,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
socket.emit("lobby:updateSettings", { socket.emit("lobby:updateSettings", {
gameType: partial.gameType ?? current.gameType, gameType: partial.gameType ?? current.gameType,
mixedModes: partial.mixedModes ?? current.mixedModes,
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,

View file

@ -10,6 +10,9 @@ export type RoundType = "blindtest" | "quiz"
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */ /** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
export type GameType = "mixed" | "quiz" | "image" | "blindtest" export type GameType = "mixed" | "quiz" | "image" | "blindtest"
/** Sous-modes activables quand la partie est "mixed". */
export type MixMode = "quiz" | "image" | "blindtest"
/** Modes de blindtest, déterminent la forme du vote et le barème. */ /** Modes de blindtest, déterminent la forme du vote et le barème. */
export type BlindtestMode = "title_artist" | "who_added" | "mixed" export type BlindtestMode = "title_artist" | "who_added" | "mixed"
@ -32,6 +35,8 @@ export interface RoundConfig {
export interface RoomSettings { export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */ /** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: GameType gameType: GameType
/** Sous-modes actifs quand gameType === "mixed". */
mixedModes: MixMode[]
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
/** Durée d'une manche en secondes (def. 60). */ /** Durée d'une manche en secondes (def. 60). */
roundDuration: number roundDuration: number
@ -44,6 +49,7 @@ export interface RoomSettings {
/** Réglages par défaut d'une nouvelle room. */ /** Réglages par défaut d'une nouvelle room. */
export const DEFAULT_ROOM_SETTINGS: RoomSettings = { export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
gameType: "mixed", gameType: "mixed",
mixedModes: ["quiz", "image"],
blindtestMode: "title_artist", blindtestMode: "title_artist",
roundDuration: 60, roundDuration: 60,
tracksPerPlayer: 2, tracksPerPlayer: 2,

View file

@ -5,6 +5,7 @@ import type {
Answer, Answer,
BlindtestMode, BlindtestMode,
GameType, GameType,
MixMode,
PlayerScore, PlayerScore,
RoomSnapshot, RoomSnapshot,
RoundConfig, RoundConfig,
@ -34,6 +35,7 @@ export interface SetNamePayload {
export interface UpdateSettingsPayload { export interface UpdateSettingsPayload {
gameType: GameType gameType: GameType
mixedModes: MixMode[]
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
roundDuration: number roundDuration: number
tracksPerPlayer: number tracksPerPlayer: number