feature/image-reveal #10
29 changed files with 1000 additions and 183 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -32,3 +32,6 @@ dist-ssr
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.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
|
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
|
||||||
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
|
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
|
||||||
ADMIN_TOKEN=change-me
|
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,
|
"when": 1781085893179,
|
||||||
"tag": "0000_loose_doctor_spectrum",
|
"tag": "0000_loose_doctor_spectrum",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781129455626,
|
||||||
|
"tag": "0001_fancy_captain_stacy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -14,10 +14,13 @@
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "bun src/db/migrate.ts",
|
"db:migrate": "bun src/db/migrate.ts",
|
||||||
"db:seed": "bun src/db/seed.ts",
|
"db:seed": "bun src/db/seed.ts",
|
||||||
|
"db:seed:images": "bun src/db/seed-images.ts",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11",
|
"@fastify/cors": "^11",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@nerdware/shared": "workspace:*",
|
"@nerdware/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.45",
|
"drizzle-orm": "^0.45",
|
||||||
"fastify": "^5",
|
"fastify": "^5",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
|
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
|
||||||
// CRUD des questions manuelles écrites en base (source 'manual').
|
// 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 type { FastifyInstance } from "fastify"
|
||||||
import { desc, eq } from "drizzle-orm"
|
import { desc, eq } from "drizzle-orm"
|
||||||
import { env } from "../env"
|
import { env } from "../env"
|
||||||
import { db } from "../db"
|
import { db } from "../db"
|
||||||
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
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]
|
type AdminFormat = (typeof FORMATS)[number]
|
||||||
|
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
||||||
|
|
||||||
interface CreateBody {
|
interface CreateBody {
|
||||||
format?: string
|
format?: string
|
||||||
|
|
@ -18,6 +22,7 @@ interface CreateBody {
|
||||||
choices?: string[]
|
choices?: string[]
|
||||||
correctIndex?: number
|
correctIndex?: number
|
||||||
acceptedAnswers?: string[]
|
acceptedAnswers?: string[]
|
||||||
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */
|
/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */
|
||||||
|
|
@ -29,7 +34,10 @@ function validate(
|
||||||
if (!FORMATS.includes(format)) {
|
if (!FORMATS.includes(format)) {
|
||||||
return { error: "Format invalide." }
|
return { error: "Format invalide." }
|
||||||
}
|
}
|
||||||
const prompt = (body.prompt ?? "").trim()
|
// L'intitulé est optionnel pour image_reveal (défaut générique).
|
||||||
|
const prompt =
|
||||||
|
(body.prompt ?? "").trim() ||
|
||||||
|
(format === "image_reveal" ? "Qui est-ce ?" : "")
|
||||||
if (prompt.length === 0) {
|
if (prompt.length === 0) {
|
||||||
return { error: "L'intitulé est obligatoire." }
|
return { error: "L'intitulé est obligatoire." }
|
||||||
}
|
}
|
||||||
|
|
@ -40,14 +48,23 @@ function validate(
|
||||||
|
|
||||||
const base = { categoryId, format, prompt, difficulty, source: "manual" }
|
const base = { categoryId, format, prompt, difficulty, source: "manual" }
|
||||||
|
|
||||||
if (format === "free") {
|
if (format === "free" || format === "image_reveal") {
|
||||||
const answers = (body.acceptedAnswers ?? [])
|
const answers = (body.acceptedAnswers ?? [])
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
if (answers.length === 0) {
|
if (answers.length === 0) {
|
||||||
return { error: "Au moins une réponse acceptée est requise." }
|
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
|
// 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 () => {
|
app.get("/categories", async () => {
|
||||||
const rows = await db!
|
const rows = await db!
|
||||||
.select({ id: quizCategory.id, name: quizCategory.name })
|
.select({ id: quizCategory.id, name: quizCategory.name })
|
||||||
|
|
@ -102,6 +137,7 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||||
choices: quizQuestion.choices,
|
choices: quizQuestion.choices,
|
||||||
correctIndex: quizQuestion.correctIndex,
|
correctIndex: quizQuestion.correctIndex,
|
||||||
acceptedAnswers: quizQuestion.acceptedAnswers,
|
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||||
|
imageUrl: quizQuestion.imageUrl,
|
||||||
category: quizCategory.name,
|
category: quizCategory.name,
|
||||||
})
|
})
|
||||||
.from(quizQuestion)
|
.from(quizQuestion)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
|
||||||
*/
|
*/
|
||||||
export async function loadQuizPool(
|
export async function loadQuizPool(
|
||||||
roomCode: string,
|
roomCode: string,
|
||||||
limit: number
|
limit: number,
|
||||||
|
formats: string[]
|
||||||
): Promise<QuizQuestion[]> {
|
): Promise<QuizQuestion[]> {
|
||||||
if (!db) {
|
if (!db || formats.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const alreadyPlayed = db
|
const alreadyPlayed = db
|
||||||
|
|
@ -29,6 +30,7 @@ export async function loadQuizPool(
|
||||||
choices: quizQuestion.choices,
|
choices: quizQuestion.choices,
|
||||||
correctIndex: quizQuestion.correctIndex,
|
correctIndex: quizQuestion.correctIndex,
|
||||||
acceptedAnswers: quizQuestion.acceptedAnswers,
|
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||||
|
imageUrl: quizQuestion.imageUrl,
|
||||||
difficulty: quizQuestion.difficulty,
|
difficulty: quizQuestion.difficulty,
|
||||||
category: quizCategory.name,
|
category: quizCategory.name,
|
||||||
})
|
})
|
||||||
|
|
@ -36,7 +38,7 @@ export async function loadQuizPool(
|
||||||
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
|
inArray(quizQuestion.format, formats),
|
||||||
notInArray(quizQuestion.id, alreadyPlayed)
|
notInArray(quizQuestion.id, alreadyPlayed)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -44,11 +46,15 @@ export async function loadQuizPool(
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
.filter((r) =>
|
.filter((r) => {
|
||||||
r.format === "free"
|
if (r.format === "free") {
|
||||||
? (r.acceptedAnswers?.length ?? 0) > 0
|
return (r.acceptedAnswers?.length ?? 0) > 0
|
||||||
: r.choices && r.correctIndex !== null
|
}
|
||||||
)
|
if (r.format === "image_reveal") {
|
||||||
|
return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0
|
||||||
|
}
|
||||||
|
return r.choices && r.correctIndex !== null
|
||||||
|
})
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
format: r.format as QuizQuestion["format"],
|
format: r.format as QuizQuestion["format"],
|
||||||
|
|
@ -56,6 +62,7 @@ export async function loadQuizPool(
|
||||||
choices: r.choices ?? undefined,
|
choices: r.choices ?? undefined,
|
||||||
correctIndex: r.correctIndex ?? undefined,
|
correctIndex: r.correctIndex ?? undefined,
|
||||||
acceptedAnswers: r.acceptedAnswers ?? undefined,
|
acceptedAnswers: r.acceptedAnswers ?? undefined,
|
||||||
|
imageUrl: r.imageUrl ?? undefined,
|
||||||
category: r.category ?? "Quiz",
|
category: r.category ?? "Quiz",
|
||||||
difficulty: r.difficulty,
|
difficulty: r.difficulty,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
primaryKey,
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
|
@ -20,13 +21,14 @@ export const quizCategory = pgTable("quiz_category", {
|
||||||
.default(sql`now()`),
|
.default(sql`now()`),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const quizQuestion = pgTable("quiz_question", {
|
export const quizQuestion = pgTable(
|
||||||
|
"quiz_question",
|
||||||
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
categoryId: uuid("category_id").references(() => quizCategory.id),
|
categoryId: uuid("category_id").references(() => quizCategory.id),
|
||||||
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
|
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
|
||||||
format: text("format").notNull(),
|
format: text("format").notNull(),
|
||||||
/** Unique → seed idempotent (onConflictDoNothing). */
|
prompt: text("prompt").notNull(),
|
||||||
prompt: text("prompt").notNull().unique(),
|
|
||||||
/** 1 (facile) .. 3 (difficile). */
|
/** 1 (facile) .. 3 (difficile). */
|
||||||
difficulty: integer("difficulty").notNull().default(1),
|
difficulty: integer("difficulty").notNull().default(1),
|
||||||
/** 'opentdb' | 'manual' */
|
/** 'opentdb' | 'manual' */
|
||||||
|
|
@ -39,7 +41,18 @@ export const quizQuestion = pgTable("quiz_question", {
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`now()`),
|
.default(sql`now()`),
|
||||||
})
|
},
|
||||||
|
// Unique sur l'intitulé SAUF pour les images (même intitulé "Qui est-ce ?"
|
||||||
|
// pour des images différentes). Les images se dédupliquent par image_url.
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("quiz_question_prompt_unique")
|
||||||
|
.on(t.prompt)
|
||||||
|
.where(sql`${t.format} <> 'image_reveal'`),
|
||||||
|
uniqueIndex("quiz_question_image_unique")
|
||||||
|
.on(t.imageUrl)
|
||||||
|
.where(sql`${t.imageUrl} is not null`),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
|
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
|
||||||
export const quizPlayed = pgTable(
|
export const quizPlayed = pgTable(
|
||||||
|
|
|
||||||
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 ?? "",
|
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||||
/** Jeton du back-office quiz. Vide → back-office désactivé. */
|
/** Jeton du back-office quiz. Vide → back-office désactivé. */
|
||||||
adminToken: process.env.ADMIN_TOKEN ?? "",
|
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). */
|
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
|
||||||
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
|
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// File d'attente de questions par room. Préchargée au lancement de la partie
|
// File d'attente de questions par room. Préchargée au lancement de la partie
|
||||||
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche.
|
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche.
|
||||||
|
// Les formats servis dépendent du type de partie (quiz / images / mixte).
|
||||||
|
|
||||||
|
import type { QuizFormat, RoomSettings } from "@nerdware/shared"
|
||||||
import { hasDb } from "../../../db"
|
import { hasDb } from "../../../db"
|
||||||
import { loadQuizPool } from "../../../db/quiz-repo"
|
import { loadQuizPool } from "../../../db/quiz-repo"
|
||||||
import type { ServerRoom } from "../../../rooms"
|
import type { ServerRoom } from "../../../rooms"
|
||||||
|
|
@ -10,10 +12,32 @@ interface RoomPool {
|
||||||
queue: QuizQuestion[]
|
queue: QuizQuestion[]
|
||||||
/** true si les questions viennent de la DB (→ marquer jouées). */
|
/** true si les questions viennent de la DB (→ marquer jouées). */
|
||||||
fromDb: boolean
|
fromDb: boolean
|
||||||
|
formats: QuizFormat[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
|
const poolByRoom = new WeakMap<ServerRoom, 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[] {
|
function shuffle<T>(items: T[]): T[] {
|
||||||
const arr = [...items]
|
const arr = [...items]
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
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. */
|
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
|
||||||
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
|
const formats = quizFormatsFor(room.settings)
|
||||||
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
|
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
|
||||||
let queue: QuizQuestion[] = []
|
let queue: QuizQuestion[] = []
|
||||||
let fromDb = false
|
let fromDb = false
|
||||||
|
|
||||||
if (hasDb) {
|
if (hasDb) {
|
||||||
try {
|
try {
|
||||||
queue = await loadQuizPool(room.code, Math.max(needed, 1))
|
queue = await loadQuizPool(room.code, Math.max(needed, 1), formats)
|
||||||
fromDb = queue.length > 0
|
fromDb = queue.length > 0
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
||||||
|
|
@ -39,14 +64,14 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queue.length === 0) {
|
if (queue.length === 0) {
|
||||||
queue = shuffle(QUIZ_QUESTIONS)
|
queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)))
|
||||||
fromDb = false
|
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): {
|
export function takeQuestion(room: ServerRoom): {
|
||||||
question: QuizQuestion
|
question: QuizQuestion
|
||||||
fromDb: boolean
|
fromDb: boolean
|
||||||
|
|
@ -55,7 +80,11 @@ export function takeQuestion(room: ServerRoom): {
|
||||||
if (pool && pool.queue.length > 0) {
|
if (pool && pool.queue.length > 0) {
|
||||||
return { question: pool.queue.shift()!, fromDb: pool.fromDb }
|
return { question: pool.queue.shift()!, fromDb: pool.fromDb }
|
||||||
}
|
}
|
||||||
const question =
|
const formats = pool?.formats ?? ["mcq", "truefalse", "free"]
|
||||||
QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
|
const candidates = QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))
|
||||||
return { question, fromDb: false }
|
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[]
|
choices?: string[]
|
||||||
/** mcq/truefalse uniquement. */
|
/** mcq/truefalse uniquement. */
|
||||||
correctIndex?: number
|
correctIndex?: number
|
||||||
/** free : réponses acceptées (matching tolérant). */
|
/** free / image_reveal : réponses acceptées (matching tolérant). */
|
||||||
acceptedAnswers?: string[]
|
acceptedAnswers?: string[]
|
||||||
|
/** image_reveal : image à dévoiler (chemin relatif au serveur). */
|
||||||
|
imageUrl?: string
|
||||||
category: string
|
category: string
|
||||||
difficulty: number
|
difficulty: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,17 @@ function asText(answer: Answer): string | null {
|
||||||
return typeof v === "string" ? v : 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. */
|
/** Réponse correcte ? Selon le format de la question. */
|
||||||
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
|
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
|
||||||
if (!answer) {
|
if (!answer) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (question.format === "free") {
|
if (isTextFormat(question)) {
|
||||||
const text = asText(answer)
|
const text = asText(answer)
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -68,9 +73,12 @@ export class QuizRound implements GameRound {
|
||||||
category: question.category,
|
category: question.category,
|
||||||
difficulty: question.difficulty,
|
difficulty: question.difficulty,
|
||||||
}
|
}
|
||||||
if (question.format !== "free") {
|
if (question.format === "mcq" || question.format === "truefalse") {
|
||||||
payload.choices = question.choices
|
payload.choices = question.choices
|
||||||
}
|
}
|
||||||
|
if (question.format === "image_reveal") {
|
||||||
|
payload.imageUrl = question.imageUrl
|
||||||
|
}
|
||||||
const data: QuizRoundData = { question, votedAt: new Map() }
|
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||||
return { djId: null, payload, data }
|
return { djId: null, payload, data }
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +89,7 @@ export class QuizRound implements GameRound {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { question, votedAt } = ctx.data as QuizRoundData
|
const { question, votedAt } = ctx.data as QuizRoundData
|
||||||
if (question.format === "free") {
|
if (isTextFormat(question)) {
|
||||||
const text = asText(answer)
|
const text = asText(answer)
|
||||||
if (text === null || text.trim().length === 0) {
|
if (text === null || text.trim().length === 0) {
|
||||||
return
|
return
|
||||||
|
|
@ -111,8 +119,7 @@ export class QuizRound implements GameRound {
|
||||||
correct: isCorrect(question, vote),
|
correct: isCorrect(question, vote),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const truth: QuizRevealTruth =
|
const truth: QuizRevealTruth = isTextFormat(question)
|
||||||
question.format === "free"
|
|
||||||
? { answer: question.acceptedAnswers?.[0] ?? "" }
|
? { answer: question.acceptedAnswers?.[0] ?? "" }
|
||||||
: { correctIndex: question.correctIndex }
|
: { correctIndex: question.correctIndex }
|
||||||
return { truth, perPlayerResult }
|
return { truth, perPlayerResult }
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export function registerRoomHandlers(
|
||||||
}
|
}
|
||||||
room.settings = {
|
room.settings = {
|
||||||
gameType: payload.gameType,
|
gameType: payload.gameType,
|
||||||
|
mixedModes: payload.mixedModes,
|
||||||
blindtestMode: payload.blindtestMode,
|
blindtestMode: payload.blindtestMode,
|
||||||
roundDuration: payload.roundDuration,
|
roundDuration: payload.roundDuration,
|
||||||
tracksPerPlayer: payload.tracksPerPlayer,
|
tracksPerPlayer: payload.tracksPerPlayer,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import { resolve } from "node:path"
|
||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
|
import multipart from "@fastify/multipart"
|
||||||
|
import fastifyStatic from "@fastify/static"
|
||||||
import { env, isDev } from "./env"
|
import { env, isDev } from "./env"
|
||||||
import { createSocketServer } from "./socket"
|
import { createSocketServer } from "./socket"
|
||||||
import { adminRoutes } from "./admin/routes"
|
import { adminRoutes } from "./admin/routes"
|
||||||
|
|
@ -13,6 +17,12 @@ const app = Fastify({
|
||||||
|
|
||||||
await app.register(cors, { origin: env.corsOrigins })
|
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() }))
|
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
||||||
|
|
||||||
await app.register(adminRoutes, { prefix: "/api/admin" })
|
await app.register(adminRoutes, { prefix: "/api/admin" })
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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) :
|
Rangement par mode (recommandé pour des transitions personnalisées) :
|
||||||
|
|
||||||
- `quiz/` → jouées avant une manche de **quiz**
|
- `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**
|
- `blindtest/` → jouées avant une manche de **blindtest**
|
||||||
- racine (`./`) → **communs**, fallback pour les deux modes
|
- racine (`./`) → **communs**, fallback pour tous les modes
|
||||||
|
|
||||||
Règles :
|
Règles :
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from "react"
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
Headphones,
|
Headphones,
|
||||||
|
Image as ImageIcon,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Minus,
|
Minus,
|
||||||
Music,
|
Music,
|
||||||
|
|
@ -15,19 +16,31 @@ import { SiYoutube } from "@icons-pack/react-simple-icons"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
MixMode,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
|
|
||||||
|
const MIX_LABELS: Record<MixMode, string> = {
|
||||||
|
quiz: "Quiz",
|
||||||
|
image: "Images",
|
||||||
|
blindtest: "Blindtest",
|
||||||
|
}
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
const QUESTION_OPTIONS = [3, 5, 10]
|
|
||||||
const MAX_TRACKS = 10
|
const MAX_TRACKS = 10
|
||||||
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
const GAME_TYPES: {
|
||||||
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
value: GameType
|
||||||
{ value: "quiz", label: "Quiz", Icon: Brain },
|
label: string
|
||||||
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
|
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> = {
|
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
title_artist: "Titre & artiste",
|
title_artist: "Titre & artiste",
|
||||||
|
|
@ -100,7 +113,7 @@ function buildRounds(
|
||||||
quizCount: number,
|
quizCount: number,
|
||||||
totalTracks: number
|
totalTracks: number
|
||||||
): RoundConfig[] {
|
): RoundConfig[] {
|
||||||
if (gameType === "quiz") {
|
if (gameType === "quiz" || gameType === "image") {
|
||||||
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
|
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
|
||||||
}
|
}
|
||||||
if (gameType === "blindtest") {
|
if (gameType === "blindtest") {
|
||||||
|
|
@ -119,7 +132,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const updateSettings = useRoomStore((s) => s.updateSettings)
|
const updateSettings = useRoomStore((s) => s.updateSettings)
|
||||||
const startGame = useRoomStore((s) => s.startGame)
|
const startGame = useRoomStore((s) => s.startGame)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings
|
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
|
||||||
|
snapshot.settings
|
||||||
|
|
||||||
const [count, setCount] = useState(5)
|
const [count, setCount] = useState(5)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
@ -132,14 +146,27 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
|
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
|
||||||
const connectedCount = snapshot.players.filter((p) => p.connected).length
|
const connectedCount = snapshot.players.filter((p) => p.connected).length
|
||||||
const blindtestAvailable = connectedCount >= 3
|
const blindtestAvailable = connectedCount >= 3
|
||||||
// Tant que le blindtest est indisponible, on retombe sur du quiz (sans
|
// Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
|
||||||
// toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs.
|
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
|
||||||
const effectiveType: GameType = blindtestAvailable ? gameType : "quiz"
|
const effectiveType: GameType =
|
||||||
const showQuiz = effectiveType === "quiz" || effectiveType === "mixed"
|
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
|
||||||
const showBlindtest =
|
const isMixed = effectiveType === "mixed"
|
||||||
effectiveType === "blindtest" || 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.
|
// Blindtest : tout le monde doit avoir soumis son quota de titres.
|
||||||
const allSubmitted =
|
const allSubmitted =
|
||||||
!showBlindtest ||
|
!showBlindtest ||
|
||||||
|
|
@ -147,6 +174,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
||||||
const canStart = rounds.length > 0 && allSubmitted
|
const canStart = rounds.length > 0 && allSubmitted
|
||||||
|
|
||||||
|
function toggleMix(mode: MixMode) {
|
||||||
|
const set = new Set(mixedModes)
|
||||||
|
if (set.has(mode)) {
|
||||||
|
set.delete(mode)
|
||||||
|
} else {
|
||||||
|
set.add(mode)
|
||||||
|
}
|
||||||
|
updateSettings({ mixedModes: [...set] })
|
||||||
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -190,54 +227,96 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<div className="flex flex-col gap-1">
|
<section className="flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<div className="flex gap-2">
|
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
{GAME_TYPES.map(({ value, label, Icon }) => {
|
Réglages de la partie
|
||||||
const needsThree = value !== "quiz" && !blindtestAvailable
|
</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 (
|
return (
|
||||||
<Button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
className="flex-1"
|
disabled={locked}
|
||||||
variant={effectiveType === value ? "default" : "secondary"}
|
title={locked ? "3 joueurs minimum" : undefined}
|
||||||
disabled={needsThree}
|
|
||||||
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
|
||||||
onClick={() => updateSettings({ gameType: value })}
|
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}
|
<Icon className="size-5" />
|
||||||
</Button>
|
{label}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!blindtestAvailable && (
|
{!blindtestAvailable && (
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Le blindtest se débloque à 3 joueurs.
|
Le blindtest se débloque à 3 joueurs.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sous-modes du mixte (toggles) */}
|
||||||
|
{isMixed && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium">Sous-modes inclus</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["quiz", "image", "blindtest"] as const).map((m) => {
|
||||||
|
const locked = m === "blindtest" && !blindtestAvailable
|
||||||
|
const on = mixedModes.includes(m) && !locked
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
disabled={locked}
|
||||||
|
title={locked ? "3 joueurs minimum" : undefined}
|
||||||
|
onClick={() => toggleMix(m)}
|
||||||
|
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||||
|
on
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-input hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{MIX_LABELS[m]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nombre de questions (quiz / images / mixte) */}
|
||||||
|
{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 && (
|
{/* Réglages blindtest */}
|
||||||
<div className="flex items-center justify-between">
|
{showBlindtest && (
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<div className="flex flex-col gap-3 border-t pt-3">
|
||||||
<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">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
<Music className="size-4" /> Mode blindtest
|
<Music className="size-4" /> Mode blindtest
|
||||||
|
|
@ -267,6 +346,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{showBlindtest && (
|
{showBlindtest && (
|
||||||
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import type {
|
import type {
|
||||||
QuizPerPlayerResult,
|
QuizPerPlayerResult,
|
||||||
QuizQuestionPayload,
|
QuizQuestionPayload,
|
||||||
|
|
@ -12,6 +12,9 @@ import { Countdown } from "@/components/countdown"
|
||||||
const inputClass =
|
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"
|
"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 }) {
|
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const round = useRoomStore((s) => s.round)
|
const round = useRoomStore((s) => s.round)
|
||||||
const reveal = useRoomStore((s) => s.reveal)
|
const reveal = useRoomStore((s) => s.reveal)
|
||||||
|
|
@ -31,7 +34,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const question = round.payload as QuizQuestionPayload
|
const question = round.payload as QuizQuestionPayload
|
||||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||||
const showReveal = truth !== 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
|
const myResult = reveal
|
||||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -74,7 +78,20 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{question.prompt}
|
{question.prompt}
|
||||||
</h2>
|
</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
|
<FreeAnswer
|
||||||
disabled={showReveal || hasVoted}
|
disabled={showReveal || hasVoted}
|
||||||
onSubmit={voteText}
|
onSubmit={voteText}
|
||||||
|
|
@ -110,7 +127,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
{showReveal && (
|
{showReveal && (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
{isFree && (
|
{isText && (
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="text-muted-foreground">Réponse : </span>
|
<span className="text-muted-foreground">Réponse : </span>
|
||||||
<span className="font-medium">{truth?.answer}</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({
|
function FreeAnswer({
|
||||||
disabled,
|
disabled,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
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)
|
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 :
|
// Médias custom déposés par l'utilisateur, rangés par mode :
|
||||||
// src/assets/transitions/quiz/* → transitions de quiz
|
// 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/blindtest/* → transitions de blindtest
|
||||||
// src/assets/transitions/* → communs (fallback)
|
// src/assets/transitions/* → communs (fallback)
|
||||||
// Détectés au build ; sinon, animation Framer par défaut.
|
// 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" }
|
{ eager: true, import: "default" }
|
||||||
) as Record<string, string>
|
) as Record<string, string>
|
||||||
|
|
||||||
const QUIZ_MEDIA: string[] = []
|
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
|
||||||
const BLINDTEST_MEDIA: string[] = []
|
|
||||||
const SHARED_MEDIA: string[] = []
|
const SHARED_MEDIA: string[] = []
|
||||||
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
||||||
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url)
|
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
|
||||||
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.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)
|
else SHARED_MEDIA.push(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME: Record<
|
const THEME: Record<
|
||||||
RoundType,
|
TransitionKind,
|
||||||
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
|
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
|
||||||
> = {
|
> = {
|
||||||
quiz: {
|
quiz: {
|
||||||
|
|
@ -35,6 +38,13 @@ const THEME: Record<
|
||||||
kind: "Question",
|
kind: "Question",
|
||||||
emoji: "🧠",
|
emoji: "🧠",
|
||||||
},
|
},
|
||||||
|
image: {
|
||||||
|
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
|
||||||
|
accent: "text-lime-300",
|
||||||
|
label: "IMAGE",
|
||||||
|
kind: "Image",
|
||||||
|
emoji: "🖼️",
|
||||||
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
||||||
accent: "text-yellow-300",
|
accent: "text-yellow-300",
|
||||||
|
|
@ -44,29 +54,28 @@ const THEME: Record<
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaFor(type: RoundType): string[] {
|
function mediaFor(kind: TransitionKind): string[] {
|
||||||
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA
|
return [...MEDIA[kind], ...SHARED_MEDIA]
|
||||||
return [...own, ...SHARED_MEDIA]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
|
* Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest).
|
||||||
* il change (quiz ↔ blindtest), sinon affiche un teaser léger de la manche.
|
* 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.
|
* Montée avec une `key` qui change → rejoue à chaque manche.
|
||||||
*/
|
*/
|
||||||
export function RoundTransition({
|
export function RoundTransition({
|
||||||
type,
|
kind,
|
||||||
index,
|
index,
|
||||||
modeChanged,
|
modeChanged,
|
||||||
}: {
|
}: {
|
||||||
type: RoundType
|
kind: TransitionKind
|
||||||
index: number
|
index: number
|
||||||
modeChanged: boolean
|
modeChanged: boolean
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(true)
|
const [show, setShow] = useState(true)
|
||||||
const theme = THEME[type]
|
const theme = THEME[kind]
|
||||||
const [media] = useState(() => {
|
const [media] = useState(() => {
|
||||||
const pool = mediaFor(type)
|
const pool = mediaFor(kind)
|
||||||
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
|
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface AdminQuestion {
|
||||||
choices: string[] | null
|
choices: string[] | null
|
||||||
correctIndex: number | null
|
correctIndex: number | null
|
||||||
acceptedAnswers: string[] | null
|
acceptedAnswers: string[] | null
|
||||||
|
imageUrl: string | null
|
||||||
category: string | null
|
category: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +36,12 @@ export interface NewQuestionInput {
|
||||||
choices?: string[]
|
choices?: string[]
|
||||||
correctIndex?: number
|
correctIndex?: number
|
||||||
acceptedAnswers?: string[]
|
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> {
|
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)
|
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 = {
|
export const adminApi = {
|
||||||
listQuestions: () => request<AdminQuestion[]>("/questions"),
|
listQuestions: () => request<AdminQuestion[]>("/questions"),
|
||||||
|
uploadImage,
|
||||||
createQuestion: (input: NewQuestionInput) =>
|
createQuestion: (input: NewQuestionInput) =>
|
||||||
request<{ id: string }>("/questions", {
|
request<{ id: string }>("/questions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Link } from "wouter"
|
import { Link } from "wouter"
|
||||||
import { Trash2 } from "lucide-react"
|
import { Trash2, Upload } from "lucide-react"
|
||||||
import type { QuizFormat } from "@nerdware/shared"
|
import type { QuizFormat } from "@nerdware/shared"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
|
assetUrl,
|
||||||
clearAdminToken,
|
clearAdminToken,
|
||||||
getAdminToken,
|
getAdminToken,
|
||||||
setAdminToken,
|
setAdminToken,
|
||||||
|
|
@ -147,12 +148,19 @@ function QuestionRow({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">{q.prompt}</p>
|
<p className="text-sm font-medium">{q.prompt}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}
|
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}
|
||||||
</p>
|
</p>
|
||||||
{q.format === "free" ? (
|
{q.format === "free" || q.format === "image_reveal" ? (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
✔ {q.acceptedAnswers?.join(", ")}
|
✔ {q.acceptedAnswers?.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -186,6 +194,10 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
||||||
const [correctIndex, setCorrectIndex] = useState(0)
|
const [correctIndex, setCorrectIndex] = useState(0)
|
||||||
const [answers, setAnswers] = useState("")
|
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({
|
const create = useMutation({
|
||||||
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
|
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
|
||||||
|
|
@ -194,10 +206,27 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
setChoices(EMPTY_CHOICES)
|
setChoices(EMPTY_CHOICES)
|
||||||
setCorrectIndex(0)
|
setCorrectIndex(0)
|
||||||
setAnswers("")
|
setAnswers("")
|
||||||
|
setImageUrl(null)
|
||||||
onCreated()
|
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() {
|
function submit() {
|
||||||
const input: NewQuestionInput = { format, prompt, category, difficulty }
|
const input: NewQuestionInput = { format, prompt, category, difficulty }
|
||||||
if (format === "free") {
|
if (format === "free") {
|
||||||
|
|
@ -205,6 +234,12 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
.filter(Boolean)
|
.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") {
|
} else if (format === "truefalse") {
|
||||||
input.choices = ["Vrai", "Faux"]
|
input.choices = ["Vrai", "Faux"]
|
||||||
input.correctIndex = correctIndex
|
input.correctIndex = correctIndex
|
||||||
|
|
@ -231,6 +266,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
<option value="mcq">QCM</option>
|
<option value="mcq">QCM</option>
|
||||||
<option value="truefalse">Vrai / Faux</option>
|
<option value="truefalse">Vrai / Faux</option>
|
||||||
<option value="free">Réponse libre</option>
|
<option value="free">Réponse libre</option>
|
||||||
|
<option value="image_reveal">Image à deviner</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
|
@ -251,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="Intitulé de la question"
|
placeholder={
|
||||||
|
format === "image_reveal"
|
||||||
|
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
|
||||||
|
: "Intitulé de la question"
|
||||||
|
}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -300,7 +340,42 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
</div>
|
</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
|
<textarea
|
||||||
className={`${inputClass} h-24 py-2`}
|
className={`${inputClass} h-24 py-2`}
|
||||||
placeholder="Réponses acceptées (une par ligne)"
|
placeholder="Réponses acceptées (une par ligne)"
|
||||||
|
|
@ -314,7 +389,13 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<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}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
{create.isPending ? "Ajout…" : "Ajouter la question"}
|
{create.isPending ? "Ajout…" : "Ajouter la question"}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
const roundKey = useRoomStore((s) => s.roundKey)
|
const roundKey = useRoomStore((s) => s.roundKey)
|
||||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||||
const round = useRoomStore((s) => s.round)
|
const round = useRoomStore((s) => s.round)
|
||||||
|
const roundKind = useRoomStore((s) => s.roundKind)
|
||||||
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
|
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
|
||||||
|
|
||||||
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
|
// 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 && (
|
{roundKey > 0 && round && (
|
||||||
<RoundTransition
|
<RoundTransition
|
||||||
key={roundKey}
|
key={roundKey}
|
||||||
type={round.type}
|
kind={roundKind}
|
||||||
index={snapshot.currentRound}
|
index={snapshot.currentRound}
|
||||||
modeChanged={roundModeChanged}
|
modeChanged={roundModeChanged}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ interface RoomState {
|
||||||
mediaSync: MediaSyncPayload | null
|
mediaSync: MediaSyncPayload | null
|
||||||
boomKey: number
|
boomKey: number
|
||||||
roundKey: 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
|
roundModeChanged: boolean
|
||||||
|
|
||||||
createRoom: () => Promise<RoomCreatedPayload>
|
createRoom: () => Promise<RoomCreatedPayload>
|
||||||
|
|
@ -91,6 +93,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
roundKind: "quiz",
|
||||||
roundModeChanged: false,
|
roundModeChanged: false,
|
||||||
|
|
||||||
createRoom: () =>
|
createRoom: () =>
|
||||||
|
|
@ -139,6 +142,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
|
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
|
||||||
socket.emit("lobby:updateSettings", {
|
socket.emit("lobby:updateSettings", {
|
||||||
gameType: partial.gameType ?? current.gameType,
|
gameType: partial.gameType ?? current.gameType,
|
||||||
|
mixedModes: partial.mixedModes ?? current.mixedModes,
|
||||||
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
||||||
roundDuration: partial.roundDuration ?? current.roundDuration,
|
roundDuration: partial.roundDuration ?? current.roundDuration,
|
||||||
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
||||||
|
|
@ -220,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
roundKind: "quiz",
|
||||||
roundModeChanged: false,
|
roundModeChanged: false,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
@ -247,7 +252,15 @@ socket.on("room:state", (snapshot) =>
|
||||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||||
|
|
||||||
socket.on("round:start", (payload) =>
|
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: {
|
round: {
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
djId: payload.djId,
|
djId: payload.djId,
|
||||||
|
|
@ -262,8 +275,10 @@ socket.on("round:start", (payload) =>
|
||||||
hasVoted: false,
|
hasVoted: false,
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
roundKey: s.roundKey + 1,
|
roundKey: s.roundKey + 1,
|
||||||
roundModeChanged: (s.round?.type ?? null) !== payload.type,
|
roundKind: kind,
|
||||||
}))
|
roundModeChanged: s.roundKind !== kind,
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
socket.on("round:voteAck", (voteProgress) =>
|
socket.on("round:voteAck", (voteProgress) =>
|
||||||
useRoomStore.setState({ voteProgress })
|
useRoomStore.setState({ voteProgress })
|
||||||
|
|
|
||||||
28
bun.lock
28
bun.lock
|
|
@ -16,6 +16,8 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11",
|
"@fastify/cors": "^11",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@nerdware/shared": "workspace:*",
|
"@nerdware/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.45",
|
"drizzle-orm": "^0.45",
|
||||||
"fastify": "^5",
|
"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=="],
|
"@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/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/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/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=="],
|
"@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/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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"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/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-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=="],
|
"@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"
|
export type RoundType = "blindtest" | "quiz"
|
||||||
|
|
||||||
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
||||||
export type GameType = "mixed" | "quiz" | "blindtest"
|
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
|
||||||
|
|
||||||
|
/** Sous-modes activables quand la partie est "mixed". */
|
||||||
|
export type MixMode = "quiz" | "image" | "blindtest"
|
||||||
|
|
||||||
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||||
|
|
@ -32,6 +35,8 @@ export interface RoundConfig {
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
||||||
gameType: GameType
|
gameType: GameType
|
||||||
|
/** Sous-modes actifs quand gameType === "mixed". */
|
||||||
|
mixedModes: MixMode[]
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
/** Durée d'une manche en secondes (def. 60). */
|
/** Durée d'une manche en secondes (def. 60). */
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
|
|
@ -44,6 +49,7 @@ export interface RoomSettings {
|
||||||
/** Réglages par défaut d'une nouvelle room. */
|
/** Réglages par défaut d'une nouvelle room. */
|
||||||
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||||
gameType: "mixed",
|
gameType: "mixed",
|
||||||
|
mixedModes: ["quiz", "image"],
|
||||||
blindtestMode: "title_artist",
|
blindtestMode: "title_artist",
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
tracksPerPlayer: 2,
|
tracksPerPlayer: 2,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
Answer,
|
Answer,
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
MixMode,
|
||||||
PlayerScore,
|
PlayerScore,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
|
|
@ -34,6 +35,7 @@ export interface SetNamePayload {
|
||||||
|
|
||||||
export interface UpdateSettingsPayload {
|
export interface UpdateSettingsPayload {
|
||||||
gameType: GameType
|
gameType: GameType
|
||||||
|
mixedModes: MixMode[]
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export interface QuizQuestionPayload {
|
||||||
prompt: string
|
prompt: string
|
||||||
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
|
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
|
||||||
choices?: string[]
|
choices?: string[]
|
||||||
|
/** Image à dévoiler progressivement (image_reveal). Chemin relatif au serveur. */
|
||||||
|
imageUrl?: string
|
||||||
category?: string
|
category?: string
|
||||||
/** 1 (facile) .. 3 (difficile). */
|
/** 1 (facile) .. 3 (difficile). */
|
||||||
difficulty?: number
|
difficulty?: number
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue