release/0.1.0 #18
29 changed files with 1000 additions and 183 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -32,3 +32,6 @@ dist-ssr
|
|||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# uploads (image_reveal) servies en statique
|
||||
uploads/
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@ CORS_ORIGINS=http://localhost:5173
|
|||
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
|
||||
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
|
||||
ADMIN_TOKEN=change-me
|
||||
# Dossier de stockage des images (image_reveal), servi sur /uploads.
|
||||
UPLOADS_DIR=./uploads
|
||||
|
|
|
|||
3
apps/server/drizzle/0001_fancy_captain_stacy.sql
Normal file
3
apps/server/drizzle/0001_fancy_captain_stacy.sql
Normal 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;
|
||||
288
apps/server/drizzle/meta/0001_snapshot.json
Normal file
288
apps/server/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -14,10 +14,13 @@
|
|||
"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": {
|
||||
"@fastify/cors": "^11",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@nerdware/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45",
|
||||
"fastify": "^5",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
|
||||
// CRUD des questions manuelles écrites en base (source 'manual').
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import { extname, resolve } from "node:path"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { env } from "../env"
|
||||
import { db } from "../db"
|
||||
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
||||
|
||||
const FORMATS = ["mcq", "truefalse", "free"] as const
|
||||
const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const
|
||||
type AdminFormat = (typeof FORMATS)[number]
|
||||
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
||||
|
||||
interface CreateBody {
|
||||
format?: string
|
||||
|
|
@ -18,6 +22,7 @@ interface CreateBody {
|
|||
choices?: string[]
|
||||
correctIndex?: number
|
||||
acceptedAnswers?: string[]
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */
|
||||
|
|
@ -29,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." }
|
||||
}
|
||||
|
|
@ -40,14 +48,23 @@ function validate(
|
|||
|
||||
const base = { categoryId, format, prompt, difficulty, source: "manual" }
|
||||
|
||||
if (format === "free") {
|
||||
if (format === "free" || format === "image_reveal") {
|
||||
const answers = (body.acceptedAnswers ?? [])
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
if (answers.length === 0) {
|
||||
return { error: "Au moins une réponse acceptée est requise." }
|
||||
}
|
||||
return { value: { ...base, acceptedAnswers: answers } }
|
||||
if (format === "image_reveal" && !body.imageUrl) {
|
||||
return { error: "Une image est requise." }
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
...base,
|
||||
acceptedAnswers: answers,
|
||||
imageUrl: format === "image_reveal" ? body.imageUrl : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mcq / truefalse
|
||||
|
|
@ -83,6 +100,24 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||
}
|
||||
})
|
||||
|
||||
app.post("/upload", async (req, reply) => {
|
||||
const file = await req.file()
|
||||
if (!file) {
|
||||
return reply.code(400).send({ error: "Aucun fichier reçu." })
|
||||
}
|
||||
if (!file.mimetype.startsWith("image/")) {
|
||||
return reply.code(400).send({ error: "Le fichier doit être une image." })
|
||||
}
|
||||
const ext = extname(file.filename ?? "").toLowerCase()
|
||||
const safeExt = IMAGE_EXT.includes(ext) ? ext : ".jpg"
|
||||
const name = `${randomUUID()}${safeExt}`
|
||||
const buffer = await file.toBuffer()
|
||||
// Crée le dossier si besoin (résilient s'il a été supprimé après le démarrage).
|
||||
await mkdir(resolve(env.uploadsDir), { recursive: true })
|
||||
await writeFile(resolve(env.uploadsDir, name), buffer)
|
||||
return { url: `/uploads/${name}` }
|
||||
})
|
||||
|
||||
app.get("/categories", async () => {
|
||||
const rows = await db!
|
||||
.select({ id: quizCategory.id, name: quizCategory.name })
|
||||
|
|
@ -102,6 +137,7 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||
choices: quizQuestion.choices,
|
||||
correctIndex: quizQuestion.correctIndex,
|
||||
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||
imageUrl: quizQuestion.imageUrl,
|
||||
category: quizCategory.name,
|
||||
})
|
||||
.from(quizQuestion)
|
||||
|
|
|
|||
|
|
@ -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<QuizQuestion[]> {
|
||||
if (!db) {
|
||||
if (!db || formats.length === 0) {
|
||||
return []
|
||||
}
|
||||
const alreadyPlayed = db
|
||||
|
|
@ -29,6 +30,7 @@ export async function loadQuizPool(
|
|||
choices: quizQuestion.choices,
|
||||
correctIndex: quizQuestion.correctIndex,
|
||||
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||
imageUrl: quizQuestion.imageUrl,
|
||||
difficulty: quizQuestion.difficulty,
|
||||
category: quizCategory.name,
|
||||
})
|
||||
|
|
@ -36,7 +38,7 @@ export async function loadQuizPool(
|
|||
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
|
||||
inArray(quizQuestion.format, formats),
|
||||
notInArray(quizQuestion.id, alreadyPlayed)
|
||||
)
|
||||
)
|
||||
|
|
@ -44,11 +46,15 @@ export async function loadQuizPool(
|
|||
.limit(limit)
|
||||
|
||||
return rows
|
||||
.filter((r) =>
|
||||
r.format === "free"
|
||||
? (r.acceptedAnswers?.length ?? 0) > 0
|
||||
: r.choices && r.correctIndex !== null
|
||||
)
|
||||
.filter((r) => {
|
||||
if (r.format === "free") {
|
||||
return (r.acceptedAnswers?.length ?? 0) > 0
|
||||
}
|
||||
if (r.format === "image_reveal") {
|
||||
return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0
|
||||
}
|
||||
return r.choices && r.correctIndex !== null
|
||||
})
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
format: r.format as QuizQuestion["format"],
|
||||
|
|
@ -56,6 +62,7 @@ export async function loadQuizPool(
|
|||
choices: r.choices ?? undefined,
|
||||
correctIndex: r.correctIndex ?? undefined,
|
||||
acceptedAnswers: r.acceptedAnswers ?? undefined,
|
||||
imageUrl: r.imageUrl ?? undefined,
|
||||
category: r.category ?? "Quiz",
|
||||
difficulty: r.difficulty,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
|
|
@ -20,13 +21,14 @@ export const quizCategory = pgTable("quiz_category", {
|
|||
.default(sql`now()`),
|
||||
})
|
||||
|
||||
export const quizQuestion = pgTable("quiz_question", {
|
||||
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(),
|
||||
prompt: text("prompt").notNull(),
|
||||
/** 1 (facile) .. 3 (difficile). */
|
||||
difficulty: integer("difficulty").notNull().default(1),
|
||||
/** 'opentdb' | 'manual' */
|
||||
|
|
@ -39,7 +41,18 @@ export const quizQuestion = pgTable("quiz_question", {
|
|||
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(
|
||||
|
|
|
|||
94
apps/server/src/db/seed-images.ts
Normal file
94
apps/server/src/db/seed-images.ts
Normal 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()
|
||||
|
|
@ -14,6 +14,8 @@ export const env = {
|
|||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
/** Jeton du back-office quiz. Vide → back-office désactivé. */
|
||||
adminToken: process.env.ADMIN_TOKEN ?? "",
|
||||
/** Dossier de stockage des images (image_reveal), servi en statique sur /uploads. */
|
||||
uploadsDir: process.env.UPLOADS_DIR ?? "./uploads",
|
||||
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
|
||||
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
|
||||
.split(",")
|
||||
|
|
|
|||
|
|
@ -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 { QuizFormat, RoomSettings } from "@nerdware/shared"
|
||||
import { hasDb } from "../../../db"
|
||||
import { loadQuizPool } from "../../../db/quiz-repo"
|
||||
import type { ServerRoom } from "../../../rooms"
|
||||
|
|
@ -10,10 +12,32 @@ interface RoomPool {
|
|||
queue: QuizQuestion[]
|
||||
/** true si les questions viennent de la DB (→ marquer jouées). */
|
||||
fromDb: boolean
|
||||
formats: QuizFormat[]
|
||||
}
|
||||
|
||||
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
|
||||
|
||||
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 (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[] {
|
||||
const arr = [...items]
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
|
|
@ -25,13 +49,14 @@ 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)
|
||||
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 +64,14 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
|||
}
|
||||
|
||||
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 +80,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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ export interface QuizQuestion {
|
|||
choices?: string[]
|
||||
/** mcq/truefalse uniquement. */
|
||||
correctIndex?: number
|
||||
/** free : réponses acceptées (matching tolérant). */
|
||||
/** free / image_reveal : réponses acceptées (matching tolérant). */
|
||||
acceptedAnswers?: string[]
|
||||
/** image_reveal : image à dévoiler (chemin relatif au serveur). */
|
||||
imageUrl?: string
|
||||
category: string
|
||||
difficulty: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,12 +36,17 @@ function asText(answer: Answer): string | null {
|
|||
return typeof v === "string" ? v : null
|
||||
}
|
||||
|
||||
/** Formats à réponse libre (saisie texte + matching tolérant). */
|
||||
function isTextFormat(question: QuizQuestion): boolean {
|
||||
return question.format === "free" || question.format === "image_reveal"
|
||||
}
|
||||
|
||||
/** Réponse correcte ? Selon le format de la question. */
|
||||
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
|
||||
if (!answer) {
|
||||
return false
|
||||
}
|
||||
if (question.format === "free") {
|
||||
if (isTextFormat(question)) {
|
||||
const text = asText(answer)
|
||||
if (!text) {
|
||||
return false
|
||||
|
|
@ -68,9 +73,12 @@ export class QuizRound implements GameRound {
|
|||
category: question.category,
|
||||
difficulty: question.difficulty,
|
||||
}
|
||||
if (question.format !== "free") {
|
||||
if (question.format === "mcq" || question.format === "truefalse") {
|
||||
payload.choices = question.choices
|
||||
}
|
||||
if (question.format === "image_reveal") {
|
||||
payload.imageUrl = question.imageUrl
|
||||
}
|
||||
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||
return { djId: null, payload, data }
|
||||
}
|
||||
|
|
@ -81,7 +89,7 @@ export class QuizRound implements GameRound {
|
|||
return
|
||||
}
|
||||
const { question, votedAt } = ctx.data as QuizRoundData
|
||||
if (question.format === "free") {
|
||||
if (isTextFormat(question)) {
|
||||
const text = asText(answer)
|
||||
if (text === null || text.trim().length === 0) {
|
||||
return
|
||||
|
|
@ -111,8 +119,7 @@ export class QuizRound implements GameRound {
|
|||
correct: isCorrect(question, vote),
|
||||
}
|
||||
}
|
||||
const truth: QuizRevealTruth =
|
||||
question.format === "free"
|
||||
const truth: QuizRevealTruth = isTextFormat(question)
|
||||
? { answer: question.acceptedAnswers?.[0] ?? "" }
|
||||
: { correctIndex: question.correctIndex }
|
||||
return { truth, perPlayerResult }
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export function registerRoomHandlers(
|
|||
}
|
||||
room.settings = {
|
||||
gameType: payload.gameType,
|
||||
mixedModes: payload.mixedModes,
|
||||
blindtestMode: payload.blindtestMode,
|
||||
roundDuration: payload.roundDuration,
|
||||
tracksPerPlayer: payload.tracksPerPlayer,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { mkdirSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import Fastify from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import multipart from "@fastify/multipart"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import { env, isDev } from "./env"
|
||||
import { createSocketServer } from "./socket"
|
||||
import { adminRoutes } from "./admin/routes"
|
||||
|
|
@ -13,6 +17,12 @@ const app = Fastify({
|
|||
|
||||
await app.register(cors, { origin: env.corsOrigins })
|
||||
|
||||
// Images (image_reveal) : dossier servi en statique sur /uploads.
|
||||
const uploadsRoot = resolve(env.uploadsDir)
|
||||
mkdirSync(uploadsRoot, { recursive: true })
|
||||
await app.register(multipart, { limits: { fileSize: 8 * 1024 * 1024 } })
|
||||
await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
|
||||
|
||||
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
||||
|
||||
await app.register(adminRoutes, { prefix: "/api/admin" })
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vite-monorepo</title>
|
||||
<title>NerdWare — Party game culture geek</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Party game web multi-épreuves (quiz culture geek + blindtest YouTube), à jouer entre potes en temps réel."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
|
|||
Rangement par mode (recommandé pour des transitions personnalisées) :
|
||||
|
||||
- `quiz/` → jouées avant une manche de **quiz**
|
||||
- `image/` → jouées avant une manche du mode **images**
|
||||
- `blindtest/` → jouées avant une manche de **blindtest**
|
||||
- racine (`./`) → **communs**, fallback pour les deux modes
|
||||
- racine (`./`) → **communs**, fallback pour tous les modes
|
||||
|
||||
Règles :
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from "react"
|
|||
import {
|
||||
Brain,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
ListMusic,
|
||||
Minus,
|
||||
Music,
|
||||
|
|
@ -15,19 +16,31 @@ 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"
|
||||
|
||||
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: 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 },
|
||||
]
|
||||
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||
title_artist: "Titre & artiste",
|
||||
|
|
@ -100,7 +113,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") {
|
||||
|
|
@ -119,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)
|
||||
|
|
@ -132,14 +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
|
||||
// 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"
|
||||
const showBlindtest =
|
||||
effectiveType === "blindtest" || effectiveType === "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 =
|
||||
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
|
||||
const isMixed = effectiveType === "mixed"
|
||||
|
||||
const rounds = buildRounds(effectiveType, showQuiz ? 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 ||
|
||||
|
|
@ -147,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)
|
||||
|
|
@ -190,54 +227,96 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</section>
|
||||
|
||||
{isHost && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
{GAME_TYPES.map(({ value, label, Icon }) => {
|
||||
const needsThree = value !== "quiz" && !blindtestAvailable
|
||||
<section className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Réglages de la partie
|
||||
</h2>
|
||||
|
||||
{/* Mode de jeu */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">Mode de jeu</span>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
|
||||
const locked = needsThree && !blindtestAvailable
|
||||
const active = effectiveType === value
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
key={value}
|
||||
className="flex-1"
|
||||
variant={effectiveType === value ? "default" : "secondary"}
|
||||
disabled={needsThree}
|
||||
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
||||
disabled={locked}
|
||||
title={locked ? "3 joueurs minimum" : undefined}
|
||||
onClick={() => updateSettings({ gameType: value })}
|
||||
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||
active
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Icon /> {label}
|
||||
</Button>
|
||||
<Icon className="size-5" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!blindtestAvailable && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
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">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
{effectiveType === "image" ? (
|
||||
<ImageIcon className="size-4" />
|
||||
) : (
|
||||
<Brain className="size-4" />
|
||||
)}
|
||||
{effectiveType === "image" ? "Images" : "Questions"}
|
||||
</span>
|
||||
<Stepper value={count} min={1} max={20} onChange={setCount} />
|
||||
</div>
|
||||
{effectiveType === "image" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Crée des questions « Image » dans le back-office au préalable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHost && 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>
|
||||
<div className="flex gap-1">
|
||||
{QUESTION_OPTIONS.map((n) => (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant={count === n ? "default" : "secondary"}
|
||||
onClick={() => setCount(n)}
|
||||
>
|
||||
{n}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHost && showBlindtest && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Réglages blindtest */}
|
||||
{showBlindtest && (
|
||||
<div className="flex flex-col gap-3 border-t pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Music className="size-4" /> Mode blindtest
|
||||
|
|
@ -267,6 +346,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showBlindtest && (
|
||||
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import type {
|
||||
QuizPerPlayerResult,
|
||||
QuizQuestionPayload,
|
||||
|
|
@ -12,6 +12,9 @@ import { Countdown } from "@/components/countdown"
|
|||
const inputClass =
|
||||
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
|
||||
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
||||
const MAX_BLUR = 22
|
||||
|
||||
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const reveal = useRoomStore((s) => s.reveal)
|
||||
|
|
@ -31,7 +34,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
const question = round.payload as QuizQuestionPayload
|
||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||
const showReveal = truth !== null
|
||||
const isFree = question.format === "free"
|
||||
const isImage = question.format === "image_reveal"
|
||||
const isText = question.format === "free" || isImage
|
||||
const myResult = reveal
|
||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||
: undefined
|
||||
|
|
@ -74,7 +78,20 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{question.prompt}
|
||||
</h2>
|
||||
|
||||
{isFree ? (
|
||||
{isImage && question.imageUrl && (
|
||||
<ImageReveal
|
||||
src={
|
||||
question.imageUrl.startsWith("http")
|
||||
? question.imageUrl
|
||||
: `${SERVER_URL}${question.imageUrl}`
|
||||
}
|
||||
startsAt={round.startsAt}
|
||||
endsAt={round.endsAt}
|
||||
revealed={showReveal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isText ? (
|
||||
<FreeAnswer
|
||||
disabled={showReveal || hasVoted}
|
||||
onSubmit={voteText}
|
||||
|
|
@ -110,7 +127,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
|
||||
{showReveal && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{isFree && (
|
||||
{isText && (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Réponse : </span>
|
||||
<span className="font-medium">{truth?.answer}</span>
|
||||
|
|
@ -131,6 +148,48 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ImageReveal({
|
||||
src,
|
||||
startsAt,
|
||||
endsAt,
|
||||
revealed,
|
||||
}: {
|
||||
src: string
|
||||
startsAt: number
|
||||
endsAt: number
|
||||
revealed: boolean
|
||||
}) {
|
||||
// Le flou décroît avec le temps (cadencé par les timestamps serveur) : tous
|
||||
// les clients voient le même niveau de flou au même instant.
|
||||
const [blur, setBlur] = useState(MAX_BLUR)
|
||||
|
||||
useEffect(() => {
|
||||
if (revealed) {
|
||||
return
|
||||
}
|
||||
const id = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const p =
|
||||
now < startsAt ? 0 : Math.min(1, (now - startsAt) / (endsAt - startsAt))
|
||||
setBlur(MAX_BLUR * (1 - p))
|
||||
}, 200)
|
||||
return () => clearInterval(id)
|
||||
}, [revealed, startsAt, endsAt])
|
||||
|
||||
const displayBlur = revealed ? 0 : blur
|
||||
|
||||
return (
|
||||
<div className="bg-card relative flex w-full justify-center overflow-hidden rounded-2xl p-2">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
|
||||
style={{ filter: `blur(${displayBlur}px)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FreeAnswer({
|
||||
disabled,
|
||||
onSubmit,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import type { RoundType } from "@nerdware/shared"
|
||||
|
||||
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
|
||||
export type TransitionKind = "quiz" | "image" | "blindtest"
|
||||
|
||||
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
||||
|
||||
// Médias custom déposés par l'utilisateur, rangés par mode :
|
||||
// src/assets/transitions/quiz/* → transitions de quiz
|
||||
// src/assets/transitions/image/* → transitions du mode images
|
||||
// src/assets/transitions/blindtest/* → transitions de blindtest
|
||||
// src/assets/transitions/* → communs (fallback)
|
||||
// Détectés au build ; sinon, animation Framer par défaut.
|
||||
|
|
@ -15,17 +18,17 @@ const ALL_MEDIA = import.meta.glob(
|
|||
{ eager: true, import: "default" }
|
||||
) as Record<string, string>
|
||||
|
||||
const QUIZ_MEDIA: string[] = []
|
||||
const BLINDTEST_MEDIA: string[] = []
|
||||
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
|
||||
const SHARED_MEDIA: string[] = []
|
||||
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
||||
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url)
|
||||
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url)
|
||||
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
|
||||
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
|
||||
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
|
||||
else SHARED_MEDIA.push(url)
|
||||
}
|
||||
|
||||
const THEME: Record<
|
||||
RoundType,
|
||||
TransitionKind,
|
||||
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
|
||||
> = {
|
||||
quiz: {
|
||||
|
|
@ -35,6 +38,13 @@ const THEME: Record<
|
|||
kind: "Question",
|
||||
emoji: "🧠",
|
||||
},
|
||||
image: {
|
||||
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
|
||||
accent: "text-lime-300",
|
||||
label: "IMAGE",
|
||||
kind: "Image",
|
||||
emoji: "🖼️",
|
||||
},
|
||||
blindtest: {
|
||||
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
||||
accent: "text-yellow-300",
|
||||
|
|
@ -44,29 +54,28 @@ const THEME: Record<
|
|||
},
|
||||
}
|
||||
|
||||
function mediaFor(type: RoundType): string[] {
|
||||
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA
|
||||
return [...own, ...SHARED_MEDIA]
|
||||
function mediaFor(kind: TransitionKind): string[] {
|
||||
return [...MEDIA[kind], ...SHARED_MEDIA]
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
|
||||
* il change (quiz ↔ blindtest), sinon affiche un teaser léger de la manche.
|
||||
* Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest).
|
||||
* Annonce le mode quand il change, sinon affiche un teaser léger de la manche.
|
||||
* Montée avec une `key` qui change → rejoue à chaque manche.
|
||||
*/
|
||||
export function RoundTransition({
|
||||
type,
|
||||
kind,
|
||||
index,
|
||||
modeChanged,
|
||||
}: {
|
||||
type: RoundType
|
||||
kind: TransitionKind
|
||||
index: number
|
||||
modeChanged: boolean
|
||||
}) {
|
||||
const [show, setShow] = useState(true)
|
||||
const theme = THEME[type]
|
||||
const theme = THEME[kind]
|
||||
const [media] = useState(() => {
|
||||
const pool = mediaFor(type)
|
||||
const pool = mediaFor(kind)
|
||||
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface AdminQuestion {
|
|||
choices: string[] | null
|
||||
correctIndex: number | null
|
||||
acceptedAnswers: string[] | null
|
||||
imageUrl: string | null
|
||||
category: string | null
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +36,12 @@ export interface NewQuestionInput {
|
|||
choices?: string[]
|
||||
correctIndex?: number
|
||||
acceptedAnswers?: string[]
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** Construit l'URL absolue d'un asset servi par le serveur (ex: /uploads/x.jpg). */
|
||||
export function assetUrl(path: string): string {
|
||||
return path.startsWith("http") ? path : `${SERVER_URL}${path}`
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
|
@ -53,8 +60,24 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
return res.status === 204 ? (undefined as T) : ((await res.json()) as T)
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<{ url: string }> {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
const res = await fetch(`${SERVER_URL}/api/admin/upload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(body.error ?? `Erreur ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as { url: string }
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
listQuestions: () => request<AdminQuestion[]>("/questions"),
|
||||
uploadImage,
|
||||
createQuestion: (input: NewQuestionInput) =>
|
||||
request<{ id: string }>("/questions", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
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 {
|
||||
adminApi,
|
||||
assetUrl,
|
||||
clearAdminToken,
|
||||
getAdminToken,
|
||||
setAdminToken,
|
||||
|
|
@ -147,12 +148,19 @@ function QuestionRow({
|
|||
}) {
|
||||
return (
|
||||
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3">
|
||||
{q.format === "image_reveal" && q.imageUrl && (
|
||||
<img
|
||||
src={assetUrl(q.imageUrl)}
|
||||
alt=""
|
||||
className="h-12 w-12 shrink-0 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{q.prompt}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}
|
||||
</p>
|
||||
{q.format === "free" ? (
|
||||
{q.format === "free" || q.format === "image_reveal" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
✔ {q.acceptedAnswers?.join(", ")}
|
||||
</p>
|
||||
|
|
@ -186,6 +194,10 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
||||
const [correctIndex, setCorrectIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState("")
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
|
||||
|
|
@ -194,10 +206,27 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
setChoices(EMPTY_CHOICES)
|
||||
setCorrectIndex(0)
|
||||
setAnswers("")
|
||||
setImageUrl(null)
|
||||
onCreated()
|
||||
},
|
||||
})
|
||||
|
||||
async function onPickImage(file: File | undefined) {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadError(null)
|
||||
try {
|
||||
const { url } = await adminApi.uploadImage(file)
|
||||
setImageUrl(url)
|
||||
} catch (err) {
|
||||
setUploadError((err as { message?: string }).message ?? "Échec de l'upload")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const input: NewQuestionInput = { format, prompt, category, difficulty }
|
||||
if (format === "free") {
|
||||
|
|
@ -205,6 +234,12 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
.split("\n")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
} else if (format === "image_reveal") {
|
||||
input.acceptedAnswers = answers
|
||||
.split("\n")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
input.imageUrl = imageUrl ?? undefined
|
||||
} else if (format === "truefalse") {
|
||||
input.choices = ["Vrai", "Faux"]
|
||||
input.correctIndex = correctIndex
|
||||
|
|
@ -231,6 +266,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
<option value="mcq">QCM</option>
|
||||
<option value="truefalse">Vrai / Faux</option>
|
||||
<option value="free">Réponse libre</option>
|
||||
<option value="image_reveal">Image à deviner</option>
|
||||
</select>
|
||||
<select
|
||||
className={inputClass}
|
||||
|
|
@ -251,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)}
|
||||
/>
|
||||
|
|
@ -300,7 +340,42 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{format === "free" && (
|
||||
{format === "image_reveal" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => onPickImage(e.target.files?.[0])}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload />
|
||||
{uploading
|
||||
? "Upload…"
|
||||
: imageUrl
|
||||
? "Changer l'image"
|
||||
: "Choisir une image"}
|
||||
</Button>
|
||||
{uploadError && (
|
||||
<p className="text-destructive text-xs">{uploadError}</p>
|
||||
)}
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={assetUrl(imageUrl)}
|
||||
alt=""
|
||||
className="max-h-40 w-fit rounded-md object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(format === "free" || format === "image_reveal") && (
|
||||
<textarea
|
||||
className={`${inputClass} h-24 py-2`}
|
||||
placeholder="Réponses acceptées (une par ligne)"
|
||||
|
|
@ -314,7 +389,13 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
)}
|
||||
|
||||
<Button
|
||||
disabled={create.isPending || !prompt.trim() || !category.trim()}
|
||||
disabled={
|
||||
create.isPending ||
|
||||
uploading ||
|
||||
(!prompt.trim() && format !== "image_reveal") ||
|
||||
!category.trim() ||
|
||||
(format === "image_reveal" && !imageUrl)
|
||||
}
|
||||
onClick={submit}
|
||||
>
|
||||
{create.isPending ? "Ajout…" : "Ajouter la question"}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function RoomPage({ code }: { code: string }) {
|
|||
const roundKey = useRoomStore((s) => s.roundKey)
|
||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const roundKind = useRoomStore((s) => s.roundKind)
|
||||
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
|
||||
|
||||
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
|
||||
|
|
@ -106,7 +107,7 @@ export function RoomPage({ code }: { code: string }) {
|
|||
{roundKey > 0 && round && (
|
||||
<RoundTransition
|
||||
key={roundKey}
|
||||
type={round.type}
|
||||
kind={roundKind}
|
||||
index={snapshot.currentRound}
|
||||
modeChanged={roundModeChanged}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,9 @@ interface RoomState {
|
|||
mediaSync: MediaSyncPayload | null
|
||||
boomKey: number
|
||||
roundKey: number
|
||||
/** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */
|
||||
/** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
|
||||
roundKind: "quiz" | "image" | "blindtest"
|
||||
/** Le mode a-t-il changé par rapport à la manche précédente ? */
|
||||
roundModeChanged: boolean
|
||||
|
||||
createRoom: () => Promise<RoomCreatedPayload>
|
||||
|
|
@ -91,6 +93,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
roundKind: "quiz",
|
||||
roundModeChanged: false,
|
||||
|
||||
createRoom: () =>
|
||||
|
|
@ -139,6 +142,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,
|
||||
|
|
@ -220,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
roundKind: "quiz",
|
||||
roundModeChanged: false,
|
||||
}),
|
||||
}))
|
||||
|
|
@ -247,7 +252,15 @@ socket.on("room:state", (snapshot) =>
|
|||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||
|
||||
socket.on("round:start", (payload) =>
|
||||
useRoomStore.setState((s) => ({
|
||||
useRoomStore.setState((s) => {
|
||||
const fmt = (payload.payload as { format?: string } | null)?.format
|
||||
const kind =
|
||||
payload.type === "blindtest"
|
||||
? "blindtest"
|
||||
: fmt === "image_reveal"
|
||||
? "image"
|
||||
: "quiz"
|
||||
return {
|
||||
round: {
|
||||
type: payload.type,
|
||||
djId: payload.djId,
|
||||
|
|
@ -262,8 +275,10 @@ socket.on("round:start", (payload) =>
|
|||
hasVoted: false,
|
||||
mediaSync: null,
|
||||
roundKey: s.roundKey + 1,
|
||||
roundModeChanged: (s.round?.type ?? null) !== payload.type,
|
||||
}))
|
||||
roundKind: kind,
|
||||
roundModeChanged: s.roundKind !== kind,
|
||||
}
|
||||
})
|
||||
)
|
||||
socket.on("round:voteAck", (voteProgress) =>
|
||||
useRoomStore.setState({ voteProgress })
|
||||
|
|
|
|||
28
bun.lock
28
bun.lock
|
|
@ -16,6 +16,8 @@
|
|||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@nerdware/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45",
|
||||
"fastify": "^5",
|
||||
|
|
@ -260,10 +262,16 @@
|
|||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
|
||||
"@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="],
|
||||
|
||||
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="],
|
||||
|
||||
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
|
||||
|
||||
"@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="],
|
||||
|
||||
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||
|
||||
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
||||
|
|
@ -272,8 +280,14 @@
|
|||
|
||||
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||
|
||||
"@fastify/multipart": ["@fastify/multipart@10.0.0", "", { "dependencies": { "@fastify/busboy": "^3.0.0", "@fastify/deepmerge": "^3.0.0", "@fastify/error": "^4.0.0", "fastify-plugin": "^5.0.0", "secure-json-parse": "^4.0.0" } }, "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA=="],
|
||||
|
||||
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||
|
||||
"@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="],
|
||||
|
||||
"@fastify/static": ["@fastify/static@9.1.3", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
|
@ -342,6 +356,8 @@
|
|||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
|
||||
|
|
@ -938,6 +954,8 @@
|
|||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
|
||||
|
|
@ -1084,7 +1102,7 @@
|
|||
|
||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
|
||||
|
||||
|
|
@ -1102,6 +1120,8 @@
|
|||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
|
@ -1114,6 +1134,8 @@
|
|||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
|
||||
|
|
@ -1180,6 +1202,8 @@
|
|||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
|
@ -1474,6 +1498,8 @@
|
|||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ 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"
|
||||
|
||||
/** 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface QuizQuestionPayload {
|
|||
prompt: string
|
||||
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
|
||||
choices?: string[]
|
||||
/** Image à dévoiler progressivement (image_reveal). Chemin relatif au serveur. */
|
||||
imageUrl?: string
|
||||
category?: string
|
||||
/** 1 (facile) .. 3 (difficile). */
|
||||
difficulty?: number
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue