feat(quiz): image_reveal format (progressive blur) end-to-end
Server: - quiz-round handles image_reveal like a text-answer format (tolerant match on acceptedAnswers), exposing imageUrl in the round payload; degressive scoring comes for free from the existing speed bonus (earlier = blurrier = more points) - repo includes image_reveal in the pool (requires imageUrl + acceptedAnswers) - back-office: image upload (@fastify/multipart) saved to UPLOADS_DIR and served statically on /uploads (@fastify/static); create supports image_reveal - env: UPLOADS_DIR; gitignore uploads/ Client: - admin form: "Image à deviner" — upload + preview + accepted answers - in-game: ImageReveal shows the image with a time-cadenced decreasing blur (synced via server timestamps) + text input; reveal shows the clear image and the answer shared: QuizQuestionPayload.imageUrl Verified against real server+DB: upload (url), static serve (200), create (201), validation (400). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b03879ee0
commit
0619c02cdf
14 changed files with 268 additions and 25 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -32,3 +32,6 @@ dist-ssr
|
|||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# uploads (image_reveal) servies en statique
|
||||
uploads/
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@ CORS_ORIGINS=http://localhost:5173
|
|||
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
|
||||
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
|
||||
ADMIN_TOKEN=change-me
|
||||
# Dossier de stockage des images (image_reveal), servi sur /uploads.
|
||||
UPLOADS_DIR=./uploads
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ export interface QuizQuestion {
|
|||
choices?: string[]
|
||||
/** mcq/truefalse uniquement. */
|
||||
correctIndex?: number
|
||||
/** free : réponses acceptées (matching tolérant). */
|
||||
/** free / image_reveal : réponses acceptées (matching tolérant). */
|
||||
acceptedAnswers?: string[]
|
||||
/** image_reveal : image à dévoiler (chemin relatif au serveur). */
|
||||
imageUrl?: string
|
||||
category: string
|
||||
difficulty: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,12 +36,17 @@ function asText(answer: Answer): string | null {
|
|||
return typeof v === "string" ? v : null
|
||||
}
|
||||
|
||||
/** Formats à réponse libre (saisie texte + matching tolérant). */
|
||||
function isTextFormat(question: QuizQuestion): boolean {
|
||||
return question.format === "free" || question.format === "image_reveal"
|
||||
}
|
||||
|
||||
/** Réponse correcte ? Selon le format de la question. */
|
||||
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
|
||||
if (!answer) {
|
||||
return false
|
||||
}
|
||||
if (question.format === "free") {
|
||||
if (isTextFormat(question)) {
|
||||
const text = asText(answer)
|
||||
if (!text) {
|
||||
return false
|
||||
|
|
@ -68,9 +73,12 @@ export class QuizRound implements GameRound {
|
|||
category: question.category,
|
||||
difficulty: question.difficulty,
|
||||
}
|
||||
if (question.format !== "free") {
|
||||
if (question.format === "mcq" || question.format === "truefalse") {
|
||||
payload.choices = question.choices
|
||||
}
|
||||
if (question.format === "image_reveal") {
|
||||
payload.imageUrl = question.imageUrl
|
||||
}
|
||||
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||
return { djId: null, payload, data }
|
||||
}
|
||||
|
|
@ -81,7 +89,7 @@ export class QuizRound implements GameRound {
|
|||
return
|
||||
}
|
||||
const { question, votedAt } = ctx.data as QuizRoundData
|
||||
if (question.format === "free") {
|
||||
if (isTextFormat(question)) {
|
||||
const text = asText(answer)
|
||||
if (text === null || text.trim().length === 0) {
|
||||
return
|
||||
|
|
@ -111,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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { mkdirSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import Fastify from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import multipart from "@fastify/multipart"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import { env, isDev } from "./env"
|
||||
import { createSocketServer } from "./socket"
|
||||
import { adminRoutes } from "./admin/routes"
|
||||
|
|
@ -13,6 +17,12 @@ const app = Fastify({
|
|||
|
||||
await app.register(cors, { origin: env.corsOrigins })
|
||||
|
||||
// Images (image_reveal) : dossier servi en statique sur /uploads.
|
||||
const uploadsRoot = resolve(env.uploadsDir)
|
||||
mkdirSync(uploadsRoot, { recursive: true })
|
||||
await app.register(multipart, { limits: { fileSize: 8 * 1024 * 1024 } })
|
||||
await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
|
||||
|
||||
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
||||
|
||||
await app.register(adminRoutes, { prefix: "/api/admin" })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import type {
|
||||
QuizPerPlayerResult,
|
||||
QuizQuestionPayload,
|
||||
|
|
@ -12,6 +12,9 @@ import { Countdown } from "@/components/countdown"
|
|||
const inputClass =
|
||||
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
|
||||
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
||||
const MAX_BLUR = 22
|
||||
|
||||
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const reveal = useRoomStore((s) => s.reveal)
|
||||
|
|
@ -31,7 +34,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
const question = round.payload as QuizQuestionPayload
|
||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||
const showReveal = truth !== null
|
||||
const isFree = question.format === "free"
|
||||
const isImage = question.format === "image_reveal"
|
||||
const isText = question.format === "free" || isImage
|
||||
const myResult = reveal
|
||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||
: undefined
|
||||
|
|
@ -74,7 +78,20 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{question.prompt}
|
||||
</h2>
|
||||
|
||||
{isFree ? (
|
||||
{isImage && question.imageUrl && (
|
||||
<ImageReveal
|
||||
src={
|
||||
question.imageUrl.startsWith("http")
|
||||
? question.imageUrl
|
||||
: `${SERVER_URL}${question.imageUrl}`
|
||||
}
|
||||
startsAt={round.startsAt}
|
||||
endsAt={round.endsAt}
|
||||
revealed={showReveal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isText ? (
|
||||
<FreeAnswer
|
||||
disabled={showReveal || hasVoted}
|
||||
onSubmit={voteText}
|
||||
|
|
@ -110,7 +127,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
|
||||
{showReveal && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{isFree && (
|
||||
{isText && (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Réponse : </span>
|
||||
<span className="font-medium">{truth?.answer}</span>
|
||||
|
|
@ -131,6 +148,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 (
|
||||
<div className="bg-card relative mx-auto aspect-video w-full overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="size-full object-cover transition-[filter] duration-300"
|
||||
style={{
|
||||
filter: `blur(${displayBlur}px)`,
|
||||
transform: "scale(1.1)", // évite les bords flous
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FreeAnswer({
|
||||
disabled,
|
||||
onSubmit,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface AdminQuestion {
|
|||
choices: string[] | null
|
||||
correctIndex: number | null
|
||||
acceptedAnswers: string[] | null
|
||||
imageUrl: string | null
|
||||
category: string | null
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +36,12 @@ export interface NewQuestionInput {
|
|||
choices?: string[]
|
||||
correctIndex?: number
|
||||
acceptedAnswers?: string[]
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** Construit l'URL absolue d'un asset servi par le serveur (ex: /uploads/x.jpg). */
|
||||
export function assetUrl(path: string): string {
|
||||
return path.startsWith("http") ? path : `${SERVER_URL}${path}`
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
|
@ -53,8 +60,24 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
return res.status === 204 ? (undefined as T) : ((await res.json()) as T)
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<{ url: string }> {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
const res = await fetch(`${SERVER_URL}/api/admin/upload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(body.error ?? `Erreur ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as { url: string }
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
listQuestions: () => request<AdminQuestion[]>("/questions"),
|
||||
uploadImage,
|
||||
createQuestion: (input: NewQuestionInput) =>
|
||||
request<{ id: string }>("/questions", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3">
|
||||
{q.format === "image_reveal" && q.imageUrl && (
|
||||
<img
|
||||
src={assetUrl(q.imageUrl)}
|
||||
alt=""
|
||||
className="h-12 w-12 shrink-0 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{q.prompt}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}
|
||||
</p>
|
||||
{q.format === "free" ? (
|
||||
{q.format === "free" || q.format === "image_reveal" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
✔ {q.acceptedAnswers?.join(", ")}
|
||||
</p>
|
||||
|
|
@ -186,6 +194,9 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
||||
const [correctIndex, setCorrectIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState("")
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
const 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 }) {
|
|||
<option value="mcq">QCM</option>
|
||||
<option value="truefalse">Vrai / Faux</option>
|
||||
<option value="free">Réponse libre</option>
|
||||
<option value="image_reveal">Image à deviner</option>
|
||||
</select>
|
||||
<select
|
||||
className={inputClass}
|
||||
|
|
@ -300,7 +335,31 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{format === "free" && (
|
||||
{format === "image_reveal" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="text-sm"
|
||||
onChange={(e) => onPickImage(e.target.files?.[0])}
|
||||
/>
|
||||
{uploading && (
|
||||
<p className="text-muted-foreground text-xs">Upload…</p>
|
||||
)}
|
||||
{uploadError && (
|
||||
<p className="text-destructive text-xs">{uploadError}</p>
|
||||
)}
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={assetUrl(imageUrl)}
|
||||
alt=""
|
||||
className="max-h-40 w-fit rounded-md object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(format === "free" || format === "image_reveal") && (
|
||||
<textarea
|
||||
className={`${inputClass} h-24 py-2`}
|
||||
placeholder="Réponses acceptées (une par ligne)"
|
||||
|
|
@ -314,7 +373,13 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
)}
|
||||
|
||||
<Button
|
||||
disabled={create.isPending || !prompt.trim() || !category.trim()}
|
||||
disabled={
|
||||
create.isPending ||
|
||||
uploading ||
|
||||
!prompt.trim() ||
|
||||
!category.trim() ||
|
||||
(format === "image_reveal" && !imageUrl)
|
||||
}
|
||||
onClick={submit}
|
||||
>
|
||||
{create.isPending ? "Ajout…" : "Ajouter la question"}
|
||||
|
|
|
|||
28
bun.lock
28
bun.lock
|
|
@ -16,6 +16,8 @@
|
|||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@nerdware/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45",
|
||||
"fastify": "^5",
|
||||
|
|
@ -260,10 +262,16 @@
|
|||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
|
||||
"@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="],
|
||||
|
||||
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="],
|
||||
|
||||
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
|
||||
|
||||
"@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="],
|
||||
|
||||
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||
|
||||
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
||||
|
|
@ -272,8 +280,14 @@
|
|||
|
||||
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||
|
||||
"@fastify/multipart": ["@fastify/multipart@10.0.0", "", { "dependencies": { "@fastify/busboy": "^3.0.0", "@fastify/deepmerge": "^3.0.0", "@fastify/error": "^4.0.0", "fastify-plugin": "^5.0.0", "secure-json-parse": "^4.0.0" } }, "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA=="],
|
||||
|
||||
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||
|
||||
"@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="],
|
||||
|
||||
"@fastify/static": ["@fastify/static@9.1.3", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
|
@ -342,6 +356,8 @@
|
|||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
|
||||
|
|
@ -938,6 +954,8 @@
|
|||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
|
||||
|
|
@ -1084,7 +1102,7 @@
|
|||
|
||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
|
||||
|
||||
|
|
@ -1102,6 +1120,8 @@
|
|||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
|
@ -1114,6 +1134,8 @@
|
|||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
|
||||
|
|
@ -1180,6 +1202,8 @@
|
|||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
|
@ -1474,6 +1498,8 @@
|
|||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface QuizQuestionPayload {
|
|||
prompt: string
|
||||
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
|
||||
choices?: string[]
|
||||
/** Image à dévoiler progressivement (image_reveal). Chemin relatif au serveur. */
|
||||
imageUrl?: string
|
||||
category?: string
|
||||
/** 1 (facile) .. 3 (difficile). */
|
||||
difficulty?: number
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue