nerdware/apps/web/src/pages/admin.tsx
AyoubBenziza 1727b84644 feat: rebus game mode
New "Rébus" mode: guess the geek work from an emoji puzzle (free-text answer,
tolerant matching). Implemented as a quiz format `rebus` — reuses the whole quiz
machinery (pool, anti-repeat, speed scoring, recap, bots, back-office):

- shared: QuizFormat/GameType/MixMode + RoundConfig.pool gain "rebus"
- server: rebus is a text format; pool composes a rebus sub-pool (not filtered by
  category); ~9 seeded emoji rebus in the in-code bank; admin accepts the format
- client: lobby tile + sub-mode + count; big centered emoji display in the round;
  dedicated transition theme (🧩 RÉBUS); FR/EN strings; back-office form option

Verified e2e: a rebus game serves only rebus rounds with emoji prompts and the
recap shows the right answers. typecheck/lint/build/24 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:46:31 +02:00

445 lines
13 KiB
TypeScript

import { useRef, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "wouter"
import { Eye, EyeOff, Trash2, Upload } from "lucide-react"
import type { QuizFormat } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import {
adminApi,
assetUrl,
clearAdminToken,
getAdminToken,
setAdminToken,
type AdminQuestion,
type NewQuestionInput,
} from "@/lib/admin-api"
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"
export function AdminPage() {
const [token, setToken] = useState(getAdminToken())
if (!token) {
return <Login onAuth={setToken} />
}
return (
<AdminPanel
onLogout={() => {
clearAdminToken()
setToken("")
}}
/>
)
}
function Login({ onAuth }: { onAuth: (token: string) => void }) {
const [value, setValue] = useState("")
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-xs flex-col gap-4">
<h1 className="font-heading text-center text-2xl font-bold">
Back-office quiz
</h1>
<input
type="password"
className={inputClass}
placeholder="Jeton admin"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) {
setAdminToken(value.trim())
onAuth(value.trim())
}
}}
/>
<Button
disabled={!value.trim()}
onClick={() => {
setAdminToken(value.trim())
onAuth(value.trim())
}}
>
Entrer
</Button>
<Link href="/">
<Button variant="secondary" className="w-full">
Retour
</Button>
</Link>
</div>
</div>
)
}
function AdminPanel({ onLogout }: { onLogout: () => void }) {
const qc = useQueryClient()
const questions = useQuery({
queryKey: ["admin-questions"],
queryFn: adminApi.listQuestions,
retry: false,
})
const remove = useMutation({
mutationFn: adminApi.deleteQuestion,
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
})
const toggle = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
adminApi.setActive(id, active),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
})
const unauthorized =
questions.isError && /401|autoris/i.test(String(questions.error))
return (
<div className="mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-6 p-6">
<header className="flex items-center justify-between">
<h1 className="font-heading text-2xl font-bold">Back-office quiz</h1>
<Button size="sm" variant="secondary" onClick={onLogout}>
Déconnexion
</Button>
</header>
{unauthorized ? (
<p className="text-destructive text-sm">
Jeton invalide ou back-office indisponible.{" "}
<button className="underline" onClick={onLogout}>
Réessayer
</button>
</p>
) : (
<div className="grid gap-6 md:grid-cols-2 md:items-start">
<QuestionForm
onCreated={() =>
qc.invalidateQueries({ queryKey: ["admin-questions"] })
}
/>
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
Questions ({questions.data?.length ?? 0})
</h2>
{questions.isLoading && (
<p className="text-muted-foreground text-sm">Chargement</p>
)}
<ul className="flex flex-col gap-2">
{questions.data?.map((q) => (
<QuestionRow
key={q.id}
q={q}
onDelete={() => remove.mutate(q.id)}
onToggle={() => toggle.mutate({ id: q.id, active: !q.active })}
deleting={remove.isPending}
/>
))}
</ul>
</section>
</div>
)}
</div>
)
}
function QuestionRow({
q,
onDelete,
onToggle,
deleting,
}: {
q: AdminQuestion
onDelete: () => void
onToggle: () => void
deleting: boolean
}) {
return (
<li
className={`bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3 ${
q.active ? "" : "opacity-50"
}`}
>
{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.lang.toUpperCase()} · {q.source}
{!q.active && " · désactivée"}
</p>
{q.format === "free" ||
q.format === "image_reveal" ||
q.format === "rebus" ? (
<p className="text-muted-foreground text-xs">
{q.acceptedAnswers?.join(", ")}
</p>
) : (
<p className="text-muted-foreground text-xs">
{q.choices
?.map((c, i) => (i === q.correctIndex ? `${c}` : c))
.join(" · ")}
</p>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="secondary" onClick={onToggle}>
{q.active ? <Eye /> : <EyeOff />}
</Button>
</TooltipTrigger>
<TooltipContent>{q.active ? "Désactiver" : "Activer"}</TooltipContent>
</Tooltip>
<Button
size="icon-sm"
variant="secondary"
disabled={deleting}
onClick={onDelete}
>
<Trash2 />
</Button>
</li>
)
}
const EMPTY_CHOICES = ["", "", "", ""]
function QuestionForm({ onCreated }: { onCreated: () => void }) {
const [format, setFormat] = useState<QuizFormat>("mcq")
const [prompt, setPrompt] = useState("")
const [category, setCategory] = useState("")
const [difficulty, setDifficulty] = useState(1)
const [lang, setLang] = useState("fr")
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
const [correctIndex, setCorrectIndex] = useState(0)
const [answers, setAnswers] = useState("")
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const create = useMutation({
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
onSuccess: () => {
setPrompt("")
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, lang }
if (format === "free" || format === "rebus") {
input.acceptedAnswers = answers
.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
} else {
input.choices = choices.map((c) => c.trim()).filter(Boolean)
input.correctIndex = correctIndex
}
create.mutate(input)
}
return (
<section className="flex flex-col gap-3 rounded-xl border p-4">
<h2 className="text-sm font-medium">Nouvelle question</h2>
<div className="flex gap-2">
<select
className={inputClass}
value={format}
onChange={(e) => {
setFormat(e.target.value as QuizFormat)
setCorrectIndex(0)
}}
>
<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>
<option value="rebus">Rébus</option>
</select>
<select
className={inputClass}
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
>
<option value={1}>Facile</option>
<option value={2}>Moyen</option>
<option value={3}>Difficile</option>
</select>
<select
className={inputClass}
value={lang}
onChange={(e) => setLang(e.target.value)}
>
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
</div>
<input
className={inputClass}
placeholder="Catégorie (ex: Jeux vidéo)"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
<input
className={inputClass}
placeholder={
format === "image_reveal"
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
: "Intitulé de la question"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
{format === "mcq" && (
<div className="flex flex-col gap-2">
{choices.map((choice, i) => (
<label key={i} className="flex items-center gap-2">
<input
type="radio"
name="correct"
checked={correctIndex === i}
onChange={() => setCorrectIndex(i)}
/>
<input
className={inputClass}
placeholder={`Choix ${i + 1}`}
value={choice}
onChange={(e) =>
setChoices((c) =>
c.map((x, j) => (j === i ? e.target.value : x))
)
}
/>
</label>
))}
<p className="text-muted-foreground text-xs">
Coche la bonne réponse. Les choix vides sont ignorés (min. 2).
</p>
</div>
)}
{format === "truefalse" && (
<div className="flex gap-4">
{["Vrai", "Faux"].map((label, i) => (
<label key={label} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="correct"
checked={correctIndex === i}
onChange={() => setCorrectIndex(i)}
/>
{label}
</label>
))}
</div>
)}
{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" || format === "rebus") && (
<textarea
className={`${inputClass} h-24 py-2`}
placeholder="Réponses acceptées (une par ligne)"
value={answers}
onChange={(e) => setAnswers(e.target.value)}
/>
)}
{create.isError && (
<p className="text-destructive text-sm">{String(create.error)}</p>
)}
<Button
disabled={
create.isPending ||
uploading ||
(!prompt.trim() && format !== "image_reveal") ||
!category.trim() ||
(format === "image_reveal" && !imageUrl)
}
onClick={submit}
>
{create.isPending ? "Ajout…" : "Ajouter la question"}
</Button>
</section>
)
}