- end screen: wider container (max-w-4xl), podium/awards stay centered, and the two long sections (musiques + récap des manches) sit side-by-side on desktop - back-office: 2-column layout (form | questions list) on desktop, wider Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
import { useRef, useState } from "react"
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { Link } from "wouter"
|
|
import { Trash2, Upload } from "lucide-react"
|
|
import type { QuizFormat } from "@nerdware/shared"
|
|
import { Button } from "@workspace/ui/components/button"
|
|
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 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)}
|
|
deleting={remove.isPending}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</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">
|
|
{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 === "image_reveal" ? (
|
|
<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 [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 }
|
|
if (format === "free") {
|
|
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>
|
|
</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={
|
|
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") && (
|
|
<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>
|
|
)
|
|
}
|