Compare commits
No commits in common. "04850bf74944bbb0025e9087d761e1f55117b145" and "aa386f39a23eab724efa92706c4a429f5eefdd9d" have entirely different histories.
04850bf749
...
aa386f39a2
11 changed files with 13 additions and 648 deletions
95
README.md
95
README.md
|
|
@ -1,96 +1,21 @@
|
||||||
# NerdWare
|
# shadcn/ui monorepo template
|
||||||
|
|
||||||
Party game web multi-épreuves, ambiance WarioWare, centré culture geek (jeux
|
This is a Vite monorepo template with shadcn/ui.
|
||||||
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.
|
|
||||||
|
|
||||||
**V1 : Quiz culture + Blindtest (YouTube)**, mélangeables dans une même partie.
|
## Adding components
|
||||||
|
|
||||||
> Architecture détaillée : [`ARCHITECTURE.md`](./ARCHITECTURE.md) · consignes de
|
To add components to your app, run the following command at the root of your `web` app:
|
||||||
> 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
|
||||||
bun install
|
pnpm dlx shadcn@latest add button -c apps/web
|
||||||
|
|
||||||
# (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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Client : http://localhost:5173
|
This will place the ui components in the `packages/ui/src/components` directory.
|
||||||
- Serveur : http://localhost:3001
|
|
||||||
|
|
||||||
Crée une room, choisis ton pseudo, partage le **lien d'invitation**, et lancez
|
## Using components
|
||||||
une partie (quiz, blindtest ou mixte).
|
|
||||||
|
|
||||||
## Scripts (racine)
|
To use the components in your app, import them from the `ui` package.
|
||||||
|
|
||||||
```bash
|
```tsx
|
||||||
bun run dev # tout en dev
|
import { Button } from "@workspace/ui/components/button";
|
||||||
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,5 +4,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
// 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,8 +12,6 @@ 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,7 +2,6 @@ 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({
|
||||||
|
|
@ -15,8 +14,6 @@ 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,7 +15,6 @@
|
||||||
"@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,7 +2,6 @@ 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 (
|
||||||
|
|
@ -14,7 +13,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
// 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,19 +1,14 @@
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
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,7 +41,6 @@
|
||||||
"@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",
|
||||||
|
|
@ -557,10 +556,6 @@
|
||||||
|
|
||||||
"@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