Back-office (roadmap V1 step 7): - server: token-protected HTTP API under /api/admin (ADMIN_TOKEN), Drizzle-backed CRUD for manual questions — list, create (mcq/truefalse/free, category upsert, dedup on prompt, validation), delete; 401/503 when unauthorized/unconfigured - client: /admin page with token login + TanStack Query (list/create/delete), format-aware form (QCM / vrai-faux / réponse libre) - env: ADMIN_TOKEN README: replace the shadcn template with a real NerdWare overview (stack, structure, getting started, scripts, game modes, back-office). Verified against real Postgres: auth (401), create (201), dedup (409), validation (400), list, delete (200/404). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
324 lines
9.2 KiB
TypeScript
324 lines
9.2 KiB
TypeScript
import { useState } from "react"
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { Link } from "wouter"
|
|
import { Trash2 } from "lucide-react"
|
|
import type { QuizFormat } from "@nerdware/shared"
|
|
import { Button } from "@workspace/ui/components/button"
|
|
import {
|
|
adminApi,
|
|
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 unauthorized =
|
|
questions.isError && /401|autoris/i.test(String(questions.error))
|
|
|
|
return (
|
|
<div className="mx-auto flex min-h-svh w-full max-w-2xl 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>
|
|
) : (
|
|
<>
|
|
<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)}
|
|
deleting={remove.isPending}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function QuestionRow({
|
|
q,
|
|
onDelete,
|
|
deleting,
|
|
}: {
|
|
q: AdminQuestion
|
|
onDelete: () => void
|
|
deleting: boolean
|
|
}) {
|
|
return (
|
|
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3">
|
|
<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" ? (
|
|
<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>
|
|
<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 [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
|
const [correctIndex, setCorrectIndex] = useState(0)
|
|
const [answers, setAnswers] = useState("")
|
|
|
|
const create = useMutation({
|
|
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
|
|
onSuccess: () => {
|
|
setPrompt("")
|
|
setChoices(EMPTY_CHOICES)
|
|
setCorrectIndex(0)
|
|
setAnswers("")
|
|
onCreated()
|
|
},
|
|
})
|
|
|
|
function submit() {
|
|
const input: NewQuestionInput = { format, prompt, category, difficulty }
|
|
if (format === "free") {
|
|
input.acceptedAnswers = answers
|
|
.split("\n")
|
|
.map((a) => a.trim())
|
|
.filter(Boolean)
|
|
} 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>
|
|
</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>
|
|
</div>
|
|
|
|
<input
|
|
className={inputClass}
|
|
placeholder="Catégorie (ex: Jeux vidéo)"
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
/>
|
|
<input
|
|
className={inputClass}
|
|
placeholder="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 === "free" && (
|
|
<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 || !prompt.trim() || !category.trim()}
|
|
onClick={submit}
|
|
>
|
|
{create.isPending ? "Ajout…" : "Ajouter la question"}
|
|
</Button>
|
|
</section>
|
|
)
|
|
}
|