From d3ce52b60a93cd36979c85b4cadd0cb53394c988 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 16:43:19 +0200 Subject: [PATCH] feat(admin): manual quiz back-office + rewrite README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 95 +++++++++- apps/server/.env.example | 2 + apps/server/src/admin/routes.ts | 151 +++++++++++++++ apps/server/src/env.ts | 2 + apps/server/src/index.ts | 3 + apps/web/package.json | 1 + apps/web/src/App.tsx | 2 + apps/web/src/lib/admin-api.ts | 65 +++++++ apps/web/src/main.tsx | 11 +- apps/web/src/pages/admin.tsx | 324 ++++++++++++++++++++++++++++++++ bun.lock | 5 + 11 files changed, 648 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/admin/routes.ts create mode 100644 apps/web/src/lib/admin-api.ts create mode 100644 apps/web/src/pages/admin.tsx diff --git a/README.md b/README.md index f635370..cf72be1 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/apps/server/.env.example b/apps/server/.env.example index e928b54..f7af472 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -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 diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts new file mode 100644 index 0000000..32491d0 --- /dev/null +++ b/apps/server/src/admin/routes.ts @@ -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 } + }) +} diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 169dbfa..e7e1220 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -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(",") diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 5990a3f..35b9fc7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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) diff --git a/apps/web/package.json b/apps/web/package.json index ce1306e..7a975fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6a275ee..e3b02f1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { {(params) => } +

Page introuvable.

diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts new file mode 100644 index 0000000..73735b8 --- /dev/null +++ b/apps/web/src/lib/admin-api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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("/questions"), + createQuestion: (input: NewQuestionInput) => + request<{ id: string }>("/questions", { + method: "POST", + body: JSON.stringify(input), + }), + deleteQuestion: (id: string) => + request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }), +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 242ad82..6d99f3c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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( - - - + + + + + ) diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx new file mode 100644 index 0000000..91523ea --- /dev/null +++ b/apps/web/src/pages/admin.tsx @@ -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 + } + return ( + { + clearAdminToken() + setToken("") + }} + /> + ) +} + +function Login({ onAuth }: { onAuth: (token: string) => void }) { + const [value, setValue] = useState("") + return ( +
+
+

+ Back-office quiz +

+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) { + setAdminToken(value.trim()) + onAuth(value.trim()) + } + }} + /> + + + + +
+
+ ) +} + +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 ( +
+
+

Back-office quiz

+ +
+ + {unauthorized ? ( +

+ Jeton invalide ou back-office indisponible.{" "} + +

+ ) : ( + <> + + qc.invalidateQueries({ queryKey: ["admin-questions"] }) + } + /> + +
+

+ Questions ({questions.data?.length ?? 0}) +

+ {questions.isLoading && ( +

Chargement…

+ )} +
    + {questions.data?.map((q) => ( + remove.mutate(q.id)} + deleting={remove.isPending} + /> + ))} +
+
+ + )} +
+ ) +} + +function QuestionRow({ + q, + onDelete, + deleting, +}: { + q: AdminQuestion + onDelete: () => void + deleting: boolean +}) { + return ( +
  • +
    +

    {q.prompt}

    +

    + {q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source} +

    + {q.format === "free" ? ( +

    + ✔ {q.acceptedAnswers?.join(", ")} +

    + ) : ( +

    + {q.choices + ?.map((c, i) => (i === q.correctIndex ? `✔ ${c}` : c)) + .join(" · ")} +

    + )} +
    + +
  • + ) +} + +const EMPTY_CHOICES = ["", "", "", ""] + +function QuestionForm({ onCreated }: { onCreated: () => void }) { + const [format, setFormat] = useState("mcq") + const [prompt, setPrompt] = useState("") + const [category, setCategory] = useState("") + const [difficulty, setDifficulty] = useState(1) + const [choices, setChoices] = useState(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 ( +
    +

    Nouvelle question

    + +
    + + +
    + + setCategory(e.target.value)} + /> + setPrompt(e.target.value)} + /> + + {format === "mcq" && ( +
    + {choices.map((choice, i) => ( + + ))} +

    + Coche la bonne réponse. Les choix vides sont ignorés (min. 2). +

    +
    + )} + + {format === "truefalse" && ( +
    + {["Vrai", "Faux"].map((label, i) => ( + + ))} +
    + )} + + {format === "free" && ( +