feature/image-reveal #10

Merged
ayoub merged 5 commits from feature/image-reveal into dev 2026-06-10 22:38:34 +00:00
15 changed files with 535 additions and 49 deletions
Showing only changes of commit 8bc184fab7 - Show all commits

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,
"tag": "0000_loose_doctor_spectrum",
"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:migrate": "bun src/db/migrate.ts",
"db:seed": "bun src/db/seed.ts",
"db:seed:images": "bun src/db/seed-images.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {

View file

@ -34,7 +34,10 @@ function validate(
if (!FORMATS.includes(format)) {
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) {
return { error: "L'intitulé est obligatoire." }
}

View file

@ -9,6 +9,7 @@ import {
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core"
@ -20,26 +21,38 @@ export const quizCategory = pgTable("quiz_category", {
.default(sql`now()`),
})
export const quizQuestion = pgTable("quiz_question", {
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id").references(() => quizCategory.id),
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
format: text("format").notNull(),
/** Unique → seed idempotent (onConflictDoNothing). */
prompt: text("prompt").notNull().unique(),
/** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' */
source: text("source").notNull(),
// Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
})
export const quizQuestion = pgTable(
"quiz_question",
{
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id").references(() => quizCategory.id),
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
format: text("format").notNull(),
prompt: text("prompt").notNull(),
/** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' */
source: text("source").notNull(),
// Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.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é. */
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.
// 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 { loadQuizPool } from "../../../db/quiz-repo"
import type { ServerRoom } from "../../../rooms"
@ -17,16 +17,25 @@ interface RoomPool {
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
/** Formats de quiz autorisés selon le type de partie. */
function quizFormatsFor(gameType: GameType): QuizFormat[] {
if (gameType === "image") {
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
/** 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 (gameType === "quiz") {
return ["mcq", "truefalse", "free"]
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
}
// mixte : tout (le blindtest n'a pas de manche quiz)
return ["mcq", "truefalse", "free", "image_reveal"]
// quiz, ou blindtest retombé sur du quiz (<3 joueurs)
return QUIZ_FORMATS
}
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. */
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
let queue: QuizQuestion[] = []
let fromDb = false

View file

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

View file

@ -16,9 +16,16 @@ import { SiYoutube } from "@icons-pack/react-simple-icons"
import type {
BlindtestMode,
GameType,
MixMode,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
const MIX_LABELS: Record<MixMode, string> = {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
}
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
@ -30,7 +37,7 @@ const GAME_TYPES: {
Icon: LucideIcon
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: "image", label: "Images", Icon: ImageIcon, needsThree: false },
{ 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 startGame = useRoomStore((s) => s.startGame)
const isHost = snapshot.hostId === playerId
const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
snapshot.settings
const [count, setCount] = useState(5)
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).
const connectedCount = snapshot.players.filter((p) => p.connected).length
const blindtestAvailable = connectedCount >= 3
// 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"
// Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
const effectiveType: GameType =
needsBlindtest && !blindtestAvailable ? "quiz" : gameType
const showQuestions = effectiveType !== "blindtest"
const showBlindtest =
effectiveType === "blindtest" || effectiveType === "mixed"
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
const isMixed = 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.
const allSubmitted =
!showBlindtest ||
@ -155,6 +174,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
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() {
setError(null)
setBusy(true)
@ -230,11 +259,39 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-xs">
Blindtest & Mixte se débloquent à 3 joueurs.
Le blindtest se débloque à 3 joueurs.
</p>
)}
</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) */}
{showQuestions && (
<div className="flex flex-col gap-1">

View file

@ -179,15 +179,12 @@ function ImageReveal({
const displayBlur = revealed ? 0 : blur
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
src={src}
alt=""
className="size-full object-cover transition-[filter] duration-300"
style={{
filter: `blur(${displayBlur}px)`,
transform: "scale(1.1)", // évite les bords flous
}}
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
style={{ filter: `blur(${displayBlur}px)` }}
/>
</div>
)

View file

@ -287,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
/>
<input
className={inputClass}
placeholder="Intitulé de la question"
placeholder={
format === "image_reveal"
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
: "Intitulé de la question"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
@ -388,7 +392,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
disabled={
create.isPending ||
uploading ||
!prompt.trim() ||
(!prompt.trim() && format !== "image_reveal") ||
!category.trim() ||
(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
socket.emit("lobby:updateSettings", {
gameType: partial.gameType ?? current.gameType,
mixedModes: partial.mixedModes ?? current.mixedModes,
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
roundDuration: partial.roundDuration ?? current.roundDuration,
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. */
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. */
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
@ -32,6 +35,8 @@ export interface RoundConfig {
export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: GameType
/** Sous-modes actifs quand gameType === "mixed". */
mixedModes: MixMode[]
blindtestMode: BlindtestMode
/** Durée d'une manche en secondes (def. 60). */
roundDuration: number
@ -44,6 +49,7 @@ export interface RoomSettings {
/** Réglages par défaut d'une nouvelle room. */
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
gameType: "mixed",
mixedModes: ["quiz", "image"],
blindtestMode: "title_artist",
roundDuration: 60,
tracksPerPlayer: 2,

View file

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