feat(admin): manual quiz back-office + rewrite README #7

Merged
ayoub merged 1 commit from feature/quiz-backoffice into dev 2026-06-10 15:25:45 +00:00
11 changed files with 648 additions and 13 deletions
Showing only changes of commit d3ce52b60a - Show all commits

View file

@ -1,21 +1,96 @@
# shadcn/ui monorepo template
# NerdWare
This is a Vite monorepo template with shadcn/ui.
Party game web multi-épreuves, ambiance WarioWare, centré culture geek (jeux
vidéo, manga, pop culture). Chaque joueur joue sur son propre PC/téléphone, les
rooms sont synchronisées en temps réel via Socket.IO.
## Adding components
**V1 : Quiz culture + Blindtest (YouTube)**, mélangeables dans une même partie.
To add components to your app, run the following command at the root of your `web` app:
> Architecture détaillée : [`ARCHITECTURE.md`](./ARCHITECTURE.md) · consignes de
> dev : [`CLAUDE.md`](./CLAUDE.md).
## Stack
| Couche | Choix |
|---|---|
| Langage | TypeScript (strict, front + back) |
| Runtime / package manager | **Bun** |
| Monorepo | Turborepo |
| Backend | Fastify + Socket.IO |
| DB | PostgreSQL + Drizzle ORM (contenu quiz + historique ; rooms en mémoire) |
| Frontend | Vite + React, wouter, Zustand, TanStack Query (back-office) |
| UI | Tailwind v4 + shadcn/ui, Framer Motion, Lucide |
| Audio | YouTube IFrame Player API |
| Avatars | DiceBear (style lorelei), générés en local |
## Structure
```
apps/
web/ Client Vite + React (jeu + back-office)
server/ Fastify + Socket.IO + Drizzle (moteur de jeu autoritaire)
packages/
shared/ Types de domaine + events WS partagés (@nerdware/shared)
ui/ Composants shadcn/ui (@workspace/ui)
```
Le serveur est **autoritaire** : timer, scores, ordre des manches et tirage du
DJ sont tranchés côté serveur ; le client n'affiche que l'état reçu. Chaque
épreuve implémente l'interface `GameRound` (`start / submitAnswer / reveal /
score`) — ajouter un mode = un module, sans toucher à la plomberie.
## Démarrer
Prérequis : [Bun](https://bun.sh) ≥ 1.3, et [Docker](https://www.docker.com)
pour la base (optionnel — sans DB, le quiz tourne sur une banque en dur).
```bash
pnpm dlx shadcn@latest add button -c apps/web
bun install
# (optionnel) base de données
docker compose up -d
cd apps/server && cp .env.example .env
bun run db:migrate # applique les migrations
bun run db:seed # seed Open Trivia DB (~1 req/5s) + banque FR
cd ../..
# config client
cd apps/web && cp .env.example .env && cd ../..
# tout lancer (Turborepo)
bun run dev
```
This will place the ui components in the `packages/ui/src/components` directory.
- Client : http://localhost:5173
- Serveur : http://localhost:3001
## Using components
Crée une room, choisis ton pseudo, partage le **lien d'invitation**, et lancez
une partie (quiz, blindtest ou mixte).
To use the components in your app, import them from the `ui` package.
## Scripts (racine)
```tsx
import { Button } from "@workspace/ui/components/button";
```bash
bun run dev # tout en dev
bun run build # build complet
bun run lint # lint
bun run typecheck # type-check
bun run test # tests (bun test)
bun run format # prettier
```
Serveur (`apps/server`) : `db:generate`, `db:migrate`, `db:seed`, `db:studio`.
## Modes de jeu
- **Quiz** — QCM, vrai/faux, et réponse libre (matching tolérant). Bonus de
rapidité. Questions issues d'Open Trivia DB (seed) ou du back-office.
- **Blindtest** — chaque joueur soumet des liens YouTube ; un DJ neutre pilote
la lecture, les autres devinent (titre/artiste, qui l'a ajouté, ou mixte).
Nécessite ≥ 3 joueurs. Récap des titres + liens en fin de partie.
- **Mixte** (défaut) — séquence mélangée de manches quiz et blindtest.
## Back-office quiz
Page protégée `/admin` pour écrire des questions manuelles (QCM, vrai/faux,
réponse libre). Définir `ADMIN_TOKEN` dans `apps/server/.env` et une `DATABASE_URL`
valide. Le jeton est demandé à la connexion sur `/admin`.

View file

@ -4,3 +4,5 @@ PORT=3001
CORS_ORIGINS=http://localhost:5173
# Laisser vide pour jouer sans DB (banque de questions en dur).
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
ADMIN_TOKEN=change-me

View file

@ -0,0 +1,151 @@
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
// CRUD des questions manuelles écrites en base (source 'manual').
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
type AdminFormat = (typeof FORMATS)[number]
interface CreateBody {
format?: string
prompt?: string
category?: string
difficulty?: number
choices?: string[]
correctIndex?: number
acceptedAnswers?: string[]
}
/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */
function validate(
body: CreateBody,
categoryId: string
): { error: string } | { value: NewQuizQuestion } {
const format = body.format as AdminFormat
if (!FORMATS.includes(format)) {
return { error: "Format invalide." }
}
const prompt = (body.prompt ?? "").trim()
if (prompt.length === 0) {
return { error: "L'intitulé est obligatoire." }
}
const difficulty = Number(body.difficulty)
if (![1, 2, 3].includes(difficulty)) {
return { error: "Difficulté invalide (1 à 3)." }
}
const base = { categoryId, format, prompt, difficulty, source: "manual" }
if (format === "free") {
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 } }
}
// mcq / truefalse
const choices = (body.choices ?? []).map((c) => c.trim()).filter(Boolean)
const min = format === "truefalse" ? 2 : 2
if (choices.length < min) {
return { error: "Au moins deux choix sont requis." }
}
const correctIndex = Number(body.correctIndex)
if (
!Number.isInteger(correctIndex) ||
correctIndex < 0 ||
correctIndex >= choices.length
) {
return { error: "La bonne réponse est invalide." }
}
return { value: { ...base, choices, correctIndex } }
}
export async function adminRoutes(app: FastifyInstance) {
// Auth (scopée à ce plugin) : ADMIN_TOKEN configuré + Bearer valide + DB up.
app.addHook("preHandler", async (req, reply) => {
if (!env.adminToken) {
return reply.code(503).send({ error: "Back-office désactivé (ADMIN_TOKEN manquant)." })
}
const header = req.headers.authorization ?? ""
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
if (token !== env.adminToken) {
return reply.code(401).send({ error: "Non autorisé." })
}
if (!db) {
return reply.code(503).send({ error: "Base de données indisponible." })
}
})
app.get("/categories", async () => {
const rows = await db!
.select({ id: quizCategory.id, name: quizCategory.name })
.from(quizCategory)
.orderBy(quizCategory.name)
return rows
})
app.get("/questions", async () => {
const rows = await db!
.select({
id: quizQuestion.id,
format: quizQuestion.format,
prompt: quizQuestion.prompt,
difficulty: quizQuestion.difficulty,
source: quizQuestion.source,
choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
category: quizCategory.name,
})
.from(quizQuestion)
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.orderBy(desc(quizQuestion.createdAt))
.limit(500)
return rows
})
app.post("/questions", async (req, reply) => {
const body = (req.body ?? {}) as CreateBody
const categoryName = (body.category ?? "").trim()
if (categoryName.length === 0) {
return reply.code(400).send({ error: "La catégorie est obligatoire." })
}
await db!.insert(quizCategory).values({ name: categoryName }).onConflictDoNothing()
const [cat] = await db!
.select({ id: quizCategory.id })
.from(quizCategory)
.where(eq(quizCategory.name, categoryName))
const result = validate(body, cat.id)
if ("error" in result) {
return reply.code(400).send({ error: result.error })
}
const inserted = await db!
.insert(quizQuestion)
.values(result.value)
.onConflictDoNothing()
.returning({ id: quizQuestion.id })
if (inserted.length === 0) {
return reply.code(409).send({ error: "Une question avec cet intitulé existe déjà." })
}
return reply.code(201).send({ id: inserted[0].id })
})
app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => {
const deleted = await db!
.delete(quizQuestion)
.where(eq(quizQuestion.id, req.params.id))
.returning({ id: quizQuestion.id })
if (deleted.length === 0) {
return reply.code(404).send({ error: "Question introuvable." })
}
return { ok: true }
})
}

View file

@ -12,6 +12,8 @@ export const env = {
port: num(process.env.PORT, 3001),
/** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */
databaseUrl: process.env.DATABASE_URL ?? "",
/** Jeton du back-office quiz. Vide → back-office désactivé. */
adminToken: process.env.ADMIN_TOKEN ?? "",
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
.split(",")

View file

@ -2,6 +2,7 @@ import Fastify from "fastify"
import cors from "@fastify/cors"
import { env, isDev } from "./env"
import { createSocketServer } from "./socket"
import { adminRoutes } from "./admin/routes"
import "./game/modes" // enregistre les épreuves (registerRound)
const app = Fastify({
@ -14,6 +15,8 @@ await app.register(cors, { origin: env.corsOrigins })
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
await app.register(adminRoutes, { prefix: "/api/admin" })
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
const io = createSocketServer(app.server)

View file

@ -15,6 +15,7 @@
"@dicebear/core": "^10",
"@dicebear/styles": "^10",
"@nerdware/shared": "workspace:*",
"@tanstack/react-query": "^5.101.0",
"@workspace/ui": "workspace:*",
"framer-motion": "^11",
"lucide-react": "^1.17.0",

View file

@ -2,6 +2,7 @@ import { Route, Switch } from "wouter"
import { HomePage } from "@/pages/home"
import { JoinPage } from "@/pages/join"
import { RoomPage } from "@/pages/room"
import { AdminPage } from "@/pages/admin"
export function App() {
return (
@ -13,6 +14,7 @@ export function App() {
<Route path="/room/:code">
{(params) => <RoomPage code={params.code.toUpperCase()} />}
</Route>
<Route path="/admin" component={AdminPage} />
<Route>
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">Page introuvable.</p>

View file

@ -0,0 +1,65 @@
// Accès HTTP au back-office quiz (TanStack Query l'enrobe côté composant).
import type { QuizFormat } from "@nerdware/shared"
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
const TOKEN_KEY = "nerdware-admin-token"
export function getAdminToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ""
}
export function setAdminToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearAdminToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export interface AdminQuestion {
id: string
format: QuizFormat
prompt: string
difficulty: number
source: string
choices: string[] | null
correctIndex: number | null
acceptedAnswers: string[] | null
category: string | null
}
export interface NewQuestionInput {
format: QuizFormat
prompt: string
category: string
difficulty: number
choices?: string[]
correctIndex?: number
acceptedAnswers?: string[]
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${SERVER_URL}/api/admin${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getAdminToken()}`,
...init?.headers,
},
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `Erreur ${res.status}`)
}
return res.status === 204 ? (undefined as T) : ((await res.json()) as T)
}
export const adminApi = {
listQuestions: () => request<AdminQuestion[]>("/questions"),
createQuestion: (input: NewQuestionInput) =>
request<{ id: string }>("/questions", {
method: "POST",
body: JSON.stringify(input),
}),
deleteQuestion: (id: string) =>
request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }),
}

View file

@ -1,14 +1,19 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import "@workspace/ui/globals.css"
import { App } from "./App.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
const queryClient = new QueryClient()
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>
)

View file

@ -0,0 +1,324 @@
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>
)
}

View file

@ -41,6 +41,7 @@
"@dicebear/core": "^10",
"@dicebear/styles": "^10",
"@nerdware/shared": "workspace:*",
"@tanstack/react-query": "^5.101.0",
"@workspace/ui": "workspace:*",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
@ -556,6 +557,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="],
"@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="],
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="],