release/0.1.0 #18
11 changed files with 648 additions and 13 deletions
95
README.md
95
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
|
```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
|
```bash
|
||||||
import { Button } from "@workspace/ui/components/button";
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,5 @@ PORT=3001
|
||||||
CORS_ORIGINS=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173
|
||||||
# Laisser vide pour jouer sans DB (banque de questions en dur).
|
# Laisser vide pour jouer sans DB (banque de questions en dur).
|
||||||
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
|
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware
|
||||||
|
# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver.
|
||||||
|
ADMIN_TOKEN=change-me
|
||||||
|
|
|
||||||
151
apps/server/src/admin/routes.ts
Normal file
151
apps/server/src/admin/routes.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ export const env = {
|
||||||
port: num(process.env.PORT, 3001),
|
port: num(process.env.PORT, 3001),
|
||||||
/** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */
|
/** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
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). */
|
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
|
||||||
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
|
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import Fastify from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
import { env, isDev } from "./env"
|
import { env, isDev } from "./env"
|
||||||
import { createSocketServer } from "./socket"
|
import { createSocketServer } from "./socket"
|
||||||
|
import { adminRoutes } from "./admin/routes"
|
||||||
import "./game/modes" // enregistre les épreuves (registerRound)
|
import "./game/modes" // enregistre les épreuves (registerRound)
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -14,6 +15,8 @@ await app.register(cors, { origin: env.corsOrigins })
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
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.
|
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
||||||
const io = createSocketServer(app.server)
|
const io = createSocketServer(app.server)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"@dicebear/core": "^10",
|
"@dicebear/core": "^10",
|
||||||
"@dicebear/styles": "^10",
|
"@dicebear/styles": "^10",
|
||||||
"@nerdware/shared": "workspace:*",
|
"@nerdware/shared": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@workspace/ui": "workspace:*",
|
"@workspace/ui": "workspace:*",
|
||||||
"framer-motion": "^11",
|
"framer-motion": "^11",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Route, Switch } from "wouter"
|
||||||
import { HomePage } from "@/pages/home"
|
import { HomePage } from "@/pages/home"
|
||||||
import { JoinPage } from "@/pages/join"
|
import { JoinPage } from "@/pages/join"
|
||||||
import { RoomPage } from "@/pages/room"
|
import { RoomPage } from "@/pages/room"
|
||||||
|
import { AdminPage } from "@/pages/admin"
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -13,6 +14,7 @@ export function App() {
|
||||||
<Route path="/room/:code">
|
<Route path="/room/:code">
|
||||||
{(params) => <RoomPage code={params.code.toUpperCase()} />}
|
{(params) => <RoomPage code={params.code.toUpperCase()} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/admin" component={AdminPage} />
|
||||||
<Route>
|
<Route>
|
||||||
<div className="flex min-h-svh items-center justify-center p-6">
|
<div className="flex min-h-svh items-center justify-center p-6">
|
||||||
<p className="text-muted-foreground text-sm">Page introuvable.</p>
|
<p className="text-muted-foreground text-sm">Page introuvable.</p>
|
||||||
|
|
|
||||||
65
apps/web/src/lib/admin-api.ts
Normal file
65
apps/web/src/lib/admin-api.ts
Normal 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" }),
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client"
|
import { createRoot } from "react-dom/client"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
|
||||||
import "@workspace/ui/globals.css"
|
import "@workspace/ui/globals.css"
|
||||||
import { App } from "./App.tsx"
|
import { App } from "./App.tsx"
|
||||||
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
324
apps/web/src/pages/admin.tsx
Normal file
324
apps/web/src/pages/admin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -41,6 +41,7 @@
|
||||||
"@dicebear/core": "^10",
|
"@dicebear/core": "^10",
|
||||||
"@dicebear/styles": "^10",
|
"@dicebear/styles": "^10",
|
||||||
"@nerdware/shared": "workspace:*",
|
"@nerdware/shared": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@workspace/ui": "workspace:*",
|
"@workspace/ui": "workspace:*",
|
||||||
"framer-motion": "^11",
|
"framer-motion": "^11",
|
||||||
"lucide-react": "^1.17.0",
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue