diff --git a/.gitignore b/.gitignore index 19d9751..1fbeef7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ dist-ssr # typescript *.tsbuildinfo + +# uploads (image_reveal) servies en statique +uploads/ diff --git a/apps/server/.env.example b/apps/server/.env.example index f7af472..7dc6e6f 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -6,3 +6,5 @@ CORS_ORIGINS=http://localhost:5173 DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware # Jeton du back-office quiz (/admin). Laisser vide pour le désactiver. ADMIN_TOKEN=change-me +# Dossier de stockage des images (image_reveal), servi sur /uploads. +UPLOADS_DIR=./uploads diff --git a/apps/server/package.json b/apps/server/package.json index 31db470..6dced34 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,6 +18,8 @@ }, "dependencies": { "@fastify/cors": "^11", + "@fastify/multipart": "^10.0.0", + "@fastify/static": "^9.1.3", "@nerdware/shared": "workspace:*", "drizzle-orm": "^0.45", "fastify": "^5", diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index 32491d0..92c19ca 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -1,14 +1,18 @@ // Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN. // CRUD des questions manuelles écrites en base (source 'manual'). +import { randomUUID } from "node:crypto" +import { writeFile } from "node:fs/promises" +import { extname, resolve } from "node:path" import type { FastifyInstance } from "fastify" import { desc, eq } from "drizzle-orm" import { env } from "../env" import { db } from "../db" import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema" -const FORMATS = ["mcq", "truefalse", "free"] as const +const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const type AdminFormat = (typeof FORMATS)[number] +const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"] interface CreateBody { format?: string @@ -18,6 +22,7 @@ interface CreateBody { choices?: string[] correctIndex?: number acceptedAnswers?: string[] + imageUrl?: string } /** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */ @@ -40,14 +45,23 @@ function validate( const base = { categoryId, format, prompt, difficulty, source: "manual" } - if (format === "free") { + if (format === "free" || format === "image_reveal") { const answers = (body.acceptedAnswers ?? []) .map((a) => a.trim()) .filter(Boolean) if (answers.length === 0) { return { error: "Au moins une réponse acceptée est requise." } } - return { value: { ...base, acceptedAnswers: answers } } + if (format === "image_reveal" && !body.imageUrl) { + return { error: "Une image est requise." } + } + return { + value: { + ...base, + acceptedAnswers: answers, + imageUrl: format === "image_reveal" ? body.imageUrl : undefined, + }, + } } // mcq / truefalse @@ -83,6 +97,22 @@ 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() + await writeFile(resolve(env.uploadsDir, name), buffer) + return { url: `/uploads/${name}` } + }) + app.get("/categories", async () => { const rows = await db! .select({ id: quizCategory.id, name: quizCategory.name }) @@ -102,6 +132,7 @@ export async function adminRoutes(app: FastifyInstance) { choices: quizQuestion.choices, correctIndex: quizQuestion.correctIndex, acceptedAnswers: quizQuestion.acceptedAnswers, + imageUrl: quizQuestion.imageUrl, category: quizCategory.name, }) .from(quizQuestion) diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index b9512f2..2bdb35a 100644 --- a/apps/server/src/db/quiz-repo.ts +++ b/apps/server/src/db/quiz-repo.ts @@ -29,6 +29,7 @@ export async function loadQuizPool( choices: quizQuestion.choices, correctIndex: quizQuestion.correctIndex, acceptedAnswers: quizQuestion.acceptedAnswers, + imageUrl: quizQuestion.imageUrl, difficulty: quizQuestion.difficulty, category: quizCategory.name, }) @@ -36,7 +37,7 @@ export async function loadQuizPool( .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .where( and( - inArray(quizQuestion.format, ["mcq", "truefalse", "free"]), + inArray(quizQuestion.format, ["mcq", "truefalse", "free", "image_reveal"]), notInArray(quizQuestion.id, alreadyPlayed) ) ) @@ -44,11 +45,15 @@ export async function loadQuizPool( .limit(limit) return rows - .filter((r) => - r.format === "free" - ? (r.acceptedAnswers?.length ?? 0) > 0 - : r.choices && r.correctIndex !== null - ) + .filter((r) => { + if (r.format === "free") { + return (r.acceptedAnswers?.length ?? 0) > 0 + } + if (r.format === "image_reveal") { + return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0 + } + return r.choices && r.correctIndex !== null + }) .map((r) => ({ id: r.id, format: r.format as QuizQuestion["format"], @@ -56,6 +61,7 @@ export async function loadQuizPool( choices: r.choices ?? undefined, correctIndex: r.correctIndex ?? undefined, acceptedAnswers: r.acceptedAnswers ?? undefined, + imageUrl: r.imageUrl ?? undefined, category: r.category ?? "Quiz", difficulty: r.difficulty, })) diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index e7e1220..f138043 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -14,6 +14,8 @@ export const env = { databaseUrl: process.env.DATABASE_URL ?? "", /** Jeton du back-office quiz. Vide → back-office désactivé. */ adminToken: process.env.ADMIN_TOKEN ?? "", + /** Dossier de stockage des images (image_reveal), servi en statique sur /uploads. */ + uploadsDir: process.env.UPLOADS_DIR ?? "./uploads", /** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */ corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173") .split(",") diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts index 271157e..4887f25 100644 --- a/apps/server/src/game/modes/quiz/questions.ts +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -12,8 +12,10 @@ export interface QuizQuestion { choices?: string[] /** mcq/truefalse uniquement. */ correctIndex?: number - /** free : réponses acceptées (matching tolérant). */ + /** free / image_reveal : réponses acceptées (matching tolérant). */ acceptedAnswers?: string[] + /** image_reveal : image à dévoiler (chemin relatif au serveur). */ + imageUrl?: string category: string difficulty: number } diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 310b046..6ee1bc9 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -36,12 +36,17 @@ function asText(answer: Answer): string | null { return typeof v === "string" ? v : null } +/** Formats à réponse libre (saisie texte + matching tolérant). */ +function isTextFormat(question: QuizQuestion): boolean { + return question.format === "free" || question.format === "image_reveal" +} + /** Réponse correcte ? Selon le format de la question. */ function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean { if (!answer) { return false } - if (question.format === "free") { + if (isTextFormat(question)) { const text = asText(answer) if (!text) { return false @@ -68,9 +73,12 @@ export class QuizRound implements GameRound { category: question.category, difficulty: question.difficulty, } - if (question.format !== "free") { + if (question.format === "mcq" || question.format === "truefalse") { payload.choices = question.choices } + if (question.format === "image_reveal") { + payload.imageUrl = question.imageUrl + } const data: QuizRoundData = { question, votedAt: new Map() } return { djId: null, payload, data } } @@ -81,7 +89,7 @@ export class QuizRound implements GameRound { return } const { question, votedAt } = ctx.data as QuizRoundData - if (question.format === "free") { + if (isTextFormat(question)) { const text = asText(answer) if (text === null || text.trim().length === 0) { return @@ -111,10 +119,9 @@ export class QuizRound implements GameRound { correct: isCorrect(question, vote), } } - const truth: QuizRevealTruth = - question.format === "free" - ? { answer: question.acceptedAnswers?.[0] ?? "" } - : { correctIndex: question.correctIndex } + const truth: QuizRevealTruth = isTextFormat(question) + ? { answer: question.acceptedAnswers?.[0] ?? "" } + : { correctIndex: question.correctIndex } return { truth, perPlayerResult } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 35b9fc7..6d5c675 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,5 +1,9 @@ +import { mkdirSync } from "node:fs" +import { resolve } from "node:path" import Fastify from "fastify" import cors from "@fastify/cors" +import multipart from "@fastify/multipart" +import fastifyStatic from "@fastify/static" import { env, isDev } from "./env" import { createSocketServer } from "./socket" import { adminRoutes } from "./admin/routes" @@ -13,6 +17,12 @@ const app = Fastify({ await app.register(cors, { origin: env.corsOrigins }) +// Images (image_reveal) : dossier servi en statique sur /uploads. +const uploadsRoot = resolve(env.uploadsDir) +mkdirSync(uploadsRoot, { recursive: true }) +await app.register(multipart, { limits: { fileSize: 8 * 1024 * 1024 } }) +await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" }) + app.get("/health", async () => ({ status: "ok", uptime: process.uptime() })) await app.register(adminRoutes, { prefix: "/api/admin" }) diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index ddc401a..57f662e 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import type { QuizPerPlayerResult, QuizQuestionPayload, @@ -12,6 +12,9 @@ import { Countdown } from "@/components/countdown" const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" +const MAX_BLUR = 22 + export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const round = useRoomStore((s) => s.round) const reveal = useRoomStore((s) => s.reveal) @@ -31,7 +34,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const question = round.payload as QuizQuestionPayload const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const showReveal = truth !== null - const isFree = question.format === "free" + const isImage = question.format === "image_reveal" + const isText = question.format === "free" || isImage const myResult = reveal ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] : undefined @@ -74,7 +78,20 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.prompt} - {isFree ? ( + {isImage && question.imageUrl && ( + + )} + + {isText ? ( - {isFree && ( + {isText && (

Réponse : {truth?.answer} @@ -131,6 +148,51 @@ 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 ( +

+ +
+ ) +} + function FreeAnswer({ disabled, onSubmit, diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts index 73735b8..5a277a8 100644 --- a/apps/web/src/lib/admin-api.ts +++ b/apps/web/src/lib/admin-api.ts @@ -24,6 +24,7 @@ export interface AdminQuestion { choices: string[] | null correctIndex: number | null acceptedAnswers: string[] | null + imageUrl: string | null category: string | null } @@ -35,6 +36,12 @@ export interface NewQuestionInput { choices?: string[] correctIndex?: number acceptedAnswers?: string[] + imageUrl?: string +} + +/** Construit l'URL absolue d'un asset servi par le serveur (ex: /uploads/x.jpg). */ +export function assetUrl(path: string): string { + return path.startsWith("http") ? path : `${SERVER_URL}${path}` } async function request(path: string, init?: RequestInit): Promise { @@ -53,8 +60,24 @@ async function request(path: string, init?: RequestInit): Promise { return res.status === 204 ? (undefined as T) : ((await res.json()) as T) } +async function uploadImage(file: File): Promise<{ url: string }> { + const form = new FormData() + form.append("file", file) + const res = await fetch(`${SERVER_URL}/api/admin/upload`, { + method: "POST", + headers: { Authorization: `Bearer ${getAdminToken()}` }, + body: form, + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(body.error ?? `Erreur ${res.status}`) + } + return (await res.json()) as { url: string } +} + export const adminApi = { listQuestions: () => request("/questions"), + uploadImage, createQuestion: (input: NewQuestionInput) => request<{ id: string }>("/questions", { method: "POST", diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 91523ea..b70ce0f 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -6,6 +6,7 @@ import type { QuizFormat } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { adminApi, + assetUrl, clearAdminToken, getAdminToken, setAdminToken, @@ -147,12 +148,19 @@ function QuestionRow({ }) { return (
  • + {q.format === "image_reveal" && q.imageUrl && ( + + )}

    {q.prompt}

    {q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}

    - {q.format === "free" ? ( + {q.format === "free" || q.format === "image_reveal" ? (

    ✔ {q.acceptedAnswers?.join(", ")}

    @@ -186,6 +194,9 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { const [choices, setChoices] = useState(EMPTY_CHOICES) const [correctIndex, setCorrectIndex] = useState(0) const [answers, setAnswers] = useState("") + const [imageUrl, setImageUrl] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) const create = useMutation({ mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input), @@ -194,10 +205,27 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { setChoices(EMPTY_CHOICES) setCorrectIndex(0) setAnswers("") + setImageUrl(null) onCreated() }, }) + async function onPickImage(file: File | undefined) { + if (!file) { + return + } + setUploading(true) + setUploadError(null) + try { + const { url } = await adminApi.uploadImage(file) + setImageUrl(url) + } catch (err) { + setUploadError((err as { message?: string }).message ?? "Échec de l'upload") + } finally { + setUploading(false) + } + } + function submit() { const input: NewQuestionInput = { format, prompt, category, difficulty } if (format === "free") { @@ -205,6 +233,12 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { .split("\n") .map((a) => a.trim()) .filter(Boolean) + } else if (format === "image_reveal") { + input.acceptedAnswers = answers + .split("\n") + .map((a) => a.trim()) + .filter(Boolean) + input.imageUrl = imageUrl ?? undefined } else if (format === "truefalse") { input.choices = ["Vrai", "Faux"] input.correctIndex = correctIndex @@ -231,6 +265,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { + onPickImage(e.target.files?.[0])} + /> + {uploading && ( +

    Upload…

    + )} + {uploadError && ( +

    {uploadError}

    + )} + {imageUrl && ( + + )} +
    + )} + + {(format === "free" || format === "image_reveal") && (