Merge pull request 'release/0.1.0' (#18) from release/0.1.0 into main

Reviewed-on: #18
This commit is contained in:
ayoub 2026-06-11 18:36:42 +00:00
commit 8598141a43
106 changed files with 10021 additions and 79 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
**/node_modules
**/dist
**/.turbo
**/uploads
.git
.github
**/*.log
**/.env
**/.env.*
!**/.env.example
**/.DS_Store

4
.gitignore vendored
View file

@ -14,6 +14,7 @@ dist-ssr
# env files (can opt-in for committing if needed)
.env*
!.env.example
# turbo
.turbo
@ -31,3 +32,6 @@ dist-ssr
# typescript
*.tsbuildinfo
# uploads (image_reveal) servies en statique
uploads/

34
CHANGELOG.md Normal file
View file

@ -0,0 +1,34 @@
# Changelog
Toutes les évolutions notables de NerdWare. Format basé sur
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
[SemVer](https://semver.org/lang/fr/).
## [0.1.0] — 2026-06-11
Première version déployable. Cycle de jeu temps réel complet (lobby → manches →
fin), trois modes, back-office et internationalisation.
### Ajouté
- **Temps réel** : rooms en mémoire, synchronisation Socket.IO, serveur
autoritaire (timer, scores, ordre des manches), moteur de jeu générique.
- **Modes de jeu** : Quiz (QCM / vrai-faux / réponse libre), Images (révélation
progressive floutée), Blindtest (YouTube, DJ neutre, modes titre & artiste /
qui l'a ajouté / mixte), et mode Mixte avec ordre mélangé.
- **Réglages** : compteur par sous-mode, filtre de catégories, durée, titres par
joueur, difficulté des bots.
- **Bots** : joueurs virtuels qui votent automatiquement (jamais DJ).
- **Fin de partie** : podium animé, récompenses, récapitulatif des manches,
export playlist (Spotify / YouTube), copie des résultats.
- **Reconnexion** : reprise de sa place après un refresh ou une coupure réseau ;
bouton « Quitter ».
- **Back-office** quiz (token) : CRUD des questions, upload d'images, langue,
activation/désactivation.
- **Historique** des parties par room.
- **i18n** FR / EN de l'interface (détection navigateur + sélecteur).
- **Persistance** : PostgreSQL + Drizzle (contenu quiz + historique).
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
prêt pour Dokploy.
[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0

138
README.md
View file

@ -1,21 +1,139 @@
# 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
**Quiz culture · Images (révélation) · Blindtest (YouTube)**, mélangeables dans
une même partie. Interface FR / EN, reconnexion après refresh, bots pour jouer à
plusieurs tout seul.
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
bun run db:seed:images # (optionnel) seed images via PokéAPI (mode Images)
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.
- **Images** — une image se dé-floute progressivement, on devine le sujet
(réponse libre). Seed PokéAPI ou images uploadées dans le 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 **dont 2 réels** (un bot ne peut pas être DJ). Récap des
titres + liens en fin de partie.
- **Mixte** (défaut) — séquence mélangée de manches, par sous-modes au choix.
Filtre par catégories, durée et nombre de manches réglables, et **bots** (niveau
réglable) pour compléter une partie.
## Back-office quiz
Page protégée `/admin` pour écrire des questions manuelles (QCM, vrai/faux,
réponse libre, image), choisir leur langue et les activer/désactiver. Définir
`ADMIN_TOKEN` dans `apps/server/.env` et une `DATABASE_URL` valide. Le jeton est
demandé à la connexion sur `/admin`.
## Déploiement (Dokploy)
La stack de prod est décrite dans
[`docker-compose.prod.yml`](./docker-compose.prod.yml) : `postgres`, `server`
(Bun) et `web` (build statique servi par nginx). Voir
[`.env.production.example`](./.env.production.example) pour les variables.
1. **Dokploy → Create → Compose**, pointant sur ce repo et `docker-compose.prod.yml`.
2. Renseigner les variables d'environnement : `POSTGRES_PASSWORD`, `ADMIN_TOKEN`,
`CORS_ORIGINS` (domaine du client), `VITE_SERVER_URL` (URL publique du serveur).
3. Assigner les **domaines** via Traefik : le service `web` (port 80) sur le
domaine principal, le service `server` (port 3001) sur le sous-domaine d'API.
`VITE_SERVER_URL` doit pointer vers ce domaine d'API, et `CORS_ORIGINS` vers
celui du client.
4. Déployer. Les **migrations** s'appliquent automatiquement au démarrage du
serveur. Pour peupler le contenu (one-shot) :
`docker compose -f docker-compose.prod.yml exec server bun run db:seed`.
Les images uploadées sont persistées dans le volume `uploads`, la base dans
`pgdata`.
> Test local de la stack de prod :
> `docker compose -f docker-compose.prod.yml up --build` (après avoir copié
> `.env.production.example` en `.env`).
## Versionnement & gitflow
- Branches : `main` (production, taguée), `dev` (intégration), `feature/*`,
`release/*`.
- Une **release** se coupe depuis `dev` (`release/x.y.z`), y reçoit le bump de
version + le CHANGELOG, puis fusionne dans `main` (taguée `vX.Y.Z`) et revient
dans `dev`. Voir [`CHANGELOG.md`](./CHANGELOG.md). Versionnage [SemVer](https://semver.org).

10
apps/server/.env.example Normal file
View file

@ -0,0 +1,10 @@
NODE_ENV=development
HOST=0.0.0.0
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
# Dossier de stockage des images (image_reveal), servi sur /uploads.
UPLOADS_DIR=./uploads

23
apps/server/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Serveur Fastify + Socket.IO (Bun, monorepo). Contexte de build = racine du repo.
FROM oven/bun:1.3.14-alpine
WORKDIR /app
# Manifests d'abord (cache des dépendances).
COPY package.json bun.lock turbo.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN bun install --frozen-lockfile
# Sources nécessaires au serveur (le client n'est pas requis ici).
COPY packages/shared packages/shared
COPY apps/server apps/server
WORKDIR /app/apps/server
ENV NODE_ENV=production
EXPOSE 3001
# Applique les migrations (idempotent) puis démarre le serveur.
CMD ["sh", "-c", "bun src/db/migrate.ts && bun src/index.ts"]

View file

@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
})

View file

@ -0,0 +1,39 @@
CREATE TABLE "game_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"room_code" text NOT NULL,
"played_at" timestamp with time zone DEFAULT now() NOT NULL,
"modes" jsonb,
"results" jsonb
);
--> statement-breakpoint
CREATE TABLE "quiz_category" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "quiz_category_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "quiz_played" (
"question_id" uuid NOT NULL,
"room_code" text NOT NULL,
"played_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "quiz_played_question_id_room_code_pk" PRIMARY KEY("question_id","room_code")
);
--> statement-breakpoint
CREATE TABLE "quiz_question" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"category_id" uuid,
"format" text NOT NULL,
"prompt" text NOT NULL,
"difficulty" integer DEFAULT 1 NOT NULL,
"source" text NOT NULL,
"choices" jsonb,
"correct_index" integer,
"accepted_answers" jsonb,
"image_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "quiz_question_prompt_unique" UNIQUE("prompt")
);
--> statement-breakpoint
ALTER TABLE "quiz_played" ADD CONSTRAINT "quiz_played_question_id_quiz_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."quiz_question"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_question" ADD CONSTRAINT "quiz_question_category_id_quiz_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."quiz_category"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "quiz_question" DROP CONSTRAINT "quiz_question_prompt_unique";--> statement-breakpoint
CREATE UNIQUE INDEX "quiz_question_prompt_unique" ON "quiz_question" USING btree ("prompt") WHERE "quiz_question"."format" <> 'image_reveal';--> statement-breakpoint
CREATE UNIQUE INDEX "quiz_question_image_unique" ON "quiz_question" USING btree ("image_url") WHERE "quiz_question"."image_url" is not null;

View file

@ -0,0 +1,2 @@
ALTER TABLE "quiz_question" ADD COLUMN "lang" text DEFAULT 'fr' NOT NULL;--> statement-breakpoint
ALTER TABLE "quiz_question" ADD COLUMN "active" boolean DEFAULT true NOT NULL;

View file

@ -0,0 +1,263 @@
{
"id": "c68e2f59-3d1b-4fca-8443-17fe243e2919",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"nullsNotDistinct": false,
"columns": [
"prompt"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,288 @@
{
"id": "6de8c2ee-3634-455b-ba4b-372368fc2e66",
"prevId": "c68e2f59-3d1b-4fca-8443-17fe243e2919",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"columns": [
{
"expression": "prompt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"format\" <> 'image_reveal'",
"concurrently": false,
"method": "btree",
"with": {}
},
"quiz_question_image_unique": {
"name": "quiz_question_image_unique",
"columns": [
{
"expression": "image_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"image_url\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,302 @@
{
"id": "7df3d3d1-dbf0-445e-a5c1-5ef4872a80de",
"prevId": "6de8c2ee-3634-455b-ba4b-372368fc2e66",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lang": {
"name": "lang",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'fr'"
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"columns": [
{
"expression": "prompt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"format\" <> 'image_reveal'",
"concurrently": false,
"method": "btree",
"with": {}
},
"quiz_question_image_unique": {
"name": "quiz_question_image_unique",
"columns": [
{
"expression": "image_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"image_url\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781085893179,
"tag": "0000_loose_doctor_spectrum",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781129455626,
"tag": "0001_fancy_captain_stacy",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1781185531830,
"tag": "0002_lowly_tenebrous",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,15 @@
import js from "@eslint/js"
import globals from "globals"
import tseslint from "typescript-eslint"
import { defineConfig, globalIgnores } from "eslint/config"
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.ts"],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
globals: globals.node,
},
},
])

41
apps/server/package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "@nerdware/server",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"build": "bun build ./src/index.ts --target bun --outdir dist",
"lint": "eslint",
"format": "prettier --write \"**/*.ts\"",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun src/db/migrate.ts",
"db:seed": "bun src/db/seed.ts",
"db:seed:images": "bun src/db/seed-images.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cors": "^11",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@nerdware/shared": "workspace:*",
"drizzle-orm": "^0.45",
"fastify": "^5",
"postgres": "^3.4",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^10",
"@types/bun": "latest",
"@types/node": "^24",
"drizzle-kit": "^0.31",
"eslint": "^10",
"globals": "^17",
"pino-pretty": "^13",
"typescript": "~6",
"typescript-eslint": "^8"
}
}

View file

@ -0,0 +1,216 @@
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
// CRUD des questions manuelles écrites en base (source 'manual').
import { randomUUID } from "node:crypto"
import { mkdir, writeFile } from "node:fs/promises"
import { extname, resolve } from "node:path"
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", "image_reveal"] as const
type AdminFormat = (typeof FORMATS)[number]
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
interface CreateBody {
format?: string
prompt?: string
category?: string
difficulty?: number
lang?: string
choices?: string[]
correctIndex?: number
acceptedAnswers?: string[]
imageUrl?: 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." }
}
// L'intitulé est optionnel pour image_reveal (défaut générique).
const prompt =
(body.prompt ?? "").trim() ||
(format === "image_reveal" ? "Qui est-ce ?" : "")
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",
lang: body.lang === "en" ? "en" : "fr",
}
if (format === "free" || format === "image_reveal") {
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." }
}
if (format === "image_reveal" && !body.imageUrl) {
return { error: "Une image est requise." }
}
return {
value: {
...base,
acceptedAnswers: answers,
imageUrl: format === "image_reveal" ? body.imageUrl : undefined,
},
}
}
// 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.post("/upload", async (req, reply) => {
const file = await req.file()
if (!file) {
return reply.code(400).send({ error: "Aucun fichier reçu." })
}
if (!file.mimetype.startsWith("image/")) {
return reply.code(400).send({ error: "Le fichier doit être une image." })
}
const ext = extname(file.filename ?? "").toLowerCase()
const safeExt = IMAGE_EXT.includes(ext) ? ext : ".jpg"
const name = `${randomUUID()}${safeExt}`
const buffer = await file.toBuffer()
// Crée le dossier si besoin (résilient s'il a été supprimé après le démarrage).
await mkdir(resolve(env.uploadsDir), { recursive: true })
await writeFile(resolve(env.uploadsDir, name), buffer)
return { url: `/uploads/${name}` }
})
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,
imageUrl: quizQuestion.imageUrl,
lang: quizQuestion.lang,
active: quizQuestion.active,
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.patch<{ Params: { id: string }; Body: { active?: boolean } }>(
"/questions/:id",
async (req, reply) => {
const active = req.body?.active
if (typeof active !== "boolean") {
return reply.code(400).send({ error: "Champ 'active' requis." })
}
const updated = await db!
.update(quizQuestion)
.set({ active })
.where(eq(quizQuestion.id, req.params.id))
.returning({ id: quizQuestion.id })
if (updated.length === 0) {
return reply.code(404).send({ error: "Question introuvable." })
}
return { ok: true }
}
)
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

@ -0,0 +1,55 @@
// Historique de parties (table game_history). No-op si pas de DB.
import { desc, eq } from "drizzle-orm"
import { db } from "./index"
import { gameHistory } from "./schema"
export interface GameResultEntry {
name: string
score: number
}
export async function saveGameHistory(
roomCode: string,
modes: string[],
results: GameResultEntry[]
): Promise<void> {
if (!db) {
return
}
await db.insert(gameHistory).values({ roomCode, modes, results })
}
export interface GameHistoryRow {
id: string
playedAt: string
modes: string[]
results: GameResultEntry[]
}
export async function listGameHistory(
roomCode: string,
limit = 10
): Promise<GameHistoryRow[]> {
if (!db) {
return []
}
const rows = await db
.select({
id: gameHistory.id,
playedAt: gameHistory.playedAt,
modes: gameHistory.modes,
results: gameHistory.results,
})
.from(gameHistory)
.where(eq(gameHistory.roomCode, roomCode))
.orderBy(desc(gameHistory.playedAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
playedAt:
r.playedAt instanceof Date ? r.playedAt.toISOString() : String(r.playedAt),
modes: (r.modes as string[]) ?? [],
results: (r.results as GameResultEntry[]) ?? [],
}))
}

View file

@ -0,0 +1,21 @@
// Client Drizzle/PostgreSQL. Optionnel : sans DATABASE_URL, `db` vaut null et
// le jeu retombe sur la banque de questions en dur (dev sans infra).
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../env"
import * as schema from "./schema"
export type Database = ReturnType<typeof drizzle<typeof schema>>
function createDb(): Database | null {
if (!env.databaseUrl) {
return null
}
const client = postgres(env.databaseUrl)
return drizzle(client, { schema })
}
export const db = createDb()
export const hasDb = db !== null
export { schema }

View file

@ -0,0 +1,18 @@
// Applique les migrations générées (drizzle/*.sql) à la base.
// bun run db:migrate
import { drizzle } from "drizzle-orm/postgres-js"
import { migrate } from "drizzle-orm/postgres-js/migrator"
import postgres from "postgres"
import { env } from "../env"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — rien à migrer.")
process.exit(1)
}
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client)
await migrate(db, { migrationsFolder: "./drizzle" })
console.log("Migrations appliquées.")
await client.end()

View file

@ -0,0 +1,100 @@
// Accès aux questions de quiz en base. No-op si pas de DB (db === null).
import { and, eq, inArray, notInArray, sql } from "drizzle-orm"
import { db } from "./index"
import { quizCategory, quizPlayed, quizQuestion } from "./schema"
import type { QuizQuestion } from "../game/modes/quiz/questions"
/**
* Pool de questions mcq/truefalse non encore jouées par cette room,
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
*/
/** Catégories existantes (pour le filtre du lobby). */
export async function listCategories(): Promise<string[]> {
if (!db) {
return []
}
const rows = await db
.select({ name: quizCategory.name })
.from(quizCategory)
.orderBy(quizCategory.name)
return rows.map((r) => r.name)
}
export async function loadQuizPool(
roomCode: string,
limit: number,
formats: string[],
categories: string[] = []
): Promise<QuizQuestion[]> {
if (!db || formats.length === 0) {
return []
}
const alreadyPlayed = db
.select({ id: quizPlayed.questionId })
.from(quizPlayed)
.where(eq(quizPlayed.roomCode, roomCode))
const rows = await db
.select({
id: quizQuestion.id,
format: quizQuestion.format,
prompt: quizQuestion.prompt,
choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
imageUrl: quizQuestion.imageUrl,
difficulty: quizQuestion.difficulty,
category: quizCategory.name,
})
.from(quizQuestion)
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where(
and(
eq(quizQuestion.active, true),
inArray(quizQuestion.format, formats),
categories.length > 0
? inArray(quizCategory.name, categories)
: undefined,
notInArray(quizQuestion.id, alreadyPlayed)
)
)
.orderBy(sql`random()`)
.limit(limit)
return rows
.filter((r) => {
if (r.format === "free") {
return (r.acceptedAnswers?.length ?? 0) > 0
}
if (r.format === "image_reveal") {
return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0
}
return r.choices && r.correctIndex !== null
})
.map((r) => ({
id: r.id,
format: r.format as QuizQuestion["format"],
prompt: r.prompt,
choices: r.choices ?? undefined,
correctIndex: r.correctIndex ?? undefined,
acceptedAnswers: r.acceptedAnswers ?? undefined,
imageUrl: r.imageUrl ?? undefined,
category: r.category ?? "Quiz",
difficulty: r.difficulty,
}))
}
/** Marque une question comme jouée par cette room (anti-répétition). */
export async function markQuestionPlayed(
roomCode: string,
questionId: string
): Promise<void> {
if (!db) {
return
}
await db
.insert(quizPlayed)
.values({ roomCode, questionId })
.onConflictDoNothing()
}

View file

@ -0,0 +1,90 @@
// Schéma persistant (PostgreSQL via Drizzle). Ne stocke que le contenu quiz
// et l'historique — les rooms vivent en mémoire (voir ARCHITECTURE §3).
import { sql } from "drizzle-orm"
import {
boolean,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core"
export const quizCategory = pgTable("quiz_category", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
})
export const quizQuestion = pgTable(
"quiz_question",
{
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id").references(() => quizCategory.id),
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
format: text("format").notNull(),
prompt: text("prompt").notNull(),
/** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' | 'pokeapi' */
source: text("source").notNull(),
/** Langue de la question ('fr' | 'en'). */
lang: text("lang").notNull().default("fr"),
/** Désactivée (back-office) : exclue des parties sans être supprimée. */
active: boolean("active").notNull().default(true),
// Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
},
// Unique sur l'intitulé SAUF pour les images (même intitulé "Qui est-ce ?"
// pour des images différentes). Les images se dédupliquent par image_url.
(t) => [
uniqueIndex("quiz_question_prompt_unique")
.on(t.prompt)
.where(sql`${t.format} <> 'image_reveal'`),
uniqueIndex("quiz_question_image_unique")
.on(t.imageUrl)
.where(sql`${t.imageUrl} is not null`),
]
)
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
export const quizPlayed = pgTable(
"quiz_played",
{
questionId: uuid("question_id")
.notNull()
.references(() => quizQuestion.id),
roomCode: text("room_code").notNull(),
playedAt: timestamp("played_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
},
(t) => [primaryKey({ columns: [t.questionId, t.roomCode] })]
)
/** Historique de parties (stats / portfolio). */
export const gameHistory = pgTable("game_history", {
id: uuid("id").primaryKey().defaultRandom(),
roomCode: text("room_code").notNull(),
playedAt: timestamp("played_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
modes: jsonb("modes").$type<string[]>(),
results: jsonb("results"),
})
export type QuizQuestionRow = typeof quizQuestion.$inferSelect
export type NewQuizQuestion = typeof quizQuestion.$inferInsert
export type NewQuizCategory = typeof quizCategory.$inferInsert

View file

@ -0,0 +1,95 @@
// Seed du mode Images depuis PokéAPI : "devine le Pokémon" à partir de
// l'artwork officiel (URL distante, pas d'upload). Réponses acceptées FR + EN.
// bun run db:seed:images (gen 1, 151 Pokémon)
// IMAGE_SEED_COUNT=50 bun run db:seed:images
// Idempotent : déduplication par image_url (onConflictDoNothing).
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../env"
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — impossible de seeder.")
process.exit(1)
}
const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
const CATEGORY = "Pokémon"
const PROMPT = "Quel est ce Pokémon ?"
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
async function upsertCategory(name: string): Promise<string> {
await db.insert(quizCategory).values({ name }).onConflictDoNothing()
const [row] = await db
.select({ id: quizCategory.id })
.from(quizCategory)
.where(eq(quizCategory.name, name))
return row.id
}
interface Pokemon {
name: string
sprites: { other?: { ["official-artwork"]?: { front_default: string | null } } }
}
interface Species {
names: { name: string; language: { name: string } }[]
}
async function fetchPokemon(id: number): Promise<NewQuizQuestion | null> {
try {
const [pRes, sRes] = await Promise.all([
fetch(`https://pokeapi.co/api/v2/pokemon/${id}`),
fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`),
])
if (!pRes.ok || !sRes.ok) {
return null
}
const p = (await pRes.json()) as Pokemon
const s = (await sRes.json()) as Species
const art = p.sprites.other?.["official-artwork"]?.front_default
if (!art) {
return null
}
const fr = s.names.find((n) => n.language.name === "fr")?.name
const en = s.names.find((n) => n.language.name === "en")?.name ?? p.name
const answers = [...new Set([fr, en].filter(Boolean) as string[])]
return {
categoryId: "", // rempli plus bas
format: "image_reveal",
prompt: PROMPT,
difficulty: 2,
source: "pokeapi",
lang: "fr",
acceptedAnswers: answers,
imageUrl: art,
}
} catch {
return null
}
}
console.log(`Seed Images depuis PokéAPI (${COUNT} Pokémon)…`)
const categoryId = await upsertCategory(CATEGORY)
let inserted = 0
for (let id = 1; id <= COUNT; id++) {
const q = await fetchPokemon(id)
if (q) {
const res = await db
.insert(quizQuestion)
.values({ ...q, categoryId })
.onConflictDoNothing()
.returning({ id: quizQuestion.id })
inserted += res.length
}
if (id % 25 === 0) {
console.log(` ${id}/${COUNT}`)
}
await sleep(40) // courtoisie envers l'API
}
console.log(`Terminé : ${inserted} Pokémon ajoutés (mode Images).`)
await client.end()

197
apps/server/src/db/seed.ts Normal file
View file

@ -0,0 +1,197 @@
// Seed du contenu quiz. One-shot au déploiement : bun run db:seed
// - Open Trivia DB : catégories geek (mcq + truefalse), respect 1 req / 5 s.
// - Banque FR en dur : insérée comme source 'manual'.
// Idempotent : prompt unique → onConflictDoNothing.
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../env"
import {
quizCategory,
quizQuestion,
type NewQuizQuestion,
} from "./schema"
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — impossible de seeder.")
process.exit(1)
}
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).
const OPENTDB_CATEGORIES = [
{ id: 15, name: "Jeux vidéo" },
{ id: 31, name: "Anime & Manga" },
{ id: 29, name: "Comics" },
{ id: 11, name: "Cinéma" },
{ id: 14, name: "Télévision" },
{ id: 18, name: "Informatique" },
]
const AMOUNT = 30
const RATE_LIMIT_MS = 5200 // OpenTDB : 1 requête / 5 s par IP
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const decode = (s: string) => Buffer.from(s, "base64").toString("utf8")
const difficultyOf = (d: string) => (d === "hard" ? 3 : d === "medium" ? 2 : 1)
// Tous les champs string sont encodés en base64 (encode=base64).
interface OtdbQuestion {
type: string
difficulty: string
question: string
correct_answer: string
incorrect_answers: string[]
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
async function getToken(): Promise<string | undefined> {
try {
const res = await fetch("https://opentdb.com/api_token.php?command=request")
const json = (await res.json()) as { token?: string }
return json.token
} catch {
return undefined
}
}
async function fetchBatch(
categoryId: number,
type: "multiple" | "boolean",
token: string | undefined
): Promise<OtdbQuestion[]> {
const url =
`https://opentdb.com/api.php?amount=${AMOUNT}&category=${categoryId}` +
`&type=${type}&encode=base64${token ? `&token=${token}` : ""}`
const res = await fetch(url)
const json = (await res.json()) as {
response_code: number
results: OtdbQuestion[]
}
if (json.response_code === 5) {
// Rate limit : on attend et on réessaie une fois.
await sleep(RATE_LIMIT_MS)
return fetchBatch(categoryId, type, token)
}
return json.response_code === 0 ? json.results : []
}
/** Upsert une catégorie et renvoie son id. */
async function upsertCategory(name: string): Promise<string> {
await db.insert(quizCategory).values({ name }).onConflictDoNothing()
const [row] = await db
.select({ id: quizCategory.id })
.from(quizCategory)
.where(eq(quizCategory.name, name))
return row.id
}
function toQuestion(
q: OtdbQuestion,
categoryId: string
): NewQuizQuestion {
// Avec encode=base64, TOUS les champs sont encodés (type/difficulty inclus).
const prompt = decode(q.question)
const type = decode(q.type)
const difficulty = difficultyOf(decode(q.difficulty))
if (type === "boolean") {
const correct = decode(q.correct_answer) // "True" | "False"
return {
categoryId,
format: "truefalse",
prompt,
difficulty,
source: "opentdb",
lang: "en",
choices: ["True", "False"],
correctIndex: correct === "True" ? 0 : 1,
}
}
const correct = decode(q.correct_answer)
const choices = shuffle([
correct,
...q.incorrect_answers.map(decode),
])
return {
categoryId,
format: "mcq",
prompt,
difficulty,
source: "opentdb",
lang: "en",
choices,
correctIndex: choices.indexOf(correct),
}
}
async function insertQuestions(rows: NewQuizQuestion[]): Promise<number> {
if (rows.length === 0) {
return 0
}
const inserted = await db
.insert(quizQuestion)
.values(rows)
.onConflictDoNothing()
.returning({ id: quizQuestion.id })
return inserted.length
}
async function seedOpenTdb(): Promise<number> {
const token = await getToken()
let total = 0
let first = true
for (const cat of OPENTDB_CATEGORIES) {
const categoryId = await upsertCategory(cat.name)
for (const type of ["multiple", "boolean"] as const) {
if (!first) {
await sleep(RATE_LIMIT_MS)
}
first = false
const batch = await fetchBatch(cat.id, type, token)
const rows = batch.map((q) => toQuestion(q, categoryId))
const n = await insertQuestions(rows)
total += n
console.log(` ${cat.name} (${type}): +${n}`)
}
}
return total
}
async function seedManual(): Promise<number> {
let total = 0
for (const q of QUIZ_QUESTIONS) {
const categoryId = await upsertCategory(q.category)
total += await insertQuestions([
{
categoryId,
format: q.format,
prompt: q.prompt,
difficulty: q.difficulty,
source: "manual",
lang: "fr",
choices: q.choices,
correctIndex: q.correctIndex,
acceptedAnswers: q.acceptedAnswers,
},
])
}
return total
}
console.log("Seed Open Trivia DB (respect du 1 req / 5 s, patientez)…")
const otdb = await seedOpenTdb()
console.log("Seed banque FR (manual)…")
const manual = await seedManual()
console.log(`Terminé : ${otdb} questions OpenTDB + ${manual} manuelles.`)
await client.end()

26
apps/server/src/env.ts Normal file
View file

@ -0,0 +1,26 @@
// Configuration lue depuis l'environnement. Bun charge .env automatiquement.
// Secrets (YouTube, DB) viendront s'ajouter ici — jamais en dur.
function num(value: string | undefined, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
export const env = {
nodeEnv: process.env.NODE_ENV ?? "development",
host: process.env.HOST ?? "0.0.0.0",
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 ?? "",
/** Dossier de stockage des images (image_reveal), servi en statique sur /uploads. */
uploadsDir: process.env.UPLOADS_DIR ?? "./uploads",
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
.split(",")
.map((o) => o.trim())
.filter(Boolean),
}
export const isDev = env.nodeEnv !== "production"

View file

@ -0,0 +1,13 @@
// Réglage de difficulté des bots : probabilité de donner la bonne réponse.
import type { BotDifficulty } from "@nerdware/shared"
const CHANCE: Record<BotDifficulty, number> = {
easy: 0.3,
normal: 0.55,
hard: 0.85,
}
export function botCorrectChance(difficulty: BotDifficulty): number {
return CHANCE[difficulty] ?? CHANCE.normal
}

View file

@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test"
import type { IoServer } from "../socket"
import { RoomManager, type ServerRoom } from "../rooms"
import { GameEngine, type GameEngineOptions } from "./engine"
import type { GameRound } from "./round"
interface Emit {
event: string
payload: unknown
}
/** io stub : capture tous les emits, ignore le ciblage de room. */
function fakeIo(): { io: IoServer; emits: Emit[] } {
const emits: Emit[] = []
const io = {
to: () => ({
emit: (event: string, payload: unknown) => {
emits.push({ event, payload })
},
}),
}
return { io: io as unknown as IoServer, emits }
}
/** Épreuve factice : +10 à qui choisit l'index correct (0). */
const dummyRound: GameRound = {
type: "quiz",
start: () => ({
payload: { prompt: "2+2 ?", choices: ["4", "5"] },
data: { correct: 0 },
}),
submitAnswer: (ctx, playerId, answer) => {
ctx.votes.set(playerId, answer)
},
reveal: (ctx) => ({
truth: { correctIndex: (ctx.data as { correct: number }).correct },
perPlayerResult: {},
}),
score: (ctx) => {
const correct = (ctx.data as { correct: number }).correct
const deltas: { playerId: string; delta: number }[] = []
for (const [playerId, answer] of ctx.votes) {
if ((answer as { choiceIndex: number }).choiceIndex === correct) {
deltas.push({ playerId, delta: 10 })
}
}
return deltas
},
recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }),
botAnswer: () => ({ choiceIndex: 0 }),
}
function setup(roundDurationSec: number): {
io: IoServer
emits: Emit[]
rooms: RoomManager
room: ServerRoom
p1: string
p2: string
options: GameEngineOptions
} {
const { io, emits } = fakeIo()
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sock-a")
const { player: b } = rooms.join(room.code, "Bob", "sock-b")
room.settings.roundDuration = roundDurationSec
room.settings.rounds = [{ type: "quiz" }]
const options: GameEngineOptions = {
createRound: () => dummyRound,
revealPauseMs: 0,
leadMs: 0,
}
return { io, emits, rooms, room, p1: a.id, p2: b.id, options }
}
const eventsOf = (emits: Emit[]) => emits.map((e) => e.event)
describe("GameEngine", () => {
test("finit la manche dès que tous les éligibles ont voté (avant le timer)", async () => {
const { io, emits, rooms, room, p1, p2, options } = setup(100)
const engine = new GameEngine(io, rooms, room, options)
const run = engine.run()
// run() s'exécute jusqu'à round:start de façon synchrone, le runtime est prêt.
expect(eventsOf(emits)).toContain("round:start")
engine.handleVote(p1, { choiceIndex: 0 }) // juste
engine.handleVote(p2, { choiceIndex: 1 }) // faux → 2/2 votes → fin anticipée
await run
const events = eventsOf(emits)
expect(events).toContain("round:reveal")
expect(events).toContain("score:update")
expect(events).toContain("game:end")
expect(room.status).toBe("ended")
expect(room.scores.get(p1)).toBe(10)
expect(room.scores.get(p2)).toBe(0)
const end = emits.find((e) => e.event === "game:end")!.payload as {
finalScores: { playerId: string; score: number }[]
}
expect(end.finalScores).toHaveLength(2)
})
test("émet round:voteAck avec count/total à chaque vote", async () => {
const { io, emits, rooms, room, p1, p2, options } = setup(100)
const engine = new GameEngine(io, rooms, room, options)
const run = engine.run()
engine.handleVote(p1, { choiceIndex: 0 })
const ack = emits.find((e) => e.event === "round:voteAck")!.payload as {
count: number
total: number
voted: string[]
}
expect(ack).toEqual({ count: 1, total: 2, voted: [p1] })
engine.handleVote(p2, { choiceIndex: 0 })
await run
expect(room.scores.get(p1)).toBe(10)
expect(room.scores.get(p2)).toBe(10)
})
test("finit la manche au timer si tout le monde n'a pas voté", async () => {
const { io, emits, rooms, room, p1, options } = setup(0.05) // 50 ms
const engine = new GameEngine(io, rooms, room, options)
const run = engine.run()
engine.handleVote(p1, { choiceIndex: 0 }) // un seul vote → on attend le timer
await run
expect(eventsOf(emits)).toContain("game:end")
expect(room.status).toBe("ended")
expect(room.scores.get(p1)).toBe(10)
})
})

View file

@ -0,0 +1,314 @@
// Moteur de jeu générique. Orchestre la séquence de manches d'une room :
// pour chaque manche start → (votes | timer) → reveal → score, puis game:end.
// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté.
import type {
Answer,
MediaControlPayload,
PlayerScore,
RoundConfig,
RoundRecap,
} from "@nerdware/shared"
import type { IoServer } from "../socket"
import type { RoomManager, ServerRoom } from "../rooms"
import { saveGameHistory } from "../db/history-repo"
import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
import type { GameRound, RoomGameController, RoundContext } from "./round"
interface RoundRuntime {
round: GameRound
djId: string | null
/** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */
secretPlayerId: string | null
votes: Map<string, Answer>
startedAt: number
endsAt: number
data: unknown
/** Payload public de la manche (pour la reconnexion). */
payload: unknown
}
export interface GameEngineOptions {
/** Injection de l'instanciation d'épreuve (tests). Défaut = registre global. */
createRound?: (type: RoundConfig["type"]) => GameRound
/** Pause d'affichage du reveal/scores entre deux manches (ms). */
revealPauseMs?: number
/** Délai de préparation avant le démarrage du chrono (transition WarioWare, ms). */
leadMs?: number
/** Permet de patcher l'horloge en test ; défaut = Date.now. */
now?: () => number
}
const DEFAULT_REVEAL_PAUSE_MS = 5000
// Le chrono ne démarre qu'après ce délai, le temps que la transition s'affiche.
const DEFAULT_LEAD_MS = 1600
export class GameEngine implements RoomGameController {
private runtime: RoundRuntime | null = null
private timer: ReturnType<typeof setTimeout> | null = null
private resolveRoundEnd: (() => void) | null = null
private readonly history: RoundRecap[] = []
private readonly createRound: (type: RoundConfig["type"]) => GameRound
private readonly revealPauseMs: number
private readonly leadMs: number
private readonly now: () => number
constructor(
private readonly io: IoServer,
private readonly rooms: RoomManager,
private readonly room: ServerRoom,
options: GameEngineOptions = {}
) {
this.createRound = options.createRound ?? defaultCreateRound
this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS
this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS
this.now = options.now ?? Date.now
}
/** Joue toute la partie, manche par manche, puis émet game:end. */
async run(): Promise<void> {
const { rounds } = this.room.settings
for (let i = 0; i < rounds.length; i++) {
this.room.currentRound = i
await this.playRound(rounds[i])
if (i < rounds.length - 1) {
await this.sleep(this.revealPauseMs)
}
}
this.room.status = "ended"
this.broadcastState()
// Récap des titres blindtest joués (pour récupérer les liens YouTube).
const tracks = this.room.blindtestTracks.map((t) => ({
title: t.title,
artist: t.artist,
youtubeId: t.youtubeId,
url: t.url,
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
}))
this.io.to(this.room.code).emit("game:end", {
finalScores: this.scoreboard(),
tracks: tracks.length > 0 ? tracks : undefined,
recap: this.history.length > 0 ? this.history : undefined,
})
// Historique (fire-and-forget, DB optionnelle).
const modes = [...new Set(this.room.settings.rounds.map((r) => r.type))]
const results = this.scoreboard()
.map((s) => ({
name: this.room.players.get(s.playerId)?.name ?? "?",
score: s.score,
}))
.sort((a, b) => b.score - a.score)
void saveGameHistory(this.room.code, modes, results).catch((err) =>
console.error("[history] save failed", err)
)
}
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
handleVote(playerId: string, answer: Answer): void {
if (!this.runtime || this.room.status !== "in_round") {
return
}
const ctx = this.context(this.runtime)
this.runtime.round.submitAnswer(ctx, playerId, answer)
const total = this.eligibleVoters().length
this.io.to(this.room.code).emit("round:voteAck", {
count: this.runtime.votes.size,
total,
voted: [...this.runtime.votes.keys()],
})
// Fin anticipée : tous les éligibles ont voté.
if (total > 0 && this.runtime.votes.size >= total) {
this.endRound()
}
}
/** Relais média : seul le DJ de la manche peut piloter la lecture. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void {
if (
!this.runtime ||
this.room.status !== "in_round" ||
playerId !== this.runtime.djId
) {
return
}
this.io.to(this.room.code).emit("media:sync", {
action: payload.action,
positionSec: payload.positionSec,
atServerTs: this.now(),
})
}
/** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */
resync(socketId: string, playerId: string): void {
if (!this.runtime || this.room.status !== "in_round") {
return
}
const r = this.runtime
this.io.to(socketId).emit("round:start", {
type: r.round.type,
djId: r.djId === playerId ? r.djId : undefined,
mine: r.secretPlayerId === playerId ? true : undefined,
startsAt: r.startedAt,
endsAt: r.endsAt,
payload: r.payload,
})
}
private async playRound(config: RoundConfig): Promise<void> {
const round = this.createRound(config.type)
const start = round.start(this.room)
const durationSec = start.durationSec ?? this.room.settings.roundDuration
const now = this.now()
// Les réponses ne comptent qu'après le délai de préparation (transition).
const startedAt = now + this.leadMs
const endsAt = startedAt + durationSec * 1000
this.runtime = {
round,
djId: start.djId ?? null,
secretPlayerId: start.secretPlayerId ?? null,
votes: new Map(),
startedAt,
endsAt,
data: start.data,
payload: start.payload,
}
this.room.status = "in_round"
this.broadcastState()
const base = {
type: round.type,
djId: this.runtime.djId ?? undefined,
startsAt: startedAt,
endsAt,
payload: start.payload,
}
// Le contributeur reçoit un round:start privé (mine: true) ; il reste secret
// pour les autres (qui ne reçoivent que `base`).
const secretSocketId = this.runtime.secretPlayerId
? this.room.players.get(this.runtime.secretPlayerId)?.socketId
: null
if (secretSocketId) {
this.io.to(secretSocketId).emit("round:start", { ...base, mine: true })
this.io.to(this.room.code).except(secretSocketId).emit("round:start", base)
} else {
this.io.to(this.room.code).emit("round:start", base)
}
this.scheduleBotVotes()
// Attend la première condition de fin : timer écoulé OU tous votés.
await new Promise<void>((resolve) => {
this.resolveRoundEnd = resolve
this.timer = setTimeout(() => this.endRound(), endsAt - now)
})
const ctx = this.context(this.runtime)
this.room.status = "reveal"
this.broadcastState()
this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx))
const deltas = round.score(ctx)
for (const { playerId, delta } of deltas) {
this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + delta)
}
this.history.push({
index: this.room.currentRound,
type: round.type,
...round.recap(ctx),
scorers: deltas.map((d) => ({ playerId: d.playerId, points: d.delta })),
})
this.room.status = "scores"
this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() })
this.broadcastState()
this.runtime = null
}
/** Programme le vote automatique des bots (jamais DJ ni contributeur). */
private scheduleBotVotes(): void {
if (!this.runtime) {
return
}
const { djId, secretPlayerId, endsAt, startedAt } = this.runtime
const window = Math.max(0, endsAt - startedAt)
const bots = [...this.room.players.values()].filter(
(p) => p.isBot && p.id !== djId && p.id !== secretPlayerId
)
for (const bot of bots) {
// Vote après l'ouverture des réponses, réparti dans la première moitié.
const delay = this.leadMs + 700 + Math.random() * Math.min(window * 0.5, 3500)
setTimeout(() => this.botVote(bot.id), delay)
}
}
/** Exécute le vote d'un bot (no-op si la manche est finie ou s'il a déjà voté). */
private botVote(botId: string): void {
if (!this.runtime || this.room.status !== "in_round") {
return
}
if (this.runtime.votes.has(botId)) {
return
}
const ctx = this.context(this.runtime)
this.runtime.round.submitAnswer(ctx, botId, this.runtime.round.botAnswer(ctx, botId))
const total = this.eligibleVoters().length
this.io.to(this.room.code).emit("round:voteAck", {
count: this.runtime.votes.size,
total,
voted: [...this.runtime.votes.keys()],
})
if (total > 0 && this.runtime.votes.size >= total) {
this.endRound()
}
}
/** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */
private endRound(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
const resolve = this.resolveRoundEnd
this.resolveRoundEnd = null
resolve?.()
}
private context(runtime: RoundRuntime): RoundContext {
return {
room: this.room,
djId: runtime.djId,
votes: runtime.votes,
startedAt: runtime.startedAt,
endsAt: runtime.endsAt,
data: runtime.data,
}
}
/**
* Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur
* (blindtest) qui n'a rien à saisir.
*/
private eligibleVoters(): string[] {
const secret = this.runtime?.secretPlayerId
return [...this.room.players.values()]
.filter((p) => p.connected && p.name !== "" && p.id !== secret)
.map((p) => p.id)
}
private scoreboard(): PlayerScore[] {
return [...this.room.scores.entries()].map(([playerId, score]) => ({
playerId,
score,
}))
}
private broadcastState(): void {
this.io.to(this.room.code).emit("room:state", this.rooms.toSnapshot(this.room))
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export type { RoundFactory }

View file

@ -0,0 +1,54 @@
// Matching tolérant pour les réponses libres (titre/artiste).
// Normalisation (minuscules, accents, ponctuation) + distance de Levenshtein.
/** Minuscule, sans accents, sans ponctuation, espaces compactés. */
export function normalize(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "") // accents
.replace(/\(.*?\)|\[.*?\]/g, " ") // contenu entre parenthèses/crochets
.replace(/[^a-z0-9\s]/g, " ") // ponctuation
.replace(/\s+/g, " ")
.trim()
}
/** Distance d'édition de Levenshtein. */
export function levenshtein(a: string, b: string): number {
if (a === b) return 0
if (a.length === 0) return b.length
if (b.length === 0) return a.length
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
for (let i = 1; i <= a.length; i++) {
const curr = [i]
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
}
prev = curr
}
return prev[b.length]
}
/**
* Vrai si `guess` correspond à `truth` avec tolérance :
* - égalité après normalisation, ou
* - une normalisation contient l'autre (réponse partielle), ou
* - distance de Levenshtein ~20% de la longueur (fautes de frappe).
*/
export function fuzzyMatch(guess: string, truth: string): boolean {
const g = normalize(guess)
const t = normalize(truth)
if (!g || !t) {
return false
}
if (g === t) {
return true
}
// Réponse contenue (ex: "zelda" pour "the legend of zelda").
if (g.length >= 3 && (t.includes(g) || g.includes(t))) {
return true
}
const tolerance = Math.floor(Math.max(g.length, t.length) * 0.2)
return levenshtein(g, t) <= tolerance
}

View file

@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test"
import { RoomManager, type BlindtestTrack } from "../../../rooms"
import type { RoundContext } from "../../round"
import { BlindtestRound } from "./blindtest-round"
import { prepareBlindtestForRoom } from "./pool"
import { fuzzyMatch, normalize } from "../../match"
function track(submittedBy: string): BlindtestTrack {
return {
id: "t1",
youtubeId: "abc12345678",
url: "https://youtu.be/abc12345678",
title: "The Legend of Zelda Main Theme",
artist: "Koji Kondo",
submittedBy,
}
}
function ctxFor(
room: ReturnType<RoomManager["create"]>["room"],
t: BlindtestTrack,
mode: "title_artist" | "who_added" | "mixed"
): RoundContext {
room.settings.blindtestMode = mode
return {
room,
djId: null,
votes: new Map(),
startedAt: 0,
endsAt: 10_000,
data: { track: t },
}
}
describe("match", () => {
test("normalize retire accents/ponctuation/casse", () => {
expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu")
})
test("fuzzyMatch tolère fautes et réponses partielles", () => {
expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true)
expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true)
expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false)
})
})
describe("BlindtestRound", () => {
test("start : le DJ est neutre (jamais le contributeur)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
room.blindtestTracks = [track(a.id)]
prepareBlindtestForRoom(room)
const round = new BlindtestRound()
const start = round.start(room)
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
})
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const { player: c } = rooms.join(room.code, "Carol", "sc")
const t = track(a.id) // Alice = contributrice/DJ
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux
round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste
round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // contributrice : non scorée
const deltas = round.score(ctx)
// Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50
expect(deltas).toContainEqual({ playerId: c.id, delta: 100 })
expect(deltas).toContainEqual({ playerId: a.id, delta: 50 })
expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined()
})
test("who_added : points si on devine le bon contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste
const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: b.id, delta: 100 }])
})
test("title_artist : 60 titre + 40 artiste, fuzzy", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" })
const { perPlayerResult } = round.reveal(ctx)
expect(perPlayerResult[b.id]).toMatchObject({
titleCorrect: true,
artistCorrect: true,
points: 100,
})
expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }])
})
test("le contributeur ne marque pas sur son propre titre", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id })
expect(round.score(ctx)).toEqual([])
})
test("reveal expose le titre, l'artiste et le contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
const { truth } = round.reveal(ctx)
expect(truth.submittedBy).toBe(a.id)
expect(truth.submittedByName).toBe("Alice")
expect(truth.title).toBe("The Legend of Zelda Main Theme")
})
})

View file

@ -0,0 +1,227 @@
// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ
// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas
// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal.
import type {
Answer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
ScoreDelta,
} from "@nerdware/shared"
import type {
GameRound,
RoundContext,
RoundRecapInfo,
RoundStart,
} from "../../round"
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
import { fuzzyMatch } from "../../match"
import { botCorrectChance } from "../../bot"
import { takeTrack } from "./pool"
const TITLE_POINTS = 60
const ARTIST_POINTS = 40
const WHO_POINTS = 100
/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */
const MISDIRECTION_POINTS = 50
interface BlindtestRoundData {
track: BlindtestTrack
}
/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */
function pickDj(room: ServerRoom, submittedBy: string): string | null {
const eligible = [...room.players.values()].filter(
(p) => p.connected && !p.isBot && p.id !== submittedBy
)
if (eligible.length === 0) {
return null
}
return eligible[Math.floor(Math.random() * eligible.length)].id
}
/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
function voterResult(
mode: BlindtestMode,
answer: Answer | undefined,
track: BlindtestTrack
): BlindtestPlayerResult {
const a = (answer ?? {}) as {
title?: string
artist?: string
guessedPlayerId?: string
}
let points = 0
const result: BlindtestPlayerResult = { points: 0 }
if (mode === "title_artist" || mode === "mixed") {
result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false
result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false
if (result.titleCorrect) points += TITLE_POINTS
if (result.artistCorrect) points += ARTIST_POINTS
}
if (mode === "who_added" || mode === "mixed") {
result.guessedCorrect = a.guessedPlayerId === track.submittedBy
if (result.guessedCorrect) points += WHO_POINTS
}
result.points = points
return result
}
export class BlindtestRound implements GameRound {
readonly type = "blindtest" as const
start(room: ServerRoom): RoundStart {
const track = takeTrack(room)
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
const djId = track ? pickDj(room, track.submittedBy) : null
const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId }
: null
return {
djId,
payload,
secretPlayerId: track?.submittedBy,
data: track ? { track } : null,
}
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent). Le contributeur peut voter mais son
// vote ne lui rapporte rien (voir buildResults) ; ça évite de bloquer la fin.
if (ctx.votes.has(playerId)) {
return
}
ctx.votes.set(playerId, answer)
}
botAnswer(ctx: RoundContext, playerId: string): Answer {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const chance = botCorrectChance(ctx.room.settings.botDifficulty)
// Selon le niveau : devine le bon contributeur, sinon un joueur au hasard.
const others = [...ctx.room.players.values()]
.filter((p) => p.id !== playerId)
.map((p) => p.id)
const guessedPlayerId =
Math.random() < chance
? track.submittedBy
: (others[Math.floor(Math.random() * others.length)] ?? playerId)
if (mode === "who_added") {
return { guessedPlayerId }
}
const right = Math.random() < chance
const title = right ? track.title : "?"
const artist = right ? track.artist : "?"
if (mode === "title_artist") {
return { title, artist }
}
return { title, artist, guessedPlayerId }
}
/** Résultat par joueur (votants + bonus de tromperie du contributeur). */
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const perPlayer: BlindtestPerPlayerResult = {}
let wrongGuessers = 0
for (const player of ctx.room.players.values()) {
if (player.id === track.submittedBy) {
continue
}
const result = voterResult(mode, ctx.votes.get(player.id), track)
perPlayer[player.id] = result
if (
(mode === "who_added" || mode === "mixed") &&
result.guessedCorrect === false
) {
wrongGuessers++
}
}
// Contributeur : pas de points hors who_added/mixed ; sinon tromperie.
const misdirection =
mode === "who_added" || mode === "mixed"
? wrongGuessers * MISDIRECTION_POINTS
: 0
perPlayer[track.submittedBy] = { points: misdirection }
return perPlayer
}
reveal(ctx: RoundContext): {
truth: BlindtestRevealTruth
perPlayerResult: BlindtestPerPlayerResult
} {
const { track } = ctx.data as BlindtestRoundData
const submitter = ctx.room.players.get(track.submittedBy)
return {
truth: {
title: track.title,
artist: track.artist,
youtubeId: track.youtubeId,
submittedBy: track.submittedBy,
submittedByName: submitter?.name ?? "?",
},
perPlayerResult: this.buildResults(ctx),
}
}
score(ctx: RoundContext): ScoreDelta[] {
const perPlayer = this.buildResults(ctx)
const deltas: ScoreDelta[] = []
for (const [playerId, result] of Object.entries(perPlayer)) {
if (result.points > 0) {
deltas.push({ playerId, delta: result.points })
}
}
return deltas
}
recap(ctx: RoundContext): RoundRecapInfo {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const submitter = ctx.room.players.get(track.submittedBy)
const nameOf = (id?: string) =>
(id && ctx.room.players.get(id)?.name) || "?"
const answers = []
for (const [playerId, vote] of ctx.votes) {
if (playerId === track.submittedBy) {
continue
}
const a = vote as {
title?: string
artist?: string
guessedPlayerId?: string
}
let text: string
if (mode === "who_added") {
text = nameOf(a.guessedPlayerId)
} else if (mode === "title_artist") {
text = `${a.title ?? ""} / ${a.artist ?? ""}`
} else {
text = `${a.title ?? ""} / ${a.artist ?? ""}${nameOf(a.guessedPlayerId)}`
}
answers.push({
playerId,
answer: text,
correct: voterResult(mode, vote, track).points > 0,
})
}
return {
label: "Blindtest",
answer: `${track.title}${track.artist}`,
youtubeId: track.youtubeId,
url: track.url,
addedBy: submitter?.name ?? "?",
answers,
}
}
}

View file

@ -0,0 +1,6 @@
import { registerRound } from "../../registry"
import { BlindtestRound } from "./blindtest-round"
registerRound("blindtest", () => new BlindtestRound())
export { BlindtestRound }

View file

@ -0,0 +1,26 @@
// File d'attente des titres blindtest par room, préchargée (mélangée) au
// lancement de la partie. Le BlindtestRound y pioche un titre par manche.
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
const queueByRoom = new WeakMap<ServerRoom, BlindtestTrack[]>()
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Mélange les titres soumis dans une file pour la partie. */
export function prepareBlindtestForRoom(room: ServerRoom): void {
queueByRoom.set(room, shuffle(room.blindtestTracks))
}
/** Pioche le prochain titre (ou null si la file est vide). */
export function takeTrack(room: ServerRoom): BlindtestTrack | null {
const queue = queueByRoom.get(room)
return queue && queue.length > 0 ? (queue.shift() ?? null) : null
}

View file

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { extractYoutubeId } from "./youtube"
describe("extractYoutubeId", () => {
test.each([
["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
])("extrait l'ID de %s", (url, expected) => {
expect(extractYoutubeId(url)).toBe(expected)
})
test("renvoie null pour une URL non YouTube", () => {
expect(extractYoutubeId("https://example.com")).toBeNull()
expect(extractYoutubeId("")).toBeNull()
})
})

View file

@ -0,0 +1,53 @@
// Extraction d'ID YouTube + vérification via oEmbed (existe + embeddable),
// qui fournit aussi titre et auteur pour le reveal.
/** Extrait l'ID vidéo (11 chars) d'une URL YouTube (watch, youtu.be, shorts, embed). */
export function extractYoutubeId(input: string): string | null {
const url = input.trim()
// ID brut directement collé.
if (/^[\w-]{11}$/.test(url)) {
return url
}
const patterns = [
/[?&]v=([\w-]{11})/, // watch?v=ID
/youtu\.be\/([\w-]{11})/, // youtu.be/ID
/\/shorts\/([\w-]{11})/, // /shorts/ID
/\/embed\/([\w-]{11})/, // /embed/ID
]
for (const re of patterns) {
const m = url.match(re)
if (m) {
return m[1]
}
}
return null
}
export interface YoutubeMeta {
title: string
artist: string
}
/**
* Vérifie via oEmbed que la vidéo existe et est intégrable, et renvoie
* { title, artist }. null si introuvable/privée/embedding désactivé.
*/
export async function fetchYoutubeMeta(
youtubeId: string
): Promise<YoutubeMeta | null> {
const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${youtubeId}&format=json`
try {
const res = await fetch(url)
if (!res.ok) {
// 401/404 → vidéo privée, supprimée, ou embedding désactivé.
return null
}
const json = (await res.json()) as { title?: string; author_name?: string }
return {
title: json.title ?? "",
artist: json.author_name ?? "",
}
} catch {
return null
}
}

View file

@ -0,0 +1,20 @@
// Point d'enregistrement de toutes les épreuves. Importé une fois au démarrage
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
import "./quiz"
import "./blindtest"
import type { ServerRoom } from "../../rooms"
import { prepareQuizForRoom } from "./quiz/pool"
import { prepareBlindtestForRoom } from "./blindtest/pool"
/** Précharge le contenu nécessaire avant de lancer la partie. */
export async function prepareRoom(room: ServerRoom): Promise<void> {
const types = new Set(room.settings.rounds.map((r) => r.type))
if (types.has("quiz")) {
await prepareQuizForRoom(room)
}
if (types.has("blindtest")) {
prepareBlindtestForRoom(room)
}
}

View file

@ -0,0 +1,7 @@
import { registerRound } from "../../registry"
import { QuizRound } from "./quiz-round"
// Brancher l'épreuve = un seul registerRound, aucune plomberie à toucher.
registerRound("quiz", () => new QuizRound())
export { QuizRound }

View file

@ -0,0 +1,100 @@
// File d'attente de questions par room. Préchargée au lancement : on respecte
// le sous-pool de chaque manche quiz (quiz "classique" vs images), dans l'ordre
// de la séquence. DB si dispo, sinon banque en dur (pas d'images en dur).
import type { QuizFormat } from "@nerdware/shared"
import { hasDb } from "../../../db"
import { loadQuizPool } from "../../../db/quiz-repo"
import type { ServerRoom } from "../../../rooms"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
interface QueueItem {
question: QuizQuestion
fromDb: boolean
}
const poolByRoom = new WeakMap<ServerRoom, QueueItem[]>()
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Charge un sous-pool (DB puis complément banque en dur) pour `need` manches. */
async function loadGroup(
room: ServerRoom,
need: number,
formats: QuizFormat[],
isImage: boolean,
categories: string[]
): Promise<QueueItem[]> {
if (need <= 0) {
return []
}
let items: QueueItem[] = []
if (hasDb) {
try {
const rows = await loadQuizPool(room.code, need, formats, categories)
items = rows.map((question) => ({ question, fromDb: true }))
} catch (err) {
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
}
}
if (items.length < need && !isImage) {
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
const bank = shuffle(
QUIZ_QUESTIONS.filter(
(q) =>
formats.includes(q.format) &&
(categories.length === 0 || categories.includes(q.category))
)
)
for (const question of bank) {
if (items.length >= need) break
items.push({ question, fromDb: false })
}
}
return items
}
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz")
const needImage = quizRounds.filter((r) => r.pool === "image").length
const needQuiz = quizRounds.length - needImage
const cats = room.settings.categories
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats)
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats)
// On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
const queue: QueueItem[] = []
for (const r of quizRounds) {
const group = r.pool === "image" ? imageGroup : quizGroup
const item = group.shift()
if (item) {
queue.push(item)
}
}
poolByRoom.set(room, queue)
}
/** Pioche la prochaine question. Fallback aléatoire (banque en dur) si vide. */
export function takeQuestion(room: ServerRoom): {
question: QuizQuestion
fromDb: boolean
} {
const queue = poolByRoom.get(room)
if (queue && queue.length > 0) {
return queue.shift()!
}
const q = QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
return { question: q, fromDb: false }
}

View file

@ -0,0 +1,157 @@
// Banque de questions en dur pour la V1 (mode quiz culture geek).
// Le seed Open Trivia DB + le back-office manuel arrivent aux étapes 5 et 7 ;
// d'ici là, ce petit jeu suffit à jouer une partie complète de bout en bout.
import type { QuizFormat } from "@nerdware/shared"
export interface QuizQuestion {
id: string
format: QuizFormat
prompt: string
/** mcq/truefalse uniquement. */
choices?: string[]
/** mcq/truefalse uniquement. */
correctIndex?: number
/** free / image_reveal : réponses acceptées (matching tolérant). */
acceptedAnswers?: string[]
/** image_reveal : image à dévoiler (chemin relatif au serveur). */
imageUrl?: string
category: string
difficulty: number
}
const TRUE_FALSE = ["Vrai", "Faux"]
export const QUIZ_QUESTIONS: QuizQuestion[] = [
{
id: "q-zelda-princess",
format: "mcq",
prompt: "Dans la saga The Legend of Zelda, quel est le nom du héros ?",
choices: ["Zelda", "Link", "Ganon", "Navi"],
correctIndex: 1,
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-mario-plumber",
format: "truefalse",
prompt: "Mario est plombier de profession.",
choices: TRUE_FALSE,
correctIndex: 0,
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-onepiece-captain",
format: "mcq",
prompt: "Qui est le capitaine de l'équipage du Chapeau de paille dans One Piece ?",
choices: ["Zoro", "Sanji", "Luffy", "Usopp"],
correctIndex: 2,
category: "Manga",
difficulty: 1,
},
{
id: "q-pokemon-first",
format: "mcq",
prompt: "Quel Pokémon porte le numéro 001 dans le Pokédex national ?",
choices: ["Salamèche", "Carapuce", "Pikachu", "Bulbizarre"],
correctIndex: 3,
category: "Jeux vidéo",
difficulty: 2,
},
{
id: "q-naruto-village",
format: "mcq",
prompt: "De quel village ninja Naruto est-il originaire ?",
choices: ["Konoha", "Suna", "Kiri", "Iwa"],
correctIndex: 0,
category: "Manga",
difficulty: 2,
},
{
id: "q-minecraft-creeper",
format: "truefalse",
prompt: "Dans Minecraft, le Creeper explose au contact du joueur.",
choices: TRUE_FALSE,
correctIndex: 0,
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-starwars-vador",
format: "mcq",
prompt: "Dans Star Wars, qui est le père de Luke Skywalker ?",
choices: ["Obi-Wan Kenobi", "Dark Vador", "Yoda", "Palpatine"],
correctIndex: 1,
category: "Pop culture",
difficulty: 1,
},
{
id: "q-dbz-kamehameha",
format: "mcq",
prompt: "Dans Dragon Ball, quelle attaque emblématique Sangoku utilise-t-il ?",
choices: ["Genkidama", "Kamehameha", "Final Flash", "Makankosappo"],
correctIndex: 1,
category: "Manga",
difficulty: 1,
},
{
id: "q-tetris-origin",
format: "truefalse",
prompt: "Le jeu Tetris a été créé par un développeur soviétique.",
choices: TRUE_FALSE,
correctIndex: 0,
category: "Jeux vidéo",
difficulty: 2,
},
{
id: "q-matrix-pill",
format: "mcq",
prompt: "Dans Matrix, quelle pilule Neo doit-il prendre pour découvrir la vérité ?",
choices: ["La bleue", "La rouge", "La verte", "La jaune"],
correctIndex: 1,
category: "Pop culture",
difficulty: 1,
},
// Saisie libre (matching tolérant sur acceptedAnswers).
{
id: "q-free-math-1",
format: "free",
prompt: "Combien font 7 × 8 ?",
acceptedAnswers: ["56"],
category: "Maths",
difficulty: 1,
},
{
id: "q-free-math-2",
format: "free",
prompt: "Résous : 12² 44 = ?",
acceptedAnswers: ["100", "cent"],
category: "Maths",
difficulty: 2,
},
{
id: "q-free-zelda-princess",
format: "free",
prompt: "Comment s'appelle la princesse dans The Legend of Zelda ?",
acceptedAnswers: ["Zelda"],
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-free-pi",
format: "free",
prompt: "Donne les 3 premières décimales de Pi (après la virgule).",
acceptedAnswers: ["141"],
category: "Maths",
difficulty: 2,
},
{
id: "q-free-onepiece",
format: "free",
prompt: "Quel est le nom du bateau de l'équipage de Luffy (2e navire) ?",
acceptedAnswers: ["Thousand Sunny", "Sunny", "le Thousand Sunny"],
category: "Manga",
difficulty: 3,
},
]

View file

@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test"
import { RoomManager } from "../../../rooms"
import type { RoundContext } from "../../round"
import { QuizRound } from "./quiz-round"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
function makeCtx(q: QuizQuestion = question): {
ctx: RoundContext
p1: string
p2: string
} {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const ctx: RoundContext = {
room,
djId: null,
votes: new Map(),
startedAt: 0,
endsAt: 10_000,
data: { question: q, votedAt: new Map() },
}
return { ctx, p1: a.id, p2: b.id }
}
describe("QuizRound", () => {
test("verrouille le premier vote (idempotent)", () => {
const round = new QuizRound(() => 1000)
const { ctx, p1 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 1 })
round.submitAnswer(ctx, p1, { choiceIndex: 0 }) // ignoré
expect(ctx.votes.get(p1)).toEqual({ choiceIndex: 1 })
})
test("ignore un index hors bornes", () => {
const round = new QuizRound(() => 1000)
const { ctx, p1 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 99 })
expect(ctx.votes.has(p1)).toBe(false)
})
test("reveal expose la vérité + le résultat par joueur", () => {
const round = new QuizRound(() => 1000)
const { ctx, p1, p2 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
const { truth, perPlayerResult } = round.reveal(ctx)
expect(truth.correctIndex).toBe(question.correctIndex)
expect(perPlayerResult[p1]).toEqual({ choiceIndex: 1, correct: true })
expect(perPlayerResult[p2]).toEqual({ choiceIndex: 0, correct: false })
})
test("score = base + bonus rapidité pour les bonnes réponses uniquement", () => {
const round = new QuizRound(() => 1000) // vote à t=1000 sur 10000 → reste 90%
const { ctx, p1, p2 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste, rapide
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
})
test("free : matching tolérant sur acceptedAnswers", () => {
const free: QuizQuestion = {
id: "f1",
format: "free",
prompt: "7 × 8 ?",
acceptedAnswers: ["56"],
category: "Maths",
difficulty: 1,
}
const round = new QuizRound(() => 1000)
const { ctx, p1, p2 } = makeCtx(free)
round.submitAnswer(ctx, p1, { text: " 56 " }) // juste (espaces tolérés)
round.submitAnswer(ctx, p2, { text: "57" }) // faux
const { truth, perPlayerResult } = round.reveal(ctx)
expect(truth).toEqual({ answer: "56" })
expect(perPlayerResult[p1].correct).toBe(true)
expect(perPlayerResult[p2].correct).toBe(false)
expect(round.score(ctx)).toEqual([{ playerId: p1, delta: 190 }])
})
})

View file

@ -0,0 +1,196 @@
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
// Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant).
import type {
Answer,
QuizPerPlayerResult,
QuizQuestionPayload,
QuizRevealTruth,
ScoreDelta,
} from "@nerdware/shared"
import { hasDb } from "../../../db"
import { botCorrectChance } from "../../bot"
import { markQuestionPlayed } from "../../../db/quiz-repo"
import { fuzzyMatch } from "../../match"
import type {
GameRound,
RoundContext,
RoundRecapInfo,
RoundStart,
} from "../../round"
import type { ServerRoom } from "../../../rooms"
import type { QuizQuestion } from "./questions"
import { takeQuestion } from "./pool"
const BASE_POINTS = 100
const SPEED_BONUS_MAX = 100
/** Données privées de la manche (jamais diffusées). */
interface QuizRoundData {
question: QuizQuestion
/** Premier instant de vote par joueur, pour le bonus rapidité. */
votedAt: Map<string, number>
}
function asChoice(answer: Answer): number | null {
const v = (answer as { choiceIndex?: unknown }).choiceIndex
return typeof v === "number" ? v : null
}
function asText(answer: Answer): string | null {
const v = (answer as { text?: unknown }).text
return typeof v === "string" ? v : null
}
/** Formats à réponse libre (saisie texte + matching tolérant). */
function isTextFormat(question: QuizQuestion): boolean {
return question.format === "free" || question.format === "image_reveal"
}
/** Réponse correcte ? Selon le format de la question. */
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
if (!answer) {
return false
}
if (isTextFormat(question)) {
const text = asText(answer)
if (!text) {
return false
}
return (question.acceptedAnswers ?? []).some((a) => fuzzyMatch(text, a))
}
return asChoice(answer) === question.correctIndex
}
export class QuizRound implements GameRound {
readonly type = "quiz" as const
constructor(private readonly now: () => number = Date.now) {}
start(room: ServerRoom): RoundStart {
const { question, fromDb } = takeQuestion(room)
// Anti-répétition cross-session : on enregistre la question jouée (DB only).
if (fromDb && hasDb) {
void markQuestionPlayed(room.code, question.id)
}
const payload: QuizQuestionPayload = {
format: question.format,
prompt: question.prompt,
category: question.category,
difficulty: question.difficulty,
}
if (question.format === "mcq" || question.format === "truefalse") {
payload.choices = question.choices
}
if (question.format === "image_reveal") {
payload.imageUrl = question.imageUrl
}
const data: QuizRoundData = { question, votedAt: new Map() }
return { djId: null, payload, data }
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent) : on ignore les votes suivants.
if (ctx.votes.has(playerId)) {
return
}
const { question, votedAt } = ctx.data as QuizRoundData
if (isTextFormat(question)) {
const text = asText(answer)
if (text === null || text.trim().length === 0) {
return
}
ctx.votes.set(playerId, { text })
} else {
const choiceIndex = asChoice(answer)
const count = question.choices?.length ?? 0
if (choiceIndex === null || choiceIndex < 0 || choiceIndex >= count) {
return
}
ctx.votes.set(playerId, { choiceIndex })
}
votedAt.set(playerId, this.now())
}
reveal(ctx: RoundContext): {
truth: QuizRevealTruth
perPlayerResult: QuizPerPlayerResult
} {
const { question } = ctx.data as QuizRoundData
const perPlayerResult: QuizPerPlayerResult = {}
for (const player of ctx.room.players.values()) {
const vote = ctx.votes.get(player.id)
perPlayerResult[player.id] = {
choiceIndex: vote ? asChoice(vote) : null,
correct: isCorrect(question, vote),
}
}
const truth: QuizRevealTruth = isTextFormat(question)
? { answer: question.acceptedAnswers?.[0] ?? "" }
: { correctIndex: question.correctIndex }
return { truth, perPlayerResult }
}
score(ctx: RoundContext): ScoreDelta[] {
const { question, votedAt } = ctx.data as QuizRoundData
const total = ctx.endsAt - ctx.startedAt
const deltas: ScoreDelta[] = []
for (const [playerId, vote] of ctx.votes) {
if (!isCorrect(question, vote)) {
continue
}
// Bonus rapidité : proportionnel au temps restant au moment du vote.
const at = votedAt.get(playerId) ?? ctx.endsAt
const remaining = Math.max(0, Math.min(1, (ctx.endsAt - at) / total))
const bonus = Math.round(SPEED_BONUS_MAX * remaining)
deltas.push({ playerId, delta: BASE_POINTS + bonus })
}
return deltas
}
botAnswer(ctx: RoundContext): Answer {
const { question } = ctx.data as QuizRoundData
// Le bot répond juste selon le niveau choisi, sinon une réponse plausible.
const rightish = Math.random() < botCorrectChance(ctx.room.settings.botDifficulty)
if (isTextFormat(question)) {
return {
text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…",
}
}
const count = question.choices?.length ?? 1
if (rightish && typeof question.correctIndex === "number") {
return { choiceIndex: question.correctIndex }
}
return { choiceIndex: Math.floor(Math.random() * count) }
}
recap(ctx: RoundContext): RoundRecapInfo {
const { question } = ctx.data as QuizRoundData
const answer = isTextFormat(question)
? (question.acceptedAnswers?.[0] ?? "")
: (question.choices?.[question.correctIndex ?? 0] ?? "")
const answers = []
for (const player of ctx.room.players.values()) {
const vote = ctx.votes.get(player.id)
if (!vote) {
continue
}
const text = isTextFormat(question)
? (asText(vote) ?? "")
: (question.choices?.[asChoice(vote) ?? -1] ?? "?")
answers.push({
playerId: player.id,
answer: text,
correct: isCorrect(question, vote),
})
}
return {
label: question.prompt,
answer,
category: question.category,
imageUrl:
question.format === "image_reveal" ? question.imageUrl : undefined,
answers,
}
}
}

View file

@ -0,0 +1,25 @@
// Registre des épreuves. Chaque mode s'enregistre via registerRound() ;
// le moteur instancie une manche fraîche par round via createRound().
import type { RoundType } from "@nerdware/shared"
import type { GameRound } from "./round"
export type RoundFactory = () => GameRound
const factories = new Map<RoundType, RoundFactory>()
export function registerRound(type: RoundType, factory: RoundFactory): void {
factories.set(type, factory)
}
export function createRound(type: RoundType): GameRound {
const factory = factories.get(type)
if (!factory) {
throw new Error(`Aucune épreuve enregistrée pour le type "${type}"`)
}
return factory()
}
export function hasRound(type: RoundType): boolean {
return factories.has(type)
}

View file

@ -0,0 +1,81 @@
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
import type {
Answer,
MediaControlPayload,
RevealPayload,
RoundRecap,
RoundType,
ScoreDelta,
} from "@nerdware/shared"
import type { ServerRoom } from "../rooms"
/** Partie du récap fournie par l'épreuve (le moteur complète index/type/scorers). */
export type RoundRecapInfo = Omit<RoundRecap, "index" | "type" | "scorers">
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
export interface RoundStart {
/** Joueur DJ (blindtest) ; null pour les épreuves sans DJ (quiz). */
djId?: string | null
/** Durée de la manche en secondes ; défaut = room.settings.roundDuration. */
durationSec?: number
/** Payload broadcasté aux clients, SANS la réponse. */
payload: unknown
/**
* Joueur recevant un round:start privé avec `mine: true` et exclu des votants
* (blindtest : le contributeur du titre). Jamais révélé aux autres.
*/
secretPlayerId?: string
/** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */
data?: unknown
}
/** Contexte runtime d'une manche, fourni aux callbacks de l'épreuve. */
export interface RoundContext {
room: ServerRoom
djId: string | null
/** Votes reçus : playerId → réponse. Muté par submitAnswer. */
votes: Map<string, Answer>
startedAt: number
endsAt: number
/** Données privées renvoyées par start(). */
data: unknown
}
/**
* Une épreuve. Implémente le cycle start submitAnswer reveal score.
* Le serveur est autoritaire : start/reveal/score ne s'exécutent que côté serveur.
*/
export interface GameRound {
type: RoundType
/** Prépare la manche (tire le DJ, choisit le contenu) et renvoie le payload public. */
start(room: ServerRoom): RoundStart
/** Enregistre/valide le vote d'un joueur dans ctx.votes. Doit être idempotent. */
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void
/** Calcule la vérité + le détail révélé à tous. */
reveal(ctx: RoundContext): RevealPayload
/** Renvoie les variations de score à appliquer (le moteur les applique). */
score(ctx: RoundContext): ScoreDelta[]
/** Réponse automatique d'un bot (vote plausible, parfois juste). */
botAnswer(ctx: RoundContext, playerId: string): Answer
/** Résumé de la manche pour le récap de fin de partie (intitulé + réponse + média). */
recap(ctx: RoundContext): RoundRecapInfo
}
/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */
export interface RoomGameController {
run(): Promise<void>
handleVote(playerId: string, answer: Answer): void
/** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void
/** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */
resync(socketId: string, playerId: string): void
}

376
apps/server/src/handlers.ts Normal file
View file

@ -0,0 +1,376 @@
// Handlers Socket.IO du cycle lobby. Enregistrés par socket à la connexion.
import type { Socket } from "socket.io"
import type {
ClientToServerEvents,
ErrorPayload,
InterServerEvents,
ServerToClientEvents,
SocketData,
} from "@nerdware/shared"
import type { IoServer } from "./socket"
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
import { randomUUID } from "node:crypto"
import { GameEngine } from "./game/engine"
import { hasRound } from "./game/registry"
import { prepareRoom } from "./game/modes"
import {
extractYoutubeId,
fetchYoutubeMeta,
} from "./game/modes/blindtest/youtube"
type AppSocket = Socket<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>
const MAX_NAME_LENGTH = 24
function cleanName(raw: unknown): string | null {
if (typeof raw !== "string") {
return null
}
const name = raw.trim().slice(0, MAX_NAME_LENGTH)
return name.length > 0 ? name : null
}
function toErrorPayload(err: unknown): ErrorPayload {
if (err instanceof RoomError) {
return { code: err.code, message: err.message }
}
return { code: "INTERNAL", message: "Une erreur est survenue." }
}
export function registerRoomHandlers(
io: IoServer,
socket: AppSocket,
rooms: RoomManager
): void {
const broadcastState = (room: ServerRoom) => {
io.to(room.code).emit("room:state", rooms.toSnapshot(room))
}
socket.on("room:create", (payload, ack) => {
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
const name = cleanName(payload?.playerName) ?? ""
try {
const { room, player } = rooms.create(name, socket.id)
socket.data.playerId = player.id
socket.data.roomCode = room.code
socket.join(room.code)
ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } })
broadcastState(room)
} catch (err) {
ack({ ok: false, error: toErrorPayload(err) })
}
})
socket.on("room:join", (payload, ack) => {
const name = cleanName(payload?.playerName) ?? ""
const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : ""
try {
const { room, player } = rooms.join(code, name, socket.id)
socket.data.playerId = player.id
socket.data.roomCode = room.code
socket.join(room.code)
ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } })
broadcastState(room)
} catch (err) {
ack({ ok: false, error: toErrorPayload(err) })
}
})
socket.on("room:rejoin", (payload, ack) => {
const code =
typeof payload?.roomCode === "string"
? payload.roomCode.trim().toUpperCase()
: ""
const room = rooms.get(code)
const player = room?.players.get(payload?.playerId ?? "")
if (!room || !player || player.reconnectToken !== payload?.token) {
ack({
ok: false,
error: { code: "REJOIN_FAILED", message: "Reconnexion impossible." },
})
return
}
player.connected = true
player.socketId = socket.id
socket.data.playerId = player.id
socket.data.roomCode = room.code
socket.join(room.code)
ack({
ok: true,
data: {
roomCode: room.code,
playerId: player.id,
token: player.reconnectToken,
},
})
io.to(room.code).emit("player:rejoined", { playerId: player.id })
broadcastState(room)
// Re-pousse la manche en cours au socket reconnecté (si partie en cours).
room.game?.resync(socket.id, player.id)
})
socket.on("player:setName", (payload) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
const name = cleanName(payload?.name)
if (!room || !playerId || !name) {
return
}
const player = room.players.get(playerId)
if (!player) {
return
}
player.name = name
broadcastState(room)
})
socket.on("lobby:updateSettings", (payload) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
return
}
room.settings = {
gameType: payload.gameType,
mixedModes: payload.mixedModes,
blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer,
categories: payload.categories,
botDifficulty: payload.botDifficulty,
rounds: payload.rounds,
}
broadcastState(room)
})
socket.on("lobby:addBot", () => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId || room.status !== "lobby") {
return
}
if (rooms.addBot(room)) {
broadcastState(room)
}
})
socket.on("lobby:removeBot", () => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
return
}
rooms.removeBot(room)
broadcastState(room)
})
socket.on("game:start", (ack) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
ack({ ok: false, error: { code: "NOT_HOST", message: "Seul l'hôte peut lancer." } })
return
}
if (room.status !== "lobby") {
ack({ ok: false, error: { code: "ALREADY_STARTED", message: "Partie déjà lancée." } })
return
}
if (room.settings.rounds.length === 0) {
ack({ ok: false, error: { code: "NO_ROUNDS", message: "Aucune épreuve configurée." } })
return
}
const unknown = room.settings.rounds.find((r) => !hasRound(r.type))
if (unknown) {
ack({
ok: false,
error: { code: "UNKNOWN_MODE", message: `Épreuve indisponible : ${unknown.type}.` },
})
return
}
const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest")
const active = [...room.players.values()].filter(
(p) => p.connected && p.name !== ""
)
const connected = active.length
const humans = active.filter((p) => !p.isBot).length
if (hasBlindtest && (connected < 3 || humans < 2)) {
ack({
ok: false,
error: {
code: "NEED_THREE",
message: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.",
},
})
return
}
if (hasBlindtest) {
const tpp = room.settings.tracksPerPlayer
const pending = [...room.players.values()].some(
(p) =>
p.connected &&
p.name !== "" &&
!p.isBot &&
room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp
)
if (pending) {
ack({
ok: false,
error: {
code: "TRACKS_PENDING",
message: "Tous les joueurs n'ont pas encore soumis leurs titres.",
},
})
return
}
}
ack({ ok: true, data: null })
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
// Fire-and-forget : la boucle de jeu broadcast son propre état.
void prepareRoom(room)
.then(() => {
const engine = new GameEngine(io, rooms, room)
room.game = engine
return engine.run()
})
.catch((err) => console.error("[game] run failed", err))
})
socket.on("blindtest:submitTrack", async (payload, ack) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
if (!room || !playerId || room.status !== "lobby") {
ack({ accepted: false, reason: "Soumission impossible maintenant." })
return
}
const youtubeId = extractYoutubeId(payload?.youtubeUrl ?? "")
if (!youtubeId) {
ack({ accepted: false, reason: "Lien YouTube invalide." })
return
}
const mine = room.blindtestTracks.filter((t) => t.submittedBy === playerId)
if (mine.length >= room.settings.tracksPerPlayer) {
ack({ accepted: false, reason: "Quota de titres atteint." })
return
}
if (room.blindtestTracks.some((t) => t.youtubeId === youtubeId)) {
ack({ accepted: false, reason: "Titre déjà soumis." })
return
}
const meta = await fetchYoutubeMeta(youtubeId)
if (!meta) {
ack({
accepted: false,
reason: "Vidéo introuvable ou non intégrable.",
})
return
}
// Re-vérifie la room après l'await (le joueur a pu se déconnecter).
if (room.status !== "lobby" || !room.players.has(playerId)) {
ack({ accepted: false, reason: "Soumission impossible maintenant." })
return
}
const trackId = randomUUID()
room.blindtestTracks.push({
id: trackId,
youtubeId,
url: payload.youtubeUrl,
title: meta.title,
artist: meta.artist,
submittedBy: playerId,
})
ack({
accepted: true,
trackId,
youtubeId,
title: meta.title,
count: mine.length + 1,
})
broadcastState(room)
})
socket.on("blindtest:removeTrack", (payload, ack) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
if (!room || !playerId || room.status !== "lobby") {
ack({ ok: false })
return
}
const before = room.blindtestTracks.length
room.blindtestTracks = room.blindtestTracks.filter(
(t) => !(t.id === payload.trackId && t.submittedBy === playerId)
)
const removed = room.blindtestTracks.length < before
ack({ ok: removed })
if (removed) {
broadcastState(room)
}
})
socket.on("media:control", (payload) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || !room.game || !socket.data.playerId) {
return
}
room.game.handleMediaControl(socket.data.playerId, payload)
})
socket.on("round:vote", (payload) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || !room.game || !socket.data.playerId) {
return
}
room.game.handleVote(socket.data.playerId, payload.answer)
})
socket.on("lobby:return", () => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
return
}
// Rejouer : retour au lobby, scores remis à zéro, joueurs et titres conservés.
room.status = "lobby"
room.currentRound = -1
room.game = null
for (const playerId of room.scores.keys()) {
room.scores.set(playerId, 0)
}
broadcastState(room)
})
socket.on("room:leave", () => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
if (!code || !playerId) {
return
}
socket.leave(code)
socket.data.roomCode = undefined
socket.data.playerId = undefined
const room = rooms.leave(code, playerId)
if (room) {
io.to(room.code).emit("player:left", { playerId })
broadcastState(room)
}
})
socket.on("disconnect", () => {
const affected = rooms.handleDisconnect(socket.id)
if (!affected) {
return
}
io.to(affected.room.code).emit("player:left", { playerId: affected.playerId })
broadcastState(affected.room)
})
}

59
apps/server/src/index.ts Normal file
View file

@ -0,0 +1,59 @@
import { mkdirSync } from "node:fs"
import { resolve } from "node:path"
import Fastify from "fastify"
import cors from "@fastify/cors"
import multipart from "@fastify/multipart"
import fastifyStatic from "@fastify/static"
import { env, isDev } from "./env"
import { createSocketServer } from "./socket"
import { adminRoutes } from "./admin/routes"
import { listCategories } from "./db/quiz-repo"
import { listGameHistory } from "./db/history-repo"
import "./game/modes" // enregistre les épreuves (registerRound)
const app = Fastify({
logger: isDev
? { transport: { target: "pino-pretty" } }
: true,
})
await app.register(cors, { origin: env.corsOrigins })
// Images (image_reveal) : dossier servi en statique sur /uploads.
const uploadsRoot = resolve(env.uploadsDir)
mkdirSync(uploadsRoot, { recursive: true })
await app.register(multipart, { limits: { fileSize: 8 * 1024 * 1024 } })
await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
// Catégories de quiz disponibles (public, pour le filtre du lobby).
app.get("/api/categories", async () => listCategories())
// Historique des parties d'une room (public).
app.get<{ Params: { code: string } }>("/api/history/:code", async (req) =>
listGameHistory(req.params.code.toUpperCase())
)
await app.register(adminRoutes, { prefix: "/api/admin" })
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
const io = createSocketServer(app.server)
try {
await app.listen({ host: env.host, port: env.port })
app.log.info(`Socket.IO ready (${io.engine.clientsCount} clients)`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
// Arrêt propre : on ferme Socket.IO puis Fastify.
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, async () => {
app.log.info(`${signal} received, shutting down`)
await io.close()
await app.close()
process.exit(0)
})
}

245
apps/server/src/rooms.ts Normal file
View file

@ -0,0 +1,245 @@
// Gestion des rooms en mémoire. Source de vérité côté serveur.
// Pas de Redis en V1 : une simple Map suffit pour jouer entre potes.
import { randomUUID } from "node:crypto"
import {
DEFAULT_ROOM_SETTINGS,
type RoomSettings,
type RoomSnapshot,
type RoomStatus,
} from "@nerdware/shared"
import type { RoomGameController } from "./game/round"
/** Joueur côté serveur : porte le socketId, jamais exposé au client. */
export interface ServerPlayer {
id: string
name: string
connected: boolean
socketId: string | null
/** Secret de reconnexion (refresh/coupure). */
reconnectToken: string
/** Joueur virtuel (vote automatiquement, jamais DJ). */
isBot: boolean
}
const BOT_NAMES = [
"Bot BMO",
"Bot R2-D2",
"Bot GLaDOS",
"Bot Clank",
"Bot Wall-E",
"Bot K-2SO",
"Bot HAL",
"Bot Bender",
]
const MAX_PLAYERS = 8
/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */
export interface BlindtestTrack {
id: string
youtubeId: string
url: string
title: string
artist: string
submittedBy: string
}
/** Room côté serveur : état runtime complet (le snapshot public en dérive). */
export interface ServerRoom {
code: string
status: RoomStatus
hostId: string
players: Map<string, ServerPlayer>
settings: RoomSettings
scores: Map<string, number>
currentRound: number
/** Titres soumis pour le blindtest (mélangés au lancement). */
blindtestTracks: BlindtestTrack[]
/** Moteur de jeu actif (null tant qu'on est dans le lobby). */
game: RoomGameController | null
}
/** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */
const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
const CODE_LENGTH = 4
export class RoomManager {
private rooms = new Map<string, ServerRoom>()
/** Crée une room et y place le créateur comme hôte. */
create(playerName: string, socketId: string): { room: ServerRoom; player: ServerPlayer } {
const code = this.generateCode()
const player = this.makePlayer(playerName, socketId)
const room: ServerRoom = {
code,
status: "lobby",
hostId: player.id,
players: new Map([[player.id, player]]),
settings: { ...DEFAULT_ROOM_SETTINGS },
scores: new Map([[player.id, 0]]),
currentRound: -1,
blindtestTracks: [],
game: null,
}
this.rooms.set(code, room)
return { room, player }
}
/** Ajoute un joueur à une room existante. Lève une erreur si introuvable ou fermée. */
join(
code: string,
playerName: string,
socketId: string
): { room: ServerRoom; player: ServerPlayer } {
const room = this.rooms.get(code)
if (!room) {
throw new RoomError("ROOM_NOT_FOUND", "Cette room n'existe pas.")
}
if (room.status !== "lobby") {
throw new RoomError("ROOM_IN_PROGRESS", "La partie a déjà commencé.")
}
const player = this.makePlayer(playerName, socketId)
room.players.set(player.id, player)
room.scores.set(player.id, 0)
return { room, player }
}
get(code: string): ServerRoom | undefined {
return this.rooms.get(code)
}
/** Ajoute un bot (joueur virtuel) à la room. Renvoie false si plein. */
addBot(room: ServerRoom): boolean {
if (room.players.size >= MAX_PLAYERS) {
return false
}
const used = new Set([...room.players.values()].map((p) => p.name))
const name =
BOT_NAMES.find((n) => !used.has(n)) ?? `Bot ${room.players.size + 1}`
const bot: ServerPlayer = {
id: randomUUID(),
name,
connected: true,
socketId: null,
reconnectToken: "",
isBot: true,
}
room.players.set(bot.id, bot)
room.scores.set(bot.id, 0)
return true
}
/** Retire un bot (le dernier ajouté). */
removeBot(room: ServerRoom): void {
const bots = [...room.players.values()].filter((p) => p.isBot)
const last = bots[bots.length - 1]
if (last) {
room.players.delete(last.id)
room.scores.delete(last.id)
}
}
/**
* Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin,
* supprime la room si plus personne. Renvoie la room (ou null si supprimée).
*/
leave(code: string, playerId: string): ServerRoom | null {
const room = this.rooms.get(code)
if (!room) {
return null
}
room.players.delete(playerId)
room.scores.delete(playerId)
if (room.players.size === 0) {
this.rooms.delete(code)
return null
}
if (room.hostId === playerId) {
const named = [...room.players.values()].find((p) => p.name !== "")
room.hostId = (named ?? [...room.players.values()][0]).id
}
return room
}
/**
* Marque le joueur d'un socket comme déconnecté.
* Retourne la room affectée et le playerId, ou null si non rattaché.
* La room et le joueur restent en mémoire pour permettre un futur rejoin.
*/
handleDisconnect(socketId: string): { room: ServerRoom; playerId: string } | null {
for (const room of this.rooms.values()) {
for (const player of room.players.values()) {
if (player.socketId === socketId) {
player.connected = false
player.socketId = null
return { room, playerId: player.id }
}
}
}
return null
}
/** Projette la room vers la vue publique diffusée aux clients (sans secrets). */
toSnapshot(room: ServerRoom): RoomSnapshot {
// Seuls les joueurs ayant choisi un pseudo sont visibles dans la room.
const named = [...room.players.values()].filter((p) => p.name !== "")
return {
code: room.code,
status: room.status,
hostId: room.hostId,
players: named.map((p) => ({
id: p.id,
name: p.name,
connected: p.connected,
bot: p.isBot,
})),
settings: room.settings,
scores: named.map((p) => ({
playerId: p.id,
score: room.scores.get(p.id) ?? 0,
})),
currentRound: room.currentRound,
totalRounds: room.settings.rounds.length,
submissions: named.map((p) => ({
playerId: p.id,
count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length,
})),
}
}
private makePlayer(name: string, socketId: string): ServerPlayer {
return {
id: randomUUID(),
name,
connected: true,
socketId,
reconnectToken: randomUUID(),
isBot: false,
}
}
private generateCode(): string {
// Très peu de collisions à cette échelle ; on retire quand même par sûreté.
for (let attempt = 0; attempt < 100; attempt++) {
let code = ""
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]
}
if (!this.rooms.has(code)) {
return code
}
}
throw new RoomError("CODE_EXHAUSTED", "Impossible de générer un code de room.")
}
}
/** Erreur métier portant un code stable pour le client. */
export class RoomError extends Error {
constructor(
public code: string,
message: string
) {
super(message)
this.name = "RoomError"
}
}

41
apps/server/src/socket.ts Normal file
View file

@ -0,0 +1,41 @@
import type { Server as HttpServer } from "node:http"
import { Server } from "socket.io"
import type {
ClientToServerEvents,
InterServerEvents,
ServerToClientEvents,
SocketData,
} from "@nerdware/shared"
import { env } from "./env"
import { registerRoomHandlers } from "./handlers"
import { RoomManager } from "./rooms"
/** Serveur Socket.IO entièrement typé via les events partagés. */
export type IoServer = Server<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>
/** Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. */
export function createSocketServer(httpServer: HttpServer): IoServer {
const io: IoServer = new Server(httpServer, {
cors: {
origin: env.corsOrigins,
methods: ["GET", "POST"],
},
})
const rooms = new RoomManager()
io.on("connection", (socket) => {
console.log(`[ws] connect ${socket.id}`)
registerRoomHandlers(io, socket, rooms)
socket.on("disconnect", (reason) => {
console.log(`[ws] disconnect ${socket.id} (${reason})`)
})
})
return io
}

16
apps/server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

1
apps/web/.env.example Normal file
View file

@ -0,0 +1 @@
VITE_SERVER_URL=http://localhost:3001

23
apps/web/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Client Vite/React : build statique puis service via nginx.
# Contexte de build = racine du repo. VITE_SERVER_URL est figé au build.
FROM oven/bun:1.3.14-alpine AS build
WORKDIR /app
COPY package.json bun.lock turbo.json ./
COPY apps/web/package.json apps/web/package.json
COPY apps/server/package.json apps/server/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN bun install --frozen-lockfile
COPY packages packages
COPY apps/web apps/web
ARG VITE_SERVER_URL
ENV VITE_SERVER_URL=$VITE_SERVER_URL
RUN bun run --filter web build
FROM nginx:1.27-alpine AS runner
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -1,10 +1,14 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-monorepo</title>
<title>NerdWare — Party game culture geek</title>
<meta
name="description"
content="Party game web multi-épreuves (quiz culture geek + blindtest YouTube), à jouer entre potes en temps réel."
/>
</head>
<body>
<div id="root"></div>

17
apps/web/nginx.conf Normal file
View file

@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA : toutes les routes (/room/:code, /join/:code, /admin) index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache long pour les assets fingerprintés.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View file

@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.0.1",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
@ -12,14 +12,25 @@
"preview": "vite preview"
},
"dependencies": {
"@dicebear/core": "^10",
"@dicebear/styles": "^10",
"@icons-pack/react-simple-icons": "^13",
"@nerdware/shared": "workspace:*",
"@tanstack/react-query": "^5.101.0",
"@workspace/ui": "workspace:*",
"canvas-confetti": "^1.9.4",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"socket.io-client": "^4.8.1",
"wouter": "^3.7.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^10",
"@tailwindcss/vite": "^4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",

View file

@ -1,19 +1,25 @@
import { Button } from "@workspace/ui/components/button"
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 (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
<Switch>
<Route path="/" component={HomePage} />
<Route path="/join/:code">
{(params) => <JoinPage code={params.code.toUpperCase()} />}
</Route>
<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>
</div>
<div className="text-muted-foreground font-mono text-xs">
(Press <kbd>d</kbd> to toggle dark mode)
</div>
</div>
</div>
</Route>
</Switch>
)
}

View file

@ -0,0 +1,10 @@
# Sons du jeu
Dépose ici les effets sonores (détectés automatiquement au build).
- **`victory.mp3`** → jingle joué sur l'écran de fin de partie.
(formats acceptés : `.mp3`, `.ogg`, `.wav` — nomme le fichier `victory.*`)
Si aucun fichier n'est présent, le son est simplement ignoré (pas d'erreur).
> ⚠️ N'utilise que des sons dont tu as les droits (libres / CC0).

Binary file not shown.

View file

@ -0,0 +1,26 @@
# Animations de transition (style WarioWare)
Dépose ici des fichiers d'animation pour les transitions entre manches :
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
Rangement par mode (recommandé pour des transitions personnalisées) :
- `quiz/` → jouées avant une manche de **quiz**
- `image/` → jouées avant une manche du mode **images**
- `blindtest/` → jouées avant une manche de **blindtest**
- racine (`./`) → **communs**, fallback pour tous les modes
Règles :
- Détectés automatiquement au build (`import.meta.glob`).
- À chaque manche, un fichier est tiré **au hasard** dans le pool du mode
(dossier du mode + communs).
- S'il n'y en a aucun, l'animation Framer par défaut est jouée : annonce du
mode (« QUIZ » / « BLINDTEST ») quand on change de mode, sinon « Question/Titre N ».
Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré
plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans
`src/components/round-transition.tsx`), calée sur le délai de préparation
serveur (`leadMs`) pour ne pas rogner le temps de réponse.
> ⚠️ N'utilise que des médias dont tu as les droits (pas d'assets Nintendo).

View file

@ -0,0 +1,28 @@
import { Avatar as DicebearAvatar, Style } from "@dicebear/core"
import loreleiDefinition from "@dicebear/styles/lorelei.json"
// Génération locale (pas d'appel à l'API DiceBear) : self-hosted, offline,
// aucun pseudo envoyé à un tiers. Lorelei par Lisa Wischofsky — licence CC0 1.0.
const style = new Style(loreleiDefinition)
const cache = new Map<string, string>()
/** Data URI de l'avatar pour un seed donné, mémoïsé. */
function avatarUri(seed: string): string {
let uri = cache.get(seed)
if (!uri) {
uri = new DicebearAvatar(style, { seed }).toDataUri()
cache.set(seed, uri)
}
return uri
}
/** Avatar DiceBear (style lorelei), seedé par le pseudo → stable et reconnaissable. */
export function Avatar({ seed, className }: { seed: string; className?: string }) {
return (
<img
src={avatarUri(seed)}
alt={`Avatar de ${seed}`}
className={`bg-muted shrink-0 rounded-full ${className ?? ""}`}
/>
)
}

View file

@ -0,0 +1,354 @@
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { Disc3, Pause, Play, Rewind } from "lucide-react"
import type {
BlindtestAnswer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
RoomSnapshot,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useYoutube, type YoutubeApi } from "@/lib/youtube"
import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context"
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 disabled:opacity-50"
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, "0")}`
}
export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
const mediaSync = useRoomStore((s) => s.mediaSync)
const playerId = useRoomStore((s) => s.playerId)
const hasVoted = useRoomStore((s) => s.hasVoted)
const voteBlindtest = useRoomStore((s) => s.voteBlindtest)
const mediaControl = useRoomStore((s) => s.mediaControl)
const { t } = useI18n()
const track = round?.payload as BlindtestRoundPayload | undefined
const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "")
const isDj = !!playerId && round?.djId === playerId
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
const showReveal = truth !== null
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
useEffect(() => {
if (isDj || !ready || !mediaSync) {
return
}
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
if (mediaSync.action === "play") {
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
api.play()
} else if (mediaSync.action === "pause") {
api.seek(mediaSync.positionSec)
api.pause()
} else {
api.seek(mediaSync.positionSec)
}
}, [mediaSync, isDj, ready, api])
// Au reveal, on coupe le son.
useEffect(() => {
if (showReveal && ready) {
api.pause()
}
}, [showReveal, ready, api])
return (
<div className="flex w-full max-w-lg flex-col gap-5">
<header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase">
{t.game.blindtest(snapshot.currentRound + 1, snapshot.totalRounds)}
</span>
{!showReveal && round && (
<Countdown
key={round.endsAt}
startsAt={round.startsAt}
endsAt={round.endsAt}
/>
)}
</header>
{/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
manche (son seulement), puis révélé (bannière visible) au reveal. */}
<div className="bg-card relative aspect-video w-full overflow-hidden rounded-2xl">
<div
ref={hostRef}
className="absolute inset-0 [&>iframe]:absolute [&>iframe]:inset-0 [&>iframe]:size-full"
/>
{!showReveal && (
<div className="bg-card absolute inset-0 flex items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
>
<Disc3 className="text-muted-foreground size-24" />
</motion.div>
</div>
)}
</div>
{isDj && !showReveal && (
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
)}
{!showReveal &&
(round?.mine ? (
<MisleadCard mode={snapshot.settings.blindtestMode} />
) : (
<VoteForm
mode={snapshot.settings.blindtestMode}
snapshot={snapshot}
playerId={playerId}
disabled={hasVoted}
onVote={voteBlindtest}
/>
))}
{showReveal && truth && (
<RevealCard
truth={truth}
result={
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
}
/>
)}
</div>
)
}
function DjControls({
ready,
api,
mediaControl,
}: {
ready: boolean
api: YoutubeApi
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
}) {
const { t } = useI18n()
const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0)
useEffect(() => {
if (!ready) {
return
}
const id = setInterval(() => {
setPos(api.time())
setDur(api.duration())
}, 400)
return () => clearInterval(id)
}, [ready, api])
return (
<div className="flex flex-col gap-3">
<span className="text-muted-foreground text-center text-xs uppercase">
{t.blindtest.djHint}
</span>
<div className="flex justify-center gap-2">
<Button
size="sm"
disabled={!ready}
onClick={() => {
api.play()
mediaControl("play", api.time())
}}
>
<Play /> {t.blindtest.play}
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.pause()
mediaControl("pause", api.time())
}}
>
<Pause /> {t.blindtest.pause}
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.seek(0)
setPos(0)
mediaControl("seek", 0)
}}
>
<Rewind /> {t.blindtest.restart}
</Button>
</div>
<div className="flex items-center gap-2 text-xs tabular-nums">
<span className="text-muted-foreground w-9 text-right">{fmt(pos)}</span>
<input
type="range"
min={0}
max={Math.max(dur, 1)}
step="any"
value={Math.min(pos, dur || 0)}
disabled={!ready}
onChange={(e) => {
const v = Number(e.target.value)
setPos(v)
api.seek(v)
mediaControl("seek", v)
}}
className="accent-primary flex-1"
/>
<span className="text-muted-foreground w-9">{fmt(dur)}</span>
</div>
</div>
)
}
function MisleadCard({ mode }: { mode: BlindtestMode }) {
const { t } = useI18n()
return (
<div className="border-primary/40 bg-primary/5 flex flex-col items-center gap-2 rounded-xl border p-4 text-center">
<span className="text-2xl">🤫</span>
<p className="font-heading font-bold">{t.blindtest.yourTrack}</p>
<p className="text-muted-foreground text-sm">
{mode === "title_artist"
? t.blindtest.yourTrackTitleArtist
: t.blindtest.yourTrackWhoAdded}
</p>
</div>
)
}
function VoteForm({
mode,
snapshot,
playerId,
disabled,
onVote,
}: {
mode: BlindtestMode
snapshot: RoomSnapshot
playerId: string | null
disabled: boolean
onVote: (answer: BlindtestAnswer) => void
}) {
const { t } = useI18n()
const [title, setTitle] = useState("")
const [artist, setArtist] = useState("")
const [guessed, setGuessed] = useState<string | null>(null)
const needsText = mode === "title_artist" || mode === "mixed"
const needsWho = mode === "who_added" || mode === "mixed"
const canSubmit =
!disabled &&
(!needsText || title.trim().length > 0) &&
(!needsWho || guessed !== null)
function submit() {
const answer: {
title?: string
artist?: string
guessedPlayerId?: string
} = {}
if (needsText) {
answer.title = title.trim()
answer.artist = artist.trim()
}
if (needsWho && guessed) {
answer.guessedPlayerId = guessed
}
onVote(answer as BlindtestAnswer)
}
if (disabled) {
return (
<p className="text-muted-foreground text-center text-xs">
{t.game.answerSent}
</p>
)
}
return (
<div className="flex flex-col gap-3">
{needsText && (
<>
<input
className={inputClass}
placeholder={t.blindtest.titlePlaceholder}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
className={inputClass}
placeholder={t.blindtest.artistPlaceholder}
value={artist}
onChange={(e) => setArtist(e.target.value)}
/>
</>
)}
{needsWho && (
<div className="flex flex-wrap justify-center gap-2">
{snapshot.players
.filter((p) => p.id !== playerId)
.map((p) => (
<button
key={p.id}
onClick={() => setGuessed(p.id)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
guessed === p.id
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
<Avatar seed={p.name} className="size-5" />
{p.name}
</button>
))}
</div>
)}
<Button disabled={!canSubmit} onClick={submit}>
{t.blindtest.submit}
</Button>
</div>
)
}
function RevealCard({
truth,
result,
}: {
truth: BlindtestRevealTruth
result?: BlindtestPerPlayerResult[string]
}) {
const { t } = useI18n()
return (
<div className="flex flex-col items-center gap-2 text-center">
<p className="font-heading text-lg font-bold text-balance">{truth.title}</p>
<p className="text-muted-foreground text-sm">{truth.artist}</p>
<p className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t.blindtest.revealAddedBy}</span>
<Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span>
</p>
{result && (
<p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
>
{result.points > 0 ? t.blindtest.points(result.points) : t.game.wrong}
</p>
)}
</div>
)
}

View file

@ -0,0 +1,35 @@
import { createPortal } from "react-dom"
import { motion } from "framer-motion"
/**
* Explosion plein écran : flash blanc + 💥 géant.
* Rendue dans document.body via portal pour échapper aux transforms parents,
* et montée avec une `key` qui change l'animation rejoue à chaque boom.
*/
export function Boom() {
return createPortal(
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
<motion.div
className="absolute inset-0 bg-white"
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.9, 0] }}
transition={{ duration: 0.5, times: [0, 0.12, 1], ease: "easeOut" }}
/>
<motion.span
className="relative leading-none select-none"
style={{ fontSize: "45vmin" }}
initial={{ scale: 0, rotate: -20, opacity: 0 }}
animate={{
scale: [0, 1.35, 1.1],
rotate: [-20, 12, 0],
opacity: [0, 1, 0],
x: [0, 12, -8, 0],
}}
transition={{ duration: 0.8, times: [0, 0.3, 1], ease: "easeOut" }}
>
💥
</motion.span>
</div>,
document.body
)
}

View file

@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react"
import { motion } from "framer-motion"
import { useRoomStore } from "@/store/room"
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
/**
* Secondes restantes. Avant `startsAt` (phase de préparation / transition),
* on affiche la durée pleine sans décompter.
*/
function secondsLeft(startsAt: number, endsAt: number): number {
const now = Date.now()
if (now < startsAt) {
return Math.ceil((endsAt - startsAt) / 1000)
}
return Math.max(0, Math.ceil((endsAt - now) / 1000))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Blanc → rouge sur les DANGER_FROM dernières secondes. */
function dangerColor(t: number): string {
const r = Math.round(lerp(255, 239, t))
const g = Math.round(lerp(255, 68, t))
const b = Math.round(lerp(255, 68, t))
return `rgb(${r}, ${g}, ${b})`
}
export function Countdown({
startsAt,
endsAt,
}: {
startsAt: number
endsAt: number
}) {
const [left, setLeft] = useState(() => secondsLeft(startsAt, endsAt))
const boom = useRoomStore((s) => s.boom)
const boomed = useRef(false)
useEffect(() => {
const id = setInterval(() => setLeft(secondsLeft(startsAt, endsAt)), 250)
return () => clearInterval(id)
}, [startsAt, endsAt])
// Déclenche l'explosion une seule fois au passage à 0 (fin au timer).
useEffect(() => {
if (left === 0 && !boomed.current) {
boomed.current = true
boom()
}
}, [left, boom])
const exploded = left === 0
const danger = left <= DANGER_FROM && left > 0
// 0 (début du danger) → 1 (explosion imminente)
const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0
// Plus on approche de 0, plus le "battement" est ample et rapide.
const peak = 1.15 + intensity * 0.5
const wiggle = 4 + intensity * 12
const duration = 0.7 - intensity * 0.46
return (
<motion.span
className="font-heading inline-flex items-center gap-1 text-2xl font-bold tabular-nums"
style={{ color: danger ? dangerColor(intensity) : undefined }}
animate={
danger
? {
scale: [1, peak, 0.92, peak * 0.97, 1],
rotate: [0, -wiggle, wiggle, -wiggle * 0.6, 0],
}
: { scale: 1, rotate: 0 }
}
transition={
danger
? { duration, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.2 }
}
>
{danger && <span aria-hidden>💣</span>}
{exploded ? "💥" : left}
</motion.span>
)
}

View file

@ -0,0 +1,616 @@
import { useEffect, useRef, useState } from "react"
import { Link } from "wouter"
import { motion } from "framer-motion"
import { Check, ClipboardList, Crown, ListChecks, Music } from "lucide-react"
import { SiSpotify, SiYoutube } from "@icons-pack/react-simple-icons"
import type {
BlindtestTrackInfo,
PlayerScore,
RoomSnapshot,
RoundRecap,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
const recapImage = (url: string) =>
url.startsWith("http") ? url : `${SERVER_URL}${url}`
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
import { celebrate } from "@/lib/confetti"
import { playVictory } from "@/lib/sound"
import { useI18n, type Dict } from "@/i18n/context"
/** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */
function spotifySearch(track: BlindtestTrackInfo): string {
const query = `${track.title} ${track.artist}`.trim()
return `https://open.spotify.com/search/${encodeURIComponent(query)}`
}
type Place = 1 | 2 | 3
// Hauteur de marche, couleurs (or / argent / bronze) et taille d'avatar par place.
const PODIUM: Record<Place, {
bar: string
pedestal: string
text: string
ring: string
avatar: string
}> = {
1: {
bar: "h-24",
pedestal: "border-amber-400 bg-gradient-to-b from-amber-400/30 to-amber-400/5",
text: "text-amber-400",
ring: "ring-amber-400",
avatar: "size-20",
},
2: {
bar: "h-16",
pedestal: "border-zinc-300/70 bg-gradient-to-b from-zinc-300/20 to-zinc-300/5",
text: "text-zinc-300",
ring: "ring-zinc-300",
avatar: "size-16",
},
3: {
bar: "h-12",
pedestal: "border-amber-700/70 bg-gradient-to-b from-amber-700/25 to-amber-700/5",
text: "text-amber-600",
ring: "ring-amber-700",
avatar: "size-16",
},
}
function PodiumColumn({
entry,
place,
name,
isMe,
delay,
}: {
entry: PlayerScore
place: Place
name: string
isMe: boolean
delay: number
}) {
const cfg = PODIUM[place]
return (
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 22, delay }}
className="flex w-24 flex-col items-center gap-1"
>
<div className="relative">
{place === 1 && (
<motion.span
initial={{ y: -26, rotate: -25, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14, delay: delay + 0.25 }}
className="absolute -top-5 left-1/2 z-10 -translate-x-1/2"
>
<Crown className="size-8 fill-amber-400 text-amber-400 drop-shadow" />
</motion.span>
)}
<motion.div
animate={
place === 1
? {
boxShadow: [
"0 0 0px 0px rgba(251,191,36,0)",
"0 0 24px 4px rgba(251,191,36,0.5)",
"0 0 12px 2px rgba(251,191,36,0.25)",
],
}
: undefined
}
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse" }}
className="rounded-full"
>
<Avatar seed={name} className={`${cfg.avatar} ring-2 ${cfg.ring}`} />
</motion.div>
</div>
<span className="max-w-24 truncate text-sm font-medium">
{name}
{isMe && <span className="text-muted-foreground"> (toi)</span>}
</span>
<span className={`font-heading text-lg font-black tabular-nums ${cfg.text}`}>
{entry.score}
</span>
<div
className={`mt-1 flex w-full items-start justify-center rounded-t-lg border-x border-t-2 pt-1 ${cfg.bar} ${cfg.pedestal}`}
>
<span className={`font-heading text-2xl font-black ${cfg.text}`}>
{place}
</span>
</div>
</motion.div>
)
}
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks)
const gameRecap = useRoomStore((s) => s.gameRecap)
const leaveRoom = useRoomStore((s) => s.leaveRoom)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const { t } = useI18n()
const isHost = snapshot.hostId === playerId
// Confettis de victoire (une fois à l'arrivée sur l'écran de fin).
const celebrated = useRef(false)
useEffect(() => {
if (!celebrated.current) {
celebrated.current = true
celebrate()
playVictory()
}
}, [])
const [copied, setCopied] = useState(false)
async function copyResults() {
const lines = [t.end.resultsTitle, "", t.end.ranking]
ranked.forEach((s, i) =>
lines.push(`${i + 1}. ${nameOf(s.playerId)}${s.score}`)
)
if (gameRecap && gameRecap.length > 0) {
lines.push("", t.end.rounds)
gameRecap.forEach((r) =>
lines.push(
`${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}`
)
)
}
try {
await navigator.clipboard.writeText(lines.join("\n"))
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// presse-papier indisponible : on ignore
}
}
const scores: PlayerScore[] = finalScores ?? snapshot.scores
const ranked = [...scores].sort((a, b) => b.score - a.score)
const nameOf = (id: string) =>
snapshot.players.find((p) => p.id === id)?.name ?? "?"
const isMe = (id: string) => id === playerId
const [first, second, third] = ranked
const rest = ranked.slice(3)
const hasTracks = !!gameTracks && gameTracks.length > 0
const hasRecap = !!gameRecap && gameRecap.length > 0
return (
<div className="flex w-full flex-col items-center gap-6">
<div className="flex w-full max-w-xl flex-col gap-6 text-center">
<header>
<p className="text-muted-foreground text-xs uppercase">{t.end.over}</p>
<h2 className="font-heading text-2xl font-bold">
{t.end.winner(first ? nameOf(first.playerId) : "?")}
</h2>
</header>
{/* Podium : 2e à gauche, champion au centre, 3e à droite. */}
<div className="mt-4 flex items-end justify-center gap-2">
{second && (
<PodiumColumn
entry={second}
place={2}
name={nameOf(second.playerId)}
isMe={isMe(second.playerId)}
delay={0.1}
/>
)}
{first && (
<PodiumColumn
entry={first}
place={1}
name={nameOf(first.playerId)}
isMe={isMe(first.playerId)}
delay={0.3}
/>
)}
{third && (
<PodiumColumn
entry={third}
place={3}
name={nameOf(third.playerId)}
isMe={isMe(third.playerId)}
delay={0}
/>
)}
</div>
{rest.length > 0 && (
<ol className="flex flex-col gap-1">
{rest.map((s, i) => (
<li
key={s.playerId}
className={`bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm ${
isMe(s.playerId) ? "ring-primary ring-2" : ""
}`}
>
<span className="flex items-center gap-2">
<span className="text-muted-foreground w-5 text-right tabular-nums">
{i + 4}
</span>
<Avatar seed={nameOf(s.playerId)} className="size-7" />
<span>
{nameOf(s.playerId)}
{isMe(s.playerId) && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
</span>
<span className="font-heading font-bold tabular-nums">
{s.score}
</span>
</li>
))}
</ol>
)}
{gameRecap && gameRecap.length > 0 && (
<FunStats recap={gameRecap} snapshot={snapshot} t={t} />
)}
</div>
{/* Détails : musiques + récap côte à côte sur desktop s'il y a les deux,
sinon une seule colonne centrée (sinon le récap se calait à gauche). */}
{(hasTracks || hasRecap) && (
<div
className={
hasTracks && hasRecap
? "grid w-full gap-6 md:grid-cols-2 md:items-start"
: "flex w-full max-w-xl flex-col gap-6"
}
>
{hasTracks && <TracksRecap tracks={gameTracks!} t={t} />}
{hasRecap && (
<RoundsRecap
recap={gameRecap!}
nameOf={nameOf}
playerId={playerId}
t={t}
/>
)}
</div>
)}
<div className="flex w-full max-w-sm flex-col gap-2">
<Button variant="secondary" className="w-full" onClick={copyResults}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? t.end.copied : t.end.copy}
</Button>
{isHost ? (
<Button className="w-full" onClick={returnToLobby}>
{t.end.replay}
</Button>
) : (
<p className="text-muted-foreground text-xs">
{t.end.waitHostReplay}
</p>
)}
<Link href="/">
<Button variant="secondary" className="w-full" onClick={leaveRoom}>
{t.end.leave}
</Button>
</Link>
</div>
</div>
)
}
function TracksRecap({ tracks, t }: { tracks: BlindtestTrackInfo[]; t: Dict }) {
const [copied, setCopied] = useState(false)
async function copyPlaylist() {
const text = tracks.map((t) => `${t.title}${t.artist}`).join("\n")
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// presse-papier indisponible : on ignore
}
}
return (
<section className="flex flex-col gap-2 text-left">
<div className="flex items-center justify-between">
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> {t.end.tracksTitle}
</h3>
<Button size="sm" variant="secondary" onClick={copyPlaylist}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? t.roomCode.copied : t.end.playlist}
</Button>
</div>
<ul className="flex flex-col gap-2">
{tracks.map((track) => (
<li
key={track.youtubeId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${track.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="min-w-0 flex-1">
<span className="line-clamp-1 text-xs font-medium">
{track.title}
</span>
<span className="text-muted-foreground line-clamp-1 text-[10px]">
{t.end.addedBy(track.submittedByName)}
</span>
</span>
<Tooltip>
<TooltipTrigger asChild>
<a href={spotifySearch(track)} target="_blank" rel="noreferrer">
<Button size="icon-sm" variant="secondary">
<SiSpotify color="default" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>{t.end.searchSpotify}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<a href={track.url} target="_blank" rel="noreferrer">
<Button size="icon-sm" variant="secondary">
<SiYoutube color="default" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>{t.end.openYoutube}</TooltipContent>
</Tooltip>
</li>
))}
</ul>
</section>
)
}
interface PlayerStat {
correct: number
answered: number
decoy: number
speed: number
}
// Points de base d'une bonne réponse quiz/image ; le surplus = bonus de vitesse.
const QUIZ_BASE_POINTS = 100
function FunStats({
recap,
snapshot,
t,
}: {
recap: RoundRecap[]
snapshot: RoomSnapshot
t: Dict
}) {
const stats = new Map<string, PlayerStat>(
snapshot.players.map((p) => [
p.id,
{ correct: 0, answered: 0, decoy: 0, speed: 0 },
])
)
for (const r of recap) {
const answered = new Set(r.answers.map((a) => a.playerId))
const pointsBy = new Map(r.scorers.map((sc) => [sc.playerId, sc.points]))
for (const a of r.answers) {
const s = stats.get(a.playerId)
if (s) {
s.answered++
if (a.correct) {
s.correct++
// Bonus de vitesse = points au-dessus de la base (quiz/images).
if (r.type !== "blindtest") {
s.speed += Math.max(0, (pointsBy.get(a.playerId) ?? 0) - QUIZ_BASE_POINTS)
}
}
}
}
if (r.type === "blindtest") {
for (const sc of r.scorers) {
if (!answered.has(sc.playerId)) {
const s = stats.get(sc.playerId)
if (s) s.decoy += sc.points
}
}
}
}
const players = snapshot.players
const total = recap.length
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?"
const stat = (id: string) =>
stats.get(id) ?? { correct: 0, answered: 0, decoy: 0, speed: 0 }
const winners = (pick: (s: PlayerStat) => number, min = 1) => {
const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
}
const awards: { emoji: string; label: string; ids: string[] }[] = []
const perfect = players.filter(
(p) => total > 0 && stat(p.id).correct === total
)
if (perfect.length) {
awards.push({ emoji: "🎯", label: t.end.awardPerfect, ids: perfect.map((p) => p.id) })
}
const brains = winners((s) => s.correct)
if (brains.length && brains.length < players.length) {
awards.push({ emoji: "🧠", label: t.end.awardBrain, ids: brains.map((p) => p.id) })
}
const fastest = winners((s) => s.speed)
if (fastest.length && fastest.length < players.length) {
awards.push({
emoji: "⚡",
label: t.end.awardFastest,
ids: fastest.map((p) => p.id),
})
}
const decoy = winners((s) => s.decoy)
if (decoy.length) {
awards.push({
emoji: "🦹",
label: t.end.awardDecoy,
ids: decoy.map((p) => p.id),
})
}
const boulets = players.filter(
(p) => stat(p.id).answered > 0 && stat(p.id).correct === 0
)
if (boulets.length && total >= 3) {
awards.push({ emoji: "🪨", label: t.end.awardBoulet, ids: boulets.map((p) => p.id) })
}
if (awards.length === 0) {
return null
}
return (
<section className="flex flex-col gap-2">
<h3 className="text-muted-foreground text-sm font-medium">{t.end.awards}</h3>
<div className="flex flex-wrap gap-2">
{awards.map((a) => (
<div
key={a.label}
className="bg-muted/40 flex flex-1 flex-col items-center gap-1 rounded-xl border px-3 py-2 text-center"
>
<span className="text-2xl">{a.emoji}</span>
<span className="text-[10px] font-medium uppercase">{a.label}</span>
<div className="flex flex-wrap items-center justify-center gap-1">
{a.ids.map((id) => (
<span key={id} className="flex items-center gap-1 text-xs">
<Avatar seed={nameOf(id)} className="size-5" />
{nameOf(id)}
</span>
))}
</div>
</div>
))}
</div>
</section>
)
}
function RoundsRecap({
recap,
nameOf,
playerId,
t,
}: {
recap: RoundRecap[]
nameOf: (id: string) => string
playerId: string | null
t: Dict
}) {
return (
<details className="rounded-xl border text-left">
<summary className="text-muted-foreground flex cursor-pointer items-center gap-1.5 p-3 text-sm font-medium select-none">
<ListChecks className="size-4" /> {t.end.recapTitle(recap.length)}
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{recap.map((r) => {
const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points]))
const found = r.answers.filter((a) => a.correct).length
// Tri : plus de points d'abord, puis bonnes réponses.
const answers = [...r.answers].sort(
(a, b) =>
(pointsBy.get(b.playerId) ?? 0) - (pointsBy.get(a.playerId) ?? 0) ||
Number(b.correct) - Number(a.correct)
)
return (
<li key={r.index} className="bg-muted/40 flex gap-3 rounded-lg p-2">
{r.youtubeId ? (
<img
src={`https://img.youtube.com/vi/${r.youtubeId}/mqdefault.jpg`}
alt=""
className="h-12 w-16 shrink-0 rounded object-cover"
/>
) : r.imageUrl ? (
<img
src={recapImage(r.imageUrl)}
alt=""
className="h-12 w-16 shrink-0 rounded object-contain"
/>
) : null}
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[10px] uppercase">
{r.index + 1} ·{" "}
{r.category ??
(r.type === "blindtest"
? t.gameTypes.blindtest
: t.gameTypes.quiz)}
{" · "}
<span className="text-green-500"> {found}</span>
</p>
{r.type !== "blindtest" && (
<p className="line-clamp-1 text-xs">{r.label}</p>
)}
<p className="text-sm font-medium">
{r.answer}
{r.addedBy && (
<span className="text-muted-foreground font-normal">
{" "}
· {r.addedBy}
</span>
)}
</p>
<ul className="mt-1 flex flex-col gap-0.5">
{answers.length === 0 ? (
<li className="text-muted-foreground text-[10px]">
{t.end.noAnswer}
</li>
) : (
answers.map((a) => (
<li
key={a.playerId}
className="flex items-center gap-1 text-[10px]"
>
<Avatar seed={nameOf(a.playerId)} className="size-4" />
<span
className={
a.playerId === playerId
? "font-semibold"
: "text-muted-foreground"
}
>
{nameOf(a.playerId)}
{a.playerId === playerId && t.end.you} :
</span>
<span
className={
a.correct ? "text-green-500" : "text-red-400"
}
>
{a.answer || "—"}
</span>
{pointsBy.get(a.playerId) ? (
<span className="text-muted-foreground">
+{pointsBy.get(a.playerId)}
</span>
) : null}
</li>
))
)}
</ul>
</div>
</li>
)
})}
</ul>
</details>
)
}

View file

@ -0,0 +1,673 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import {
Brain,
Filter,
Headphones,
Image as ImageIcon,
ListMusic,
Minus,
Music,
Play,
Plus,
Shuffle,
Trash2,
type LucideIcon,
} from "lucide-react"
import { SiYoutube } from "@icons-pack/react-simple-icons"
import { fetchCategories } from "@/lib/categories"
import { fetchHistory } from "@/lib/history"
import type {
GameType,
MixMode,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
const MAX_TRACKS = 10
const GAME_TYPES: {
value: GameType
Icon: LucideIcon
needsThree: boolean
}[] = [
{ value: "mixed", Icon: Shuffle, needsThree: false },
{ value: "quiz", Icon: Brain, needsThree: false },
{ value: "image", Icon: ImageIcon, needsThree: false },
{ value: "blindtest", Icon: Headphones, needsThree: true },
]
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
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 disabled:opacity-50"
function Stepper({
value,
min = 1,
max = MAX_TRACKS,
onChange,
}: {
value: number
min?: number
max?: number
onChange: (v: number) => void
}) {
const clamp = (v: number) => Math.max(min, Math.min(max, v))
return (
<div className="flex items-center gap-1">
<Button
size="icon-sm"
variant="secondary"
disabled={value <= min}
onClick={() => onChange(clamp(value - 1))}
>
<Minus />
</Button>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => {
const n = parseInt(e.target.value, 10)
if (!Number.isNaN(n)) {
onChange(clamp(n))
}
}}
className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<Button
size="icon-sm"
variant="secondary"
disabled={value >= max}
onClick={() => onChange(clamp(value + 1))}
>
<Plus />
</Button>
</div>
)
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Construit la séquence de manches (chaque manche quiz porte son sous-pool). */
function buildRounds(opts: {
quiz: number
image: number
tracks: number
shuffleOrder: boolean
}): RoundConfig[] {
const rounds: RoundConfig[] = [
...Array.from({ length: opts.quiz }, () => ({
type: "quiz" as const,
pool: "quiz" as const,
})),
...Array.from({ length: opts.image }, () => ({
type: "quiz" as const,
pool: "image" as const,
})),
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
]
return opts.shuffleOrder ? shuffle(rounds) : rounds
}
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const updateSettings = useRoomStore((s) => s.updateSettings)
const startGame = useRoomStore((s) => s.startGame)
const addBot = useRoomStore((s) => s.addBot)
const removeBot = useRoomStore((s) => s.removeBot)
const { t, lang } = useI18n()
const isHost = snapshot.hostId === playerId
const botCount = snapshot.players.filter((p) => p.bot).length
const {
gameType,
mixedModes,
blindtestMode,
tracksPerPlayer,
categories,
botDifficulty,
} = snapshot.settings
const allCategories =
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
const history =
useQuery({
queryKey: ["history", snapshot.code],
queryFn: () => fetchHistory(snapshot.code),
}).data ?? []
const [quizCount, setQuizCount] = useState(5)
const [imageCount, setImageCount] = useState(5)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
const myCount =
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
// Le blindtest exige 3 joueurs (DJ + 2 devineurs) DONT au moins 2 réels :
// un bot ne peut pas être DJ, donc il faut un humain DJ + un humain contributeur.
const connectedCount = snapshot.players.filter((p) => p.connected).length
const humanCount = snapshot.players.filter(
(p) => p.connected && !p.bot
).length
const blindtestAvailable = connectedCount >= 3 && humanCount >= 2
// Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
const effectiveType: GameType =
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
const isMixed = effectiveType === "mixed"
// Sous-modes effectivement actifs (un réglage par sous-mode).
const showQuiz =
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
const showImage =
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
const showBlindtest =
effectiveType === "blindtest" ||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
const rounds = buildRounds({
quiz: showQuiz ? quizCount : 0,
image: showImage ? imageCount : 0,
tracks: showBlindtest ? totalTracks : 0,
shuffleOrder: isMixed,
})
// Blindtest : tous les HUMAINS doivent avoir soumis leur quota (les bots non).
const botIds = new Set(snapshot.players.filter((p) => p.bot).map((p) => p.id))
const humanSubs = snapshot.submissions.filter((s) => !botIds.has(s.playerId))
const allSubmitted =
!showBlindtest ||
(humanSubs.length > 0 &&
humanSubs.every((s) => s.count >= tracksPerPlayer))
const canStart = rounds.length > 0 && allSubmitted
function toggleMix(mode: MixMode) {
const set = new Set(mixedModes)
if (set.has(mode)) {
set.delete(mode)
} else {
set.add(mode)
}
updateSettings({ mixedModes: [...set] })
}
function toggleCategory(name: string) {
const set = new Set(categories)
if (set.has(name)) {
set.delete(name)
} else {
set.add(name)
}
updateSettings({ categories: [...set] })
}
async function start() {
setError(null)
setBusy(true)
try {
await startGame(rounds)
} catch (err) {
setError(errorMessage(t, err))
} finally {
setBusy(false)
}
}
return (
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
{t.lobby.players(snapshot.players.length)}
</h2>
<ul className="flex flex-col gap-1">
{snapshot.players.map((p) => (
<li
key={p.id}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span
className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`}
>
<Avatar seed={p.name} className="size-11" />
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground"> {t.common.you}</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{p.bot ? t.lobby.bot : p.id === snapshot.hostId ? t.lobby.host : ""}
{!p.bot && !p.connected && ` · ${t.lobby.offline}`}
</span>
</li>
))}
</ul>
{isHost && (
<div className="flex items-center justify-between gap-2 pt-1">
<span className="text-muted-foreground text-xs">
{t.lobby.bots} : {botCount}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
disabled={botCount === 0}
onClick={removeBot}
>
<Minus /> Bot
</Button>
<Button
size="sm"
variant="secondary"
disabled={snapshot.players.length >= 8}
onClick={addBot}
>
<Plus /> Bot
</Button>
</div>
</div>
)}
</section>
<div className="flex flex-col gap-6">
{isHost && (
<section className="flex flex-col gap-4 rounded-xl border p-4">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{t.lobby.settings}
</h2>
{/* Mode de jeu */}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">{t.lobby.gameMode}</span>
<div className="grid grid-cols-4 gap-2">
{GAME_TYPES.map(({ value, Icon, needsThree }) => {
const locked = needsThree && !blindtestAvailable
const active = effectiveType === value
const tile = (
<button
key={value}
aria-disabled={locked}
onClick={() => {
if (locked) return
updateSettings({ gameType: value })
}}
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-40 ${
active
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
<Icon className="size-5" />
{t.gameTypes[value]}
</button>
)
return locked ? (
<Tooltip key={value}>
<TooltipTrigger asChild>{tile}</TooltipTrigger>
<TooltipContent>{t.lobby.blindtestLockHint}</TooltipContent>
</Tooltip>
) : (
tile
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-xs">
{t.lobby.blindtestUnlock}
</p>
)}
</div>
{/* Sous-modes du mixte (toggles) */}
{isMixed && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">{t.lobby.subModes}</span>
<div className="flex gap-2">
{(["quiz", "image", "blindtest"] as const).map((m) => {
const locked = m === "blindtest" && !blindtestAvailable
const on = mixedModes.includes(m) && !locked
const tile = (
<button
key={m}
aria-disabled={locked}
onClick={() => {
if (locked) return
toggleMix(m)
}}
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-40 ${
on
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{t.mixModes[m]}
</button>
)
return locked ? (
<Tooltip key={m}>
<TooltipTrigger asChild>{tile}</TooltipTrigger>
<TooltipContent>{t.lobby.blindtestLockHint}</TooltipContent>
</Tooltip>
) : (
tile
)
})}
</div>
</div>
)}
{/* Nombre de questions de quiz */}
{showQuiz && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="size-4" /> {t.lobby.quizCount}
</span>
<Stepper value={quizCount} min={1} max={20} onChange={setQuizCount} />
</div>
)}
{/* Nombre d'images */}
{showImage && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<ImageIcon className="size-4" /> {t.lobby.imageCount}
</span>
<Stepper
value={imageCount}
min={1}
max={20}
onChange={setImageCount}
/>
</div>
<p className="text-muted-foreground text-xs">{t.lobby.imageHint}</p>
</div>
)}
{/* Filtre de catégories (quiz / images) */}
{(showQuiz || showImage) && allCategories.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Filter className="size-4" /> {t.lobby.categories}
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => updateSettings({ categories: [] })}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
categories.length === 0
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{t.lobby.allCategories}
</button>
{allCategories.map((c) => (
<button
key={c}
onClick={() => toggleCategory(c)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
categories.includes(c)
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{c}
</button>
))}
</div>
</div>
)}
{/* Niveau des bots (si au moins un bot dans la partie) */}
{botCount > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{t.lobby.botLevel}</span>
<div className="flex gap-1">
{BOT_DIFFICULTIES.map((d) => (
<Button
key={d}
size="sm"
className="flex-1"
variant={botDifficulty === d ? "default" : "secondary"}
onClick={() => updateSettings({ botDifficulty: d })}
>
{t.botDifficulty[d]}
</Button>
))}
</div>
</div>
)}
{/* Réglages blindtest */}
{showBlindtest && (
<div className="flex flex-col gap-3 border-t pt-3">
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> {t.lobby.blindtestMode}
</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
key={m}
size="sm"
className="flex-1"
variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => updateSettings({ blindtestMode: m })}
>
{t.blindtestModes[m]}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<ListMusic className="size-4" /> {t.lobby.tracksPerPlayer}
</span>
<Stepper
value={tracksPerPlayer}
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
/>
</div>
</div>
)}
</section>
)}
{showBlindtest && (
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
)}
{isHost ? (
<div className="flex flex-col gap-1">
<Button disabled={busy || !canStart} onClick={start}>
<Play />{" "}
{busy ? t.lobby.launching : t.lobby.launch(rounds.length)}
</Button>
{showBlindtest && !allSubmitted && (
<p className="text-muted-foreground text-center text-xs">
{t.lobby.waitTracks(tracksPerPlayer)}
</p>
)}
</div>
) : (
<p className="text-muted-foreground text-center text-xs">
{t.lobby.waitHost}
{showBlindtest && t.lobby.tracksSubmitted(totalTracks)}
</p>
)}
{error && <p className="text-destructive text-center text-sm">{error}</p>}
{history.length > 0 && (
<details className="rounded-xl border text-left">
<summary className="text-muted-foreground cursor-pointer p-3 text-sm font-medium select-none">
{t.lobby.prevGames(history.length)}
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{history.map((g) => {
const top = [...g.results].sort((a, b) => b.score - a.score)
return (
<li key={g.id} className="bg-muted/40 rounded-lg p-2 text-xs">
<p className="text-muted-foreground text-[10px] uppercase">
{new Date(g.playedAt).toLocaleString(
lang === "fr" ? "fr-FR" : "en-US",
{ dateStyle: "short", timeStyle: "short" }
)}
{" · "}
{g.modes.join(", ")}
</p>
<p>
🏆 {top[0]?.name ?? "?"}
<span className="text-muted-foreground">
{" "}
{top.map((r) => `${r.name} ${r.score}`).join(" · ")}
</span>
</p>
</li>
)
})}
</ul>
</details>
)}
</div>
</div>
)
}
interface MyTrack {
trackId: string
title: string
youtubeId: string
}
function TrackSubmission({
tracksPerPlayer,
myCount,
}: {
tracksPerPlayer: number
myCount: number
}) {
const submitTrack = useRoomStore((s) => s.submitTrack)
const removeTrack = useRoomStore((s) => s.removeTrack)
const { t } = useI18n()
const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [tracks, setTracks] = useState<MyTrack[]>([])
const quotaReached = myCount >= tracksPerPlayer
async function submit() {
if (!url.trim()) {
return
}
setSubmitting(true)
setFeedback(null)
const res = await submitTrack(url.trim())
if (res.accepted && res.trackId && res.youtubeId) {
setTracks((t) => [
...t,
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
])
setUrl("")
} else {
setFeedback(res.reason ?? t.lobby.rejected)
}
setSubmitting(false)
}
async function remove(trackId: string) {
const ok = await removeTrack(trackId)
if (ok) {
setTracks((t) => t.filter((x) => x.trackId !== trackId))
}
}
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
{t.lobby.myTracks(myCount, tracksPerPlayer)}
</span>
{tracks.length > 0 && (
<ul className="flex flex-col gap-2">
{tracks.map((track) => (
<li
key={track.trackId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${track.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="line-clamp-2 flex-1 text-xs">{track.title}</span>
<Tooltip>
<TooltipTrigger asChild>
<a
href={`https://www.youtube.com/watch?v=${track.youtubeId}`}
target="_blank"
rel="noreferrer"
>
<Button size="icon-sm" variant="secondary">
<SiYoutube color="default" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>{t.lobby.openYoutube}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="secondary"
onClick={() => remove(track.trackId)}
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent>{t.lobby.remove}</TooltipContent>
</Tooltip>
</li>
))}
</ul>
)}
{!quotaReached && (
<div className="flex gap-2">
<input
className={inputClass}
placeholder={t.lobby.youtubePlaceholder}
value={url}
disabled={submitting}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Button
variant="secondary"
disabled={submitting || !url.trim()}
onClick={submit}
>
<SiYoutube color="default" /> {t.lobby.add}
</Button>
</div>
)}
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
</div>
)
}

View file

@ -0,0 +1,127 @@
import { useEffect, useRef } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { Check, Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared"
import { Avatar } from "@/components/avatar"
import { celebrateLead } from "@/lib/confetti"
import { useI18n } from "@/i18n/context"
interface PlayerCardsProps {
snapshot: RoomSnapshot
playerId: string | null
/** Affiche l'état de vote (manche en cours uniquement). */
showVoteStatus?: boolean
/** playerIds ayant déjà validé leur réponse. */
votedIds?: string[]
}
/** HUD persistant : une card par joueur avec son score, couronne au leader. */
export function PlayerCards({
snapshot,
playerId,
showVoteStatus = false,
votedIds = [],
}: PlayerCardsProps) {
const { t } = useI18n()
const scoreOf = (id: string) =>
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id)))
const ranked = [...snapshot.players].sort(
(a, b) => scoreOf(b.id) - scoreOf(a.id)
)
const voted = new Set(votedIds)
const leaderId = max > 0 ? ranked[0].id : null
// Confettis quand un joueur DÉPASSE pour prendre la tête (pas à la 1re prise).
const prevLeader = useRef<string | null>(null)
useEffect(() => {
if (leaderId && leaderId !== prevLeader.current) {
if (prevLeader.current !== null) {
celebrateLead()
}
prevLeader.current = leaderId
}
}, [leaderId])
return (
<div className="flex flex-wrap justify-center gap-2">
<AnimatePresence initial={false}>
{ranked.map((p) => {
const score = scoreOf(p.id)
const isLeader = max > 0 && score === max
const isMe = p.id === playerId
const hasVoted = voted.has(p.id)
return (
<motion.div
key={p.id}
layout
transition={{ type: "spring", stiffness: 500, damping: 40 }}
className={`relative flex min-w-24 flex-col items-center gap-1 rounded-xl border px-3 py-2.5 ${
isLeader
? "border-amber-400/60 bg-amber-400/10"
: "border-input bg-muted/40"
} ${isMe ? "ring-primary ring-2" : ""} ${
p.connected ? "" : "opacity-40"
}`}
>
{isLeader && (
<motion.span
initial={{ scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 20 }}
className="absolute -top-2.5 text-amber-400"
>
<Crown className="size-4 fill-amber-400" />
</motion.span>
)}
<Avatar
seed={p.name}
className={`size-14 ${isLeader ? "ring-2 ring-amber-400" : ""}`}
/>
<span className="max-w-24 truncate text-xs font-medium">
{p.name}
{isMe && (
<span className="text-muted-foreground"> {t.common.you}</span>
)}
</span>
<motion.span
key={score}
initial={{ scale: 1.4 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="font-heading text-lg font-bold tabular-nums"
>
{score}
</motion.span>
{showVoteStatus &&
(hasVoted ? (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 18 }}
className="flex items-center gap-0.5 text-[10px] font-medium text-green-500"
>
<Check className="size-3" /> {t.playerCards.ready}
</motion.span>
) : (
<span className="flex items-center gap-1 text-[10px]">
<motion.span
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.1, repeat: Infinity }}
className="bg-muted-foreground size-1.5 rounded-full"
/>
<span className="text-muted-foreground">
{t.playerCards.waiting}
</span>
</span>
))}
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}

View file

@ -0,0 +1,47 @@
import { useState } from "react"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context"
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */
export function PseudoScreen({ code }: { code: string }) {
const setName = useRoomStore((s) => s.setName)
const { t } = useI18n()
const [name, setName_] = useState("")
const canSubmit = name.trim().length > 0
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-xs flex-col items-center gap-5">
<p className="text-muted-foreground text-xs uppercase">
{t.pseudo.room(code)}
</p>
<Avatar
seed={name.trim() || "?"}
className="size-28 ring-2 ring-primary/30"
/>
<p className="font-heading text-lg font-bold">{t.pseudo.choose}</p>
<input
className={inputClass}
placeholder={t.pseudo.placeholder}
value={name}
maxLength={24}
autoFocus
onChange={(e) => setName_(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)}
/>
<Button
className="w-full"
disabled={!canSubmit}
onClick={() => setName(name)}
>
{t.pseudo.enter}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,215 @@
import { useEffect, useState } from "react"
import type {
QuizPerPlayerResult,
QuizQuestionPayload,
QuizRevealTruth,
RoomSnapshot,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Countdown } from "@/components/countdown"
import { useI18n, type Dict } from "@/i18n/context"
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 disabled:opacity-50"
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
const MAX_BLUR = 22
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
const voteProgress = useRoomStore((s) => s.voteProgress)
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
const hasVoted = useRoomStore((s) => s.hasVoted)
const playerId = useRoomStore((s) => s.playerId)
const vote = useRoomStore((s) => s.vote)
const voteText = useRoomStore((s) => s.voteText)
const { t } = useI18n()
if (!round) {
return <p className="text-muted-foreground text-sm">{t.common.loading}</p>
}
const question = round.payload as QuizQuestionPayload
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
const showReveal = truth !== null
const isImage = question.format === "image_reveal"
const isText = question.format === "free" || isImage
const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined
function choiceClass(index: number): string {
const base =
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors"
if (showReveal) {
if (index === truth?.correctIndex) {
return `${base} border-green-500 bg-green-500/15 font-medium`
}
if (index === myChoiceIndex) {
return `${base} border-red-500 bg-red-500/10`
}
return `${base} border-input opacity-60`
}
if (index === myChoiceIndex) {
return `${base} border-primary bg-primary/10 font-medium`
}
return `${base} border-input hover:bg-muted/60`
}
return (
<div className="flex w-full max-w-lg flex-col gap-5">
<header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase">
{t.game.question(snapshot.currentRound + 1, snapshot.totalRounds)}
{question.category ? ` · ${question.category}` : ""}
</span>
{!showReveal && (
<Countdown
key={round.endsAt}
startsAt={round.startsAt}
endsAt={round.endsAt}
/>
)}
</header>
<h2 className="font-heading text-xl font-semibold text-balance">
{question.prompt}
</h2>
{isImage && question.imageUrl && (
<ImageReveal
src={
question.imageUrl.startsWith("http")
? question.imageUrl
: `${SERVER_URL}${question.imageUrl}`
}
startsAt={round.startsAt}
endsAt={round.endsAt}
revealed={showReveal}
/>
)}
{isText ? (
<FreeAnswer disabled={showReveal || hasVoted} onSubmit={voteText} t={t} />
) : (
<div className="flex flex-col gap-2">
{(question.choices ?? []).map((choice, index) => (
<button
key={index}
className={choiceClass(index)}
disabled={showReveal || hasVoted}
onClick={() => vote(index)}
>
{choice}
</button>
))}
</div>
)}
{!showReveal &&
(hasVoted ? (
<p className="text-muted-foreground text-center text-xs">
{t.game.answerSent}
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
</p>
) : (
voteProgress && (
<p className="text-muted-foreground text-center text-xs">
{t.game.answeredCount(voteProgress.count, voteProgress.total)}
</p>
)
))}
{showReveal && (
<div className="flex flex-col items-center gap-1">
{isText && (
<p className="text-sm">
<span className="text-muted-foreground">{t.game.answer} </span>
<span className="font-medium">{truth?.answer}</span>
</p>
)}
<p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
>
{myResult?.correct
? t.game.correct
: !hasVoted
? t.game.noAnswer
: t.game.wrong}
</p>
</div>
)}
</div>
)
}
function ImageReveal({
src,
startsAt,
endsAt,
revealed,
}: {
src: string
startsAt: number
endsAt: number
revealed: boolean
}) {
// Le flou décroît avec le temps (cadencé par les timestamps serveur) : tous
// les clients voient le même niveau de flou au même instant.
const [blur, setBlur] = useState(MAX_BLUR)
useEffect(() => {
if (revealed) {
return
}
const id = setInterval(() => {
const now = Date.now()
const p =
now < startsAt ? 0 : Math.min(1, (now - startsAt) / (endsAt - startsAt))
setBlur(MAX_BLUR * (1 - p))
}, 200)
return () => clearInterval(id)
}, [revealed, startsAt, endsAt])
const displayBlur = revealed ? 0 : blur
return (
<div className="bg-card relative flex w-full justify-center overflow-hidden rounded-2xl p-2">
<img
src={src}
alt=""
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
style={{ filter: `blur(${displayBlur}px)` }}
/>
</div>
)
}
function FreeAnswer({
disabled,
onSubmit,
t,
}: {
disabled: boolean
onSubmit: (text: string) => void
t: Dict
}) {
const [text, setText] = useState("")
return (
<div className="flex gap-2">
<input
className={inputClass}
placeholder={t.game.yourAnswer}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
/>
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
{t.game.validate}
</Button>
</div>
)
}

View file

@ -0,0 +1,71 @@
import { useState } from "react"
import { Check, Copy, Link2 } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { useI18n } from "@/i18n/context"
type Copied = "code" | "link" | null
/** Code de room copiable + bouton pour copier le lien d'invitation. */
export function RoomCode({ code }: { code: string }) {
const { t } = useI18n()
const [copied, setCopied] = useState<Copied>(null)
async function copy(what: Copied, text: string) {
try {
await navigator.clipboard.writeText(text)
setCopied(what)
setTimeout(() => setCopied(null), 1500)
} catch {
// presse-papier indisponible (http non sécurisé) : on ignore silencieusement
}
}
const link = `${window.location.origin}/join/${code}`
return (
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => copy("code", code)}
className="group flex items-center gap-2 text-left"
>
<span>
<span className="text-muted-foreground text-xs uppercase">
{t.roomCode.label}
</span>
<span className="font-heading block text-2xl font-bold tracking-widest">
{code}
</span>
</span>
{copied === "code" ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{t.roomCode.copyCode}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="secondary" onClick={() => copy("link", link)}>
{copied === "link" ? (
<Check className="text-green-500" />
) : (
<Link2 />
)}
{copied === "link" ? t.roomCode.copied : t.roomCode.link}
</Button>
</TooltipTrigger>
<TooltipContent>{t.roomCode.copyLink}</TooltipContent>
</Tooltip>
</div>
)
}

View file

@ -0,0 +1,177 @@
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "framer-motion"
import { useI18n } from "@/i18n/context"
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
export type TransitionKind = "quiz" | "image" | "blindtest"
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
// Médias custom déposés par l'utilisateur, rangés par mode :
// src/assets/transitions/quiz/* → transitions de quiz
// src/assets/transitions/image/* → transitions du mode images
// src/assets/transitions/blindtest/* → transitions de blindtest
// src/assets/transitions/* → communs (fallback)
// Détectés au build ; sinon, animation Framer par défaut.
const ALL_MEDIA = import.meta.glob(
"../assets/transitions/**/*.{gif,webp,apng,png,avif}",
{ eager: true, import: "default" }
) as Record<string, string>
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
const SHARED_MEDIA: string[] = []
for (const [path, url] of Object.entries(ALL_MEDIA)) {
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
else SHARED_MEDIA.push(url)
}
const THEME: Record<
TransitionKind,
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
> = {
quiz: {
gradient: "from-indigo-500 via-blue-600 to-cyan-600",
accent: "text-cyan-300",
label: "QUIZ",
kind: "Question",
emoji: "🧠",
},
image: {
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
accent: "text-lime-300",
label: "IMAGE",
kind: "Image",
emoji: "🖼️",
},
blindtest: {
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
accent: "text-yellow-300",
label: "BLINDTEST",
kind: "Titre",
emoji: "🎧",
},
}
function mediaFor(kind: TransitionKind): string[] {
return [...MEDIA[kind], ...SHARED_MEDIA]
}
/**
* Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest).
* Annonce le mode quand il change, sinon affiche un teaser léger de la manche.
* Montée avec une `key` qui change rejoue à chaque manche.
*/
export function RoundTransition({
kind,
index,
modeChanged,
}: {
kind: TransitionKind
index: number
modeChanged: boolean
}) {
const { t } = useI18n()
const [show, setShow] = useState(true)
const theme = THEME[kind]
const [media] = useState(() => {
const pool = mediaFor(kind)
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
})
useEffect(() => {
const t = setTimeout(() => setShow(false), VISIBLE_MS)
return () => clearTimeout(t)
}, [])
return createPortal(
<AnimatePresence>
{show && (
<motion.div
key="round-transition"
className={`pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}
initial={{ clipPath: "inset(0 0 100% 0)" }}
animate={{ clipPath: "inset(0 0 0% 0)" }}
exit={{
clipPath: "inset(100% 0 0 0)",
transition: { duration: 0.3, ease: "easeIn" },
}}
transition={{ duration: 0.25, ease: "easeOut" }}
>
<motion.div
aria-hidden
className="absolute size-[220vmax] opacity-60"
style={{
backgroundImage:
"repeating-conic-gradient(rgba(255,255,255,0.12) 0deg 12deg, transparent 12deg 24deg)",
}}
animate={{ rotate: 360 }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
{media ? (
<motion.img
src={media}
alt=""
className="relative max-h-[70vh] max-w-[80vw] object-contain"
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
/>
) : (
<motion.div
className="relative flex flex-col items-center"
initial={{ scale: 0, rotate: -12 }}
animate={{ scale: [0, 1.25, 1], rotate: [-12, 6, 0] }}
transition={{
duration: 0.5,
times: [0, 0.6, 1],
delay: 0.12,
ease: "easeOut",
}}
>
{modeChanged ? (
<>
<span className="text-[18vmin] leading-none">{theme.emoji}</span>
<span
className={`font-heading text-[12vmin] leading-none font-black tracking-tight ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
>
{theme.label}
</span>
</>
) : (
<>
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
{t.transition.kind[kind]}
</span>
<motion.span
className={`font-heading text-[26vmin] leading-none font-black ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
animate={{ scale: [1, 1.07, 1] }}
transition={{
duration: 0.4,
repeat: Infinity,
repeatType: "reverse",
}}
>
{index + 1}
</motion.span>
</>
)}
<motion.span
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="font-heading -rotate-3 text-3xl font-black text-white italic"
>
{t.transition.ready}
</motion.span>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>,
document.body
)
}

View file

@ -0,0 +1,36 @@
import { createContext, useContext } from "react"
import { fr } from "./fr"
export type Lang = "fr" | "en"
/** Forme du dictionnaire, dérivée du FR (source de vérité). */
export type Dict = typeof fr
export const LANG_KEY = "nerdware-lang"
export function detectLang(): Lang {
try {
const stored = localStorage.getItem(LANG_KEY)
if (stored === "fr" || stored === "en") {
return stored
}
} catch {
// localStorage indisponible : on retombe sur la langue du navigateur
}
return navigator.language?.toLowerCase().startsWith("fr") ? "fr" : "en"
}
export interface I18nContextValue {
lang: Lang
t: Dict
setLang: (lang: Lang) => void
}
export const I18nContext = createContext<I18nContextValue | null>(null)
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext)
if (!ctx) {
throw new Error("useI18n doit être utilisé dans <I18nProvider>")
}
return ctx
}

180
apps/web/src/i18n/en.ts Normal file
View file

@ -0,0 +1,180 @@
// Dictionnaire EN. Doit respecter la forme de `fr` (Dict) — sinon erreur TS.
import type { Dict } from "./context"
export const en: Dict = {
common: {
you: "(you)",
back: "Back to home",
backShort: "Back",
loading: "Loading…",
error: "Error",
},
home: {
tagline: "Geek culture party game",
create: "Create a room",
orJoin: "or join",
codePlaceholder: "CODE",
join: "Join",
connecting: "Connecting to server…",
},
pseudo: {
room: (code: string) => `Room ${code}`,
choose: "Choose your nickname",
placeholder: "Your nickname",
enter: "Enter the room",
},
joinPage: {
connecting: (code: string) => `Joining room ${code}`,
notFound: "Room not found.",
},
room: {
notFound: "Room not found or session lost.",
connected: "Connected",
disconnected: "Disconnected",
leave: "Leave",
},
roomCode: {
label: "Room code",
copyCode: "Copy code",
copyLink: "Copy invite link",
link: "Link",
copied: "Copied",
},
lobby: {
players: (n: number) => `Players (${n})`,
host: "Host",
bot: "🤖 Bot",
offline: "offline",
bots: "Bots",
settings: "Game settings",
gameMode: "Game mode",
blindtestLockHint: "3 players incl. 2 real ones (a bot can't DJ)",
blindtestUnlock:
"Blindtest unlocks at 3 players incl. 2 real ones (a bot can't DJ).",
subModes: "Included sub-modes",
quizCount: "Quiz questions",
imageCount: "Images to guess",
imageHint: "Requires “Image” questions created in the back-office.",
categories: "Categories",
allCategories: "All",
botLevel: "🤖 Bot difficulty",
blindtestMode: "Blindtest mode",
tracksPerPlayer: "Tracks per player",
launch: (n: number) => `Start (${n} rounds)`,
launching: "Starting…",
waitTracks: (n: number) =>
`Waiting for everyone to submit their ${n} track(s).`,
waitHost: "Waiting for the host to start…",
tracksSubmitted: (n: number) => ` (${n} tracks submitted)`,
prevGames: (n: number) => `Previous games (${n})`,
myTracks: (count: number, max: number) => `Your tracks (${count}/${max})`,
youtubePlaceholder: "YouTube link",
add: "Add",
openYoutube: "Open on YouTube",
remove: "Remove",
rejected: "Rejected",
},
gameTypes: {
mixed: "Mixed",
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
mixModes: {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
blindtestModes: {
title_artist: "Title & artist",
who_added: "Who added it?",
mixed: "Mixed",
},
botDifficulty: {
easy: "Easy",
normal: "Normal",
hard: "Hard",
},
game: {
question: (i: number, n: number) => `Question ${i} / ${n}`,
blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`,
answerSent: "Answer sent — waiting for the others",
answeredCount: (count: number, total: number) => `${count}/${total} answered`,
answer: "Answer:",
correct: "Correct! 🎉",
noAnswer: "No answer 😴",
wrong: "Wrong 💥",
yourAnswer: "Your answer",
validate: "Submit",
},
blindtest: {
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
play: "Play",
pause: "Pause",
restart: "Start",
yourTrack: "It's your track!",
yourTrackTitleArtist: "You don't vote. Enjoy while the others search.",
yourTrackWhoAdded:
"You don't vote — your goal: nobody guesses you added it. +50 per fooled player.",
titlePlaceholder: "Title",
artistPlaceholder: "Artist",
whoAdded: "Who added it?",
vote: "Vote",
pickPlayer: "Pick a player",
revealTitle: "Title",
revealArtist: "Artist",
revealAddedBy: "Added by",
points: (n: number) => `+${n} points 🎉`,
submit: "Submit my answer",
},
playerCards: {
ready: "Ready",
waiting: "waiting",
},
transition: {
kind: {
quiz: "Question",
image: "Image",
blindtest: "Track",
},
ready: "READY ?!",
},
end: {
over: "Game over",
winner: (name: string) => `🏆 ${name} wins!`,
copy: "Copy results",
copied: "Copied!",
replay: "Play again",
waitHostReplay: "Waiting for the host to start a new game…",
leave: "Leave",
resultsTitle: "NerdWare — Results",
ranking: "🏆 Ranking",
rounds: "Rounds",
tracksTitle: "Tracks from the game",
playlist: "Playlist",
addedBy: (name: string) => `added by ${name}`,
searchSpotify: "Search on Spotify",
openYoutube: "Open on YouTube",
awards: "Awards",
awardPerfect: "Flawless",
awardBrain: "The brain",
awardFastest: "The fastest",
awardDecoy: "Master of deception",
awardBoulet: "The dud",
recapTitle: (n: number) => `Rounds recap (${n})`,
noAnswer: "No answer",
you: " (you)",
},
errors: {
ROOM_NOT_FOUND: "This room doesn't exist.",
ROOM_IN_PROGRESS: "The game has already started.",
ALREADY_STARTED: "Game already started.",
NO_ROUNDS: "No round configured.",
NEED_THREE: "Blindtest needs at least 3 players incl. 2 real ones.",
TRACKS_PENDING: "Not all players have submitted their tracks yet.",
CODE_EXHAUSTED: "Couldn't generate a room code.",
REJOIN_FAILED: "Reconnection failed.",
UNKNOWN_MODE: "Round unavailable.",
},
}

View file

@ -0,0 +1,10 @@
import type { Dict } from "./context"
/** Traduit une erreur serveur via son code, sinon retombe sur son message. */
export function errorMessage(t: Dict, err: unknown): string {
const e = err as { code?: string; message?: string } | null
if (e?.code && e.code in t.errors) {
return t.errors[e.code as keyof typeof t.errors]
}
return e?.message ?? t.common.error
}

179
apps/web/src/i18n/fr.ts Normal file
View file

@ -0,0 +1,179 @@
// Dictionnaire FR — source de vérité de la forme du dictionnaire (voir en.ts).
export const fr = {
common: {
you: "(toi)",
back: "Retour à l'accueil",
backShort: "Retour",
loading: "Chargement…",
error: "Erreur",
},
home: {
tagline: "Party game culture geek",
create: "Créer une room",
orJoin: "ou rejoindre",
codePlaceholder: "CODE",
join: "Rejoindre",
connecting: "Connexion au serveur…",
},
pseudo: {
room: (code: string) => `Room ${code}`,
choose: "Choisis ton pseudo",
placeholder: "Ton pseudo",
enter: "Entrer dans la room",
},
joinPage: {
connecting: (code: string) => `Connexion à la room ${code}`,
notFound: "Room introuvable.",
},
room: {
notFound: "Room introuvable ou session perdue.",
connected: "Connecté",
disconnected: "Déconnecté",
leave: "Quitter",
},
roomCode: {
label: "Code room",
copyCode: "Copier le code",
copyLink: "Copier le lien d'invitation",
link: "Lien",
copied: "Copié",
},
lobby: {
players: (n: number) => `Joueurs (${n})`,
host: "Hôte",
bot: "🤖 Bot",
offline: "hors ligne",
bots: "Bots",
settings: "Réglages de la partie",
gameMode: "Mode de jeu",
blindtestLockHint: "3 joueurs dont 2 réels (un bot ne peut pas être DJ)",
blindtestUnlock:
"Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut pas être DJ).",
subModes: "Sous-modes inclus",
quizCount: "Questions de quiz",
imageCount: "Images à deviner",
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
categories: "Catégories",
allCategories: "Toutes",
botLevel: "🤖 Niveau des bots",
blindtestMode: "Mode blindtest",
tracksPerPlayer: "Titres par joueur",
launch: (n: number) => `Lancer (${n} manches)`,
launching: "Lancement…",
waitTracks: (n: number) =>
`En attente que tout le monde ait soumis ses ${n} titre(s).`,
waitHost: "En attente du lancement par l'hôte…",
tracksSubmitted: (n: number) => ` (${n} titres soumis)`,
prevGames: (n: number) => `Parties précédentes (${n})`,
myTracks: (count: number, max: number) => `Tes titres (${count}/${max})`,
youtubePlaceholder: "Lien YouTube",
add: "Ajouter",
openYoutube: "Ouvrir sur YouTube",
remove: "Supprimer",
rejected: "Refusé",
},
gameTypes: {
mixed: "Mixte",
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
mixModes: {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
blindtestModes: {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
mixed: "Mixte",
},
botDifficulty: {
easy: "Facile",
normal: "Normal",
hard: "Difficile",
},
game: {
question: (i: number, n: number) => `Question ${i} / ${n}`,
blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`,
answerSent: "Réponse envoyée — en attente des autres",
answeredCount: (count: number, total: number) => `${count}/${total} ont répondu`,
answer: "Réponse :",
correct: "Bonne réponse ! 🎉",
noAnswer: "Pas de réponse 😴",
wrong: "Raté 💥",
yourAnswer: "Ta réponse",
validate: "Valider",
},
blindtest: {
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
play: "Play",
pause: "Pause",
restart: "Début",
yourTrack: "C'est ton titre !",
yourTrackTitleArtist:
"Tu ne votes pas. Savoure pendant que les autres cherchent.",
yourTrackWhoAdded:
"Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé.",
titlePlaceholder: "Titre",
artistPlaceholder: "Artiste",
whoAdded: "Qui l'a ajouté ?",
vote: "Voter",
pickPlayer: "Choisis un joueur",
revealTitle: "Titre",
revealArtist: "Artiste",
revealAddedBy: "Ajouté par",
points: (n: number) => `+${n} points 🎉`,
submit: "Valider ma réponse",
},
playerCards: {
ready: "Prêt",
waiting: "en attente",
},
transition: {
kind: {
quiz: "Question",
image: "Image",
blindtest: "Titre",
},
ready: "PRÊT ?!",
},
end: {
over: "Partie terminée",
winner: (name: string) => `🏆 ${name} l'emporte !`,
copy: "Copier les résultats",
copied: "Copié !",
replay: "Rejouer",
waitHostReplay: "En attente que l'hôte relance une partie…",
leave: "Quitter",
resultsTitle: "NerdWare — Résultats",
ranking: "🏆 Classement",
rounds: "Manches",
tracksTitle: "Les musiques de la partie",
playlist: "Playlist",
addedBy: (name: string) => `ajouté par ${name}`,
searchSpotify: "Chercher sur Spotify",
openYoutube: "Ouvrir sur YouTube",
awards: "Récompenses",
awardPerfect: "Sans-faute",
awardBrain: "Le cerveau",
awardFastest: "Le plus rapide",
awardDecoy: "Roi de la tromperie",
awardBoulet: "Le boulet",
recapTitle: (n: number) => `Récapitulatif des manches (${n})`,
noAnswer: "Aucune réponse",
you: " (toi)",
},
errors: {
ROOM_NOT_FOUND: "Cette room n'existe pas.",
ROOM_IN_PROGRESS: "La partie a déjà commencé.",
ALREADY_STARTED: "Partie déjà lancée.",
NO_ROUNDS: "Aucune épreuve configurée.",
NEED_THREE: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.",
TRACKS_PENDING: "Tous les joueurs n'ont pas encore soumis leurs titres.",
CODE_EXHAUSTED: "Impossible de générer un code de room.",
REJOIN_FAILED: "Reconnexion impossible.",
UNKNOWN_MODE: "Épreuve indisponible.",
},
}

View file

@ -0,0 +1,25 @@
import { useI18n, type Lang } from "./context"
const LANGS: Lang[] = ["fr", "en"]
/** Petit sélecteur FR / EN. */
export function LanguageToggle({ className = "" }: { className?: string }) {
const { lang, setLang } = useI18n()
return (
<div className={`flex gap-1 ${className}`}>
{LANGS.map((l) => (
<button
key={l}
onClick={() => setLang(l)}
className={`rounded-md px-2 py-1 text-xs font-semibold uppercase transition-colors ${
lang === l
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{l}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,30 @@
import { useState, type ReactNode } from "react"
import { fr } from "./fr"
import { en } from "./en"
import {
I18nContext,
LANG_KEY,
detectLang,
type Dict,
type Lang,
} from "./context"
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(detectLang)
const dicts: Record<Lang, Dict> = { fr, en }
function setLang(next: Lang) {
try {
localStorage.setItem(LANG_KEY, next)
} catch {
// ignore
}
setLangState(next)
}
return (
<I18nContext.Provider value={{ lang, t: dicts[lang], setLang }}>
{children}
</I18nContext.Provider>
)
}

View file

@ -0,0 +1,96 @@
// 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
imageUrl: string | null
lang: string
active: boolean
category: string | null
}
export interface NewQuestionInput {
format: QuizFormat
prompt: string
category: string
difficulty: number
lang?: string
choices?: string[]
correctIndex?: number
acceptedAnswers?: string[]
imageUrl?: string
}
/** Construit l'URL absolue d'un asset servi par le serveur (ex: /uploads/x.jpg). */
export function assetUrl(path: string): string {
return path.startsWith("http") ? path : `${SERVER_URL}${path}`
}
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)
}
async function uploadImage(file: File): Promise<{ url: string }> {
const form = new FormData()
form.append("file", file)
const res = await fetch(`${SERVER_URL}/api/admin/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` },
body: form,
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `Erreur ${res.status}`)
}
return (await res.json()) as { url: string }
}
export const adminApi = {
listQuestions: () => request<AdminQuestion[]>("/questions"),
uploadImage,
createQuestion: (input: NewQuestionInput) =>
request<{ id: string }>("/questions", {
method: "POST",
body: JSON.stringify(input),
}),
setActive: (id: string, active: boolean) =>
request<{ ok: true }>(`/questions/${id}`, {
method: "PATCH",
body: JSON.stringify({ active }),
}),
deleteQuestion: (id: string) =>
request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }),
}

View file

@ -0,0 +1,15 @@
// Liste publique des catégories de quiz (pour le filtre du lobby).
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
export async function fetchCategories(): Promise<string[]> {
try {
const res = await fetch(`${SERVER_URL}/api/categories`)
if (!res.ok) {
return []
}
return (await res.json()) as string[]
} catch {
return []
}
}

View file

@ -0,0 +1,48 @@
import confetti from "canvas-confetti"
const COLORS = ["#a855f7", "#ec4899", "#22d3ee", "#fbbf24", "#f97316"]
/** Salve de confettis de victoire : gros pop central + jets latéraux ~1s. */
export function celebrate(): void {
confetti({
particleCount: 140,
spread: 100,
startVelocity: 45,
origin: { y: 0.6 },
colors: COLORS,
})
const end = Date.now() + 900
const frame = () => {
confetti({
particleCount: 5,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: COLORS,
})
confetti({
particleCount: 5,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: COLORS,
})
if (Date.now() < end) {
requestAnimationFrame(frame)
}
}
frame()
}
/** Petite salve quand un joueur prend la tête en cours de partie. */
export function celebrateLead(): void {
confetti({
particleCount: 45,
spread: 70,
startVelocity: 30,
scalar: 0.8,
origin: { y: 0.25 },
colors: COLORS,
})
}

View file

@ -0,0 +1,22 @@
// Historique public des parties d'une room.
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
export interface GameHistoryEntry {
id: string
playedAt: string
modes: string[]
results: { name: string; score: number }[]
}
export async function fetchHistory(code: string): Promise<GameHistoryEntry[]> {
try {
const res = await fetch(`${SERVER_URL}/api/history/${code}`)
if (!res.ok) {
return []
}
return (await res.json()) as GameHistoryEntry[]
} catch {
return []
}
}

View file

@ -0,0 +1,34 @@
// Session locale pour la reconnexion (refresh / coupure réseau).
const KEY = "nerdware-session"
export interface Session {
roomCode: string
playerId: string
token: string
name: string
}
export function getSession(): Session | null {
try {
const raw = localStorage.getItem(KEY)
return raw ? (JSON.parse(raw) as Session) : null
} catch {
return null
}
}
export function setSession(session: Session): void {
localStorage.setItem(KEY, JSON.stringify(session))
}
export function patchSession(patch: Partial<Session>): void {
const current = getSession()
if (current) {
setSession({ ...current, ...patch })
}
}
export function clearSession(): void {
localStorage.removeItem(KEY)
}

View file

@ -0,0 +1,13 @@
import { io, type Socket } from "socket.io-client"
import type {
ClientToServerEvents,
ServerToClientEvents,
} from "@nerdware/shared"
/** Socket typé : <events reçus, events émis>. Désync = erreur de compile. */
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
// Une seule connexion partagée pour toute l'app (reconnexion auto par défaut).
export const socket: AppSocket = io(SERVER_URL, { autoConnect: true })

24
apps/web/src/lib/sound.ts Normal file
View file

@ -0,0 +1,24 @@
// Sons du jeu. Dépose les fichiers dans src/assets/sounds/ (voir le README).
// Tout est optionnel : si le fichier est absent, la lecture est ignorée.
const victorySources = Object.values(
import.meta.glob("../assets/sounds/victory.{mp3,ogg,wav}", {
eager: true,
import: "default",
})
) as string[]
const victoryUrl: string | null = victorySources[0] ?? null
let victoryAudio: HTMLAudioElement | null = null
/** Joue le jingle de victoire (si présent). Nécessite une interaction préalable. */
export function playVictory(volume = 0.6): void {
if (!victoryUrl) {
return
}
victoryAudio ??= new Audio(victoryUrl)
victoryAudio.volume = volume
victoryAudio.currentTime = 0
// L'autoplay peut être bloqué tant qu'aucun geste utilisateur n'a eu lieu.
void victoryAudio.play().catch(() => {})
}

View file

@ -0,0 +1,99 @@
import { useEffect, useMemo, useRef, useState } from "react"
let apiPromise: Promise<void> | null = null
/** Charge l'API IFrame YouTube une seule fois. */
function loadYoutubeApi(): Promise<void> {
if (window.YT?.Player) {
return Promise.resolve()
}
if (apiPromise) {
return apiPromise
}
apiPromise = new Promise((resolve) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
document.head.appendChild(tag)
})
return apiPromise
}
export interface YoutubeApi {
play: () => void
pause: () => void
seek: (seconds: number) => void
time: () => number
duration: () => number
}
/**
* Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div
* hôte, l'état "prêt", et une API impérative (play/pause/seek/time).
*/
export function useYoutube(youtubeId: string) {
const hostRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<YT.Player | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (!host) {
return
}
setReady(false)
// YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable
// pour que React ne gère jamais ce nœud.
const inner = document.createElement("div")
host.appendChild(inner)
loadYoutubeApi().then(() => {
if (cancelled || !window.YT) {
return
}
playerRef.current = new window.YT.Player(inner, {
videoId: youtubeId,
playerVars: {
autoplay: 0,
controls: 0,
disablekb: 1,
modestbranding: 1,
rel: 0,
playsinline: 1,
},
events: {
onReady: () => {
if (!cancelled) {
setReady(true)
}
},
},
})
})
return () => {
cancelled = true
playerRef.current?.destroy()
playerRef.current = null
host.replaceChildren()
}
}, [youtubeId])
const api = useMemo<YoutubeApi>(
() => ({
play: () => playerRef.current?.playVideo(),
pause: () => playerRef.current?.pauseVideo(),
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
time: () => playerRef.current?.getCurrentTime() ?? 0,
duration: () => playerRef.current?.getDuration() ?? 0,
}),
[]
)
return { hostRef, ready, api }
}

View file

@ -1,14 +1,22 @@
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"
import { I18nProvider } from "@/i18n/provider"
const queryClient = new QueryClient()
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<App />
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>
)

View file

@ -0,0 +1,442 @@
import { useRef, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "wouter"
import { Eye, EyeOff, Trash2, Upload } from "lucide-react"
import type { QuizFormat } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import {
adminApi,
assetUrl,
clearAdminToken,
getAdminToken,
setAdminToken,
type AdminQuestion,
type NewQuestionInput,
} from "@/lib/admin-api"
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
export function AdminPage() {
const [token, setToken] = useState(getAdminToken())
if (!token) {
return <Login onAuth={setToken} />
}
return (
<AdminPanel
onLogout={() => {
clearAdminToken()
setToken("")
}}
/>
)
}
function Login({ onAuth }: { onAuth: (token: string) => void }) {
const [value, setValue] = useState("")
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-xs flex-col gap-4">
<h1 className="font-heading text-center text-2xl font-bold">
Back-office quiz
</h1>
<input
type="password"
className={inputClass}
placeholder="Jeton admin"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) {
setAdminToken(value.trim())
onAuth(value.trim())
}
}}
/>
<Button
disabled={!value.trim()}
onClick={() => {
setAdminToken(value.trim())
onAuth(value.trim())
}}
>
Entrer
</Button>
<Link href="/">
<Button variant="secondary" className="w-full">
Retour
</Button>
</Link>
</div>
</div>
)
}
function AdminPanel({ onLogout }: { onLogout: () => void }) {
const qc = useQueryClient()
const questions = useQuery({
queryKey: ["admin-questions"],
queryFn: adminApi.listQuestions,
retry: false,
})
const remove = useMutation({
mutationFn: adminApi.deleteQuestion,
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
})
const toggle = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
adminApi.setActive(id, active),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
})
const unauthorized =
questions.isError && /401|autoris/i.test(String(questions.error))
return (
<div className="mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-6 p-6">
<header className="flex items-center justify-between">
<h1 className="font-heading text-2xl font-bold">Back-office quiz</h1>
<Button size="sm" variant="secondary" onClick={onLogout}>
Déconnexion
</Button>
</header>
{unauthorized ? (
<p className="text-destructive text-sm">
Jeton invalide ou back-office indisponible.{" "}
<button className="underline" onClick={onLogout}>
Réessayer
</button>
</p>
) : (
<div className="grid gap-6 md:grid-cols-2 md:items-start">
<QuestionForm
onCreated={() =>
qc.invalidateQueries({ queryKey: ["admin-questions"] })
}
/>
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
Questions ({questions.data?.length ?? 0})
</h2>
{questions.isLoading && (
<p className="text-muted-foreground text-sm">Chargement</p>
)}
<ul className="flex flex-col gap-2">
{questions.data?.map((q) => (
<QuestionRow
key={q.id}
q={q}
onDelete={() => remove.mutate(q.id)}
onToggle={() => toggle.mutate({ id: q.id, active: !q.active })}
deleting={remove.isPending}
/>
))}
</ul>
</section>
</div>
)}
</div>
)
}
function QuestionRow({
q,
onDelete,
onToggle,
deleting,
}: {
q: AdminQuestion
onDelete: () => void
onToggle: () => void
deleting: boolean
}) {
return (
<li
className={`bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3 ${
q.active ? "" : "opacity-50"
}`}
>
{q.format === "image_reveal" && q.imageUrl && (
<img
src={assetUrl(q.imageUrl)}
alt=""
className="h-12 w-12 shrink-0 rounded object-cover"
/>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{q.prompt}</p>
<p className="text-muted-foreground text-xs">
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} ·{" "}
{q.lang.toUpperCase()} · {q.source}
{!q.active && " · désactivée"}
</p>
{q.format === "free" || q.format === "image_reveal" ? (
<p className="text-muted-foreground text-xs">
{q.acceptedAnswers?.join(", ")}
</p>
) : (
<p className="text-muted-foreground text-xs">
{q.choices
?.map((c, i) => (i === q.correctIndex ? `${c}` : c))
.join(" · ")}
</p>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="secondary" onClick={onToggle}>
{q.active ? <Eye /> : <EyeOff />}
</Button>
</TooltipTrigger>
<TooltipContent>{q.active ? "Désactiver" : "Activer"}</TooltipContent>
</Tooltip>
<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 [lang, setLang] = useState("fr")
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
const [correctIndex, setCorrectIndex] = useState(0)
const [answers, setAnswers] = useState("")
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const create = useMutation({
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
onSuccess: () => {
setPrompt("")
setChoices(EMPTY_CHOICES)
setCorrectIndex(0)
setAnswers("")
setImageUrl(null)
onCreated()
},
})
async function onPickImage(file: File | undefined) {
if (!file) {
return
}
setUploading(true)
setUploadError(null)
try {
const { url } = await adminApi.uploadImage(file)
setImageUrl(url)
} catch (err) {
setUploadError((err as { message?: string }).message ?? "Échec de l'upload")
} finally {
setUploading(false)
}
}
function submit() {
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
if (format === "free") {
input.acceptedAnswers = answers
.split("\n")
.map((a) => a.trim())
.filter(Boolean)
} else if (format === "image_reveal") {
input.acceptedAnswers = answers
.split("\n")
.map((a) => a.trim())
.filter(Boolean)
input.imageUrl = imageUrl ?? undefined
} else if (format === "truefalse") {
input.choices = ["Vrai", "Faux"]
input.correctIndex = correctIndex
} else {
input.choices = choices.map((c) => c.trim()).filter(Boolean)
input.correctIndex = correctIndex
}
create.mutate(input)
}
return (
<section className="flex flex-col gap-3 rounded-xl border p-4">
<h2 className="text-sm font-medium">Nouvelle question</h2>
<div className="flex gap-2">
<select
className={inputClass}
value={format}
onChange={(e) => {
setFormat(e.target.value as QuizFormat)
setCorrectIndex(0)
}}
>
<option value="mcq">QCM</option>
<option value="truefalse">Vrai / Faux</option>
<option value="free">Réponse libre</option>
<option value="image_reveal">Image à deviner</option>
</select>
<select
className={inputClass}
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
>
<option value={1}>Facile</option>
<option value={2}>Moyen</option>
<option value={3}>Difficile</option>
</select>
<select
className={inputClass}
value={lang}
onChange={(e) => setLang(e.target.value)}
>
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
</div>
<input
className={inputClass}
placeholder="Catégorie (ex: Jeux vidéo)"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
<input
className={inputClass}
placeholder={
format === "image_reveal"
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
: "Intitulé de la question"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
{format === "mcq" && (
<div className="flex flex-col gap-2">
{choices.map((choice, i) => (
<label key={i} className="flex items-center gap-2">
<input
type="radio"
name="correct"
checked={correctIndex === i}
onChange={() => setCorrectIndex(i)}
/>
<input
className={inputClass}
placeholder={`Choix ${i + 1}`}
value={choice}
onChange={(e) =>
setChoices((c) =>
c.map((x, j) => (j === i ? e.target.value : x))
)
}
/>
</label>
))}
<p className="text-muted-foreground text-xs">
Coche la bonne réponse. Les choix vides sont ignorés (min. 2).
</p>
</div>
)}
{format === "truefalse" && (
<div className="flex gap-4">
{["Vrai", "Faux"].map((label, i) => (
<label key={label} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="correct"
checked={correctIndex === i}
onChange={() => setCorrectIndex(i)}
/>
{label}
</label>
))}
</div>
)}
{format === "image_reveal" && (
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onPickImage(e.target.files?.[0])}
/>
<Button
type="button"
variant="secondary"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
<Upload />
{uploading
? "Upload…"
: imageUrl
? "Changer l'image"
: "Choisir une image"}
</Button>
{uploadError && (
<p className="text-destructive text-xs">{uploadError}</p>
)}
{imageUrl && (
<img
src={assetUrl(imageUrl)}
alt=""
className="max-h-40 w-fit rounded-md object-contain"
/>
)}
</div>
)}
{(format === "free" || format === "image_reveal") && (
<textarea
className={`${inputClass} h-24 py-2`}
placeholder="Réponses acceptées (une par ligne)"
value={answers}
onChange={(e) => setAnswers(e.target.value)}
/>
)}
{create.isError && (
<p className="text-destructive text-sm">{String(create.error)}</p>
)}
<Button
disabled={
create.isPending ||
uploading ||
(!prompt.trim() && format !== "image_reveal") ||
!category.trim() ||
(format === "image_reveal" && !imageUrl)
}
onClick={submit}
>
{create.isPending ? "Ajout…" : "Ajouter la question"}
</Button>
</section>
)
}

156
apps/web/src/pages/home.tsx Normal file
View file

@ -0,0 +1,156 @@
import { useState } from "react"
import { useLocation } from "wouter"
import { motion } from "framer-motion"
import { LogIn, Sparkles } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
import { LanguageToggle } from "@/i18n/language-toggle"
const inputClass =
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
// Emojis geek qui flottent en fond.
const FLOATERS = [
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
]
export function HomePage() {
const [, navigate] = useLocation()
const { t } = useI18n()
const createRoom = useRoomStore((s) => s.createRoom)
const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected)
const [code, setCode] = useState("")
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function handleCreate() {
setError(null)
setBusy(true)
try {
const { roomCode } = await createRoom()
navigate(`/room/${roomCode}`)
} catch (err) {
setError(errorMessage(t, err))
} finally {
setBusy(false)
}
}
async function handleJoin() {
setError(null)
setBusy(true)
try {
const { roomCode } = await joinRoom(code.trim().toUpperCase())
navigate(`/room/${roomCode}`)
} catch (err) {
setError(errorMessage(t, err))
} finally {
setBusy(false)
}
}
return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
<LanguageToggle className="absolute top-4 right-4 z-10" />
{/* Halo animé */}
<motion.div
aria-hidden
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Emojis flottants */}
{FLOATERS.map((f) => (
<motion.span
key={f.emoji}
aria-hidden
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
style={{ left: f.x, top: f.y }}
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: f.delay,
}}
>
{f.emoji}
</motion.span>
))}
<motion.div
className="flex w-full max-w-sm flex-col gap-6"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<header className="text-center">
<motion.h1
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 14 }}
>
NerdWare
</motion.h1>
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
<Sparkles className="size-3.5" /> {t.home.tagline}
</p>
</header>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="lg"
className="w-full"
disabled={!connected || busy}
onClick={handleCreate}
>
<Sparkles /> {t.home.create}
</Button>
</motion.div>
<div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground text-xs">{t.home.orJoin}</span>
<div className="bg-border h-px flex-1" />
</div>
<div className="flex gap-2">
<input
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
placeholder={t.home.codePlaceholder}
value={code}
maxLength={4}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()}
/>
<Button
variant="secondary"
disabled={!connected || busy || code.trim().length === 0}
onClick={handleJoin}
>
<LogIn /> {t.home.join}
</Button>
</div>
{!connected && (
<p className="text-muted-foreground text-center text-xs">
{t.home.connecting}
</p>
)}
{error && (
<p className="text-destructive text-center text-sm">{error}</p>
)}
</motion.div>
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from "react"
import { Link, useLocation } from "wouter"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
/** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */
export function JoinPage({ code }: { code: string }) {
const [, navigate] = useLocation()
const { t } = useI18n()
const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected)
const [error, setError] = useState<string | null>(null)
const tried = useRef(false)
useEffect(() => {
if (!connected || tried.current) {
return
}
tried.current = true
joinRoom(code)
.then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true }))
.catch((err) => setError(errorMessage(t, err)))
}, [connected, code, joinRoom, navigate, t])
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
{error ? (
<>
<p className="text-muted-foreground text-sm">{error}</p>
<Link href="/">
<Button variant="secondary">{t.common.back}</Button>
</Link>
</>
) : (
<p className="text-muted-foreground text-sm">
{t.joinPage.connecting(code)}
</p>
)}
</div>
)
}

158
apps/web/src/pages/room.tsx Normal file
View file

@ -0,0 +1,158 @@
import { Link, useLocation } from "wouter"
import { AnimatePresence, motion } from "framer-motion"
import { LogOut } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view"
import { QuizView } from "@/components/quiz-view"
import { BlindtestView } from "@/components/blindtest-view"
import { GameEndView } from "@/components/game-end-view"
import { PlayerCards } from "@/components/player-cards"
import { RoomCode } from "@/components/room-code"
import { Boom } from "@/components/boom"
import { RoundTransition } from "@/components/round-transition"
import { PseudoScreen } from "@/components/pseudo-screen"
import { useI18n } from "@/i18n/context"
import { LanguageToggle } from "@/i18n/language-toggle"
export function RoomPage({ code }: { code: string }) {
const [, navigate] = useLocation()
const { t } = useI18n()
const snapshot = useRoomStore((s) => s.snapshot)
const roomCode = useRoomStore((s) => s.roomCode)
const leaveRoom = useRoomStore((s) => s.leaveRoom)
const playerId = useRoomStore((s) => s.playerId)
const connected = useRoomStore((s) => s.connected)
const pseudoSet = useRoomStore((s) => s.pseudoSet)
const boomKey = useRoomStore((s) => s.boomKey)
const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round)
const roundKind = useRoomStore((s) => s.roundKind)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
if (roomCode !== code) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground text-sm">{t.room.notFound}</p>
<Link href="/">
<Button variant="secondary">{t.common.back}</Button>
</Link>
</div>
)
}
// Pseudo pas encore choisi : on l'affiche d'abord.
if (!pseudoSet) {
return <PseudoScreen code={code} />
}
// En attente du premier snapshot.
if (!snapshot || snapshot.code !== code) {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">{t.common.loading}</p>
</div>
)
}
// Phase grossière : on n'anime qu'aux frontières lobby ↔ jeu ↔ fin pour
// préserver le lecteur YouTube entre in_round / reveal / scores.
const phase =
snapshot.status === "lobby"
? "lobby"
: snapshot.status === "ended"
? "ended"
: "game"
return (
<div className="relative flex min-h-svh items-start justify-center overflow-hidden p-4 pt-10 sm:p-6 sm:pt-12">
{/* Halo d'ambiance en fond */}
<div
aria-hidden
className="pointer-events-none absolute top-[-20vmin] -z-10 size-[90vmin] rounded-full bg-gradient-to-br from-fuchsia-500/10 via-purple-500/5 to-cyan-400/10 blur-3xl"
/>
<div
className={`flex w-full flex-col gap-6 transition-[max-width] ${
phase === "lobby"
? "max-w-3xl"
: phase === "ended"
? "max-w-4xl"
: "max-w-lg"
}`}
>
<header className="flex items-center justify-between">
<RoomCode code={snapshot.code} />
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
/>
</TooltipTrigger>
<TooltipContent>
{connected ? t.room.connected : t.room.disconnected}
</TooltipContent>
</Tooltip>
<LanguageToggle />
<Button
size="sm"
variant="secondary"
onClick={() => {
leaveRoom()
navigate("/")
}}
>
<LogOut /> {t.room.leave}
</Button>
</div>
</header>
<AnimatePresence mode="wait">
<motion.div
key={phase}
className="flex flex-col gap-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.22, ease: "easeOut" }}
>
{phase === "lobby" && <LobbyView snapshot={snapshot} />}
{phase === "game" && (
<>
<PlayerCards
snapshot={snapshot}
playerId={playerId}
showVoteStatus={snapshot.status === "in_round"}
votedIds={voteProgress?.voted}
/>
{round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
)}
</>
)}
{phase === "ended" && <GameEndView snapshot={snapshot} />}
</motion.div>
</AnimatePresence>
</div>
{boomKey > 0 && <Boom key={boomKey} />}
{roundKey > 0 && round && (
<RoundTransition
key={roundKey}
kind={roundKind}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>
)}
</div>
)
}

347
apps/web/src/store/room.ts Normal file
View file

@ -0,0 +1,347 @@
import { create } from "zustand"
import {
DEFAULT_ROOM_SETTINGS,
type BlindtestAnswer,
type BlindtestTrackInfo,
type ErrorPayload,
type MediaControlPayload,
type MediaSyncPayload,
type PlayerScore,
type RoomCreatedPayload,
type RoomSnapshot,
type RoundConfig,
type RoundRecap,
type RoundStartPayload,
type SubmitOkPayload,
type UpdateSettingsPayload,
type VoteAckPayload,
} from "@nerdware/shared"
import { socket } from "@/lib/socket"
import {
clearSession,
getSession,
patchSession,
setSession,
} from "@/lib/session"
const saved = getSession()
/** Manche en cours côté client (depuis round:start). */
export interface ActiveRound {
type: RoundStartPayload["type"]
djId?: string
/** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */
mine?: boolean
startsAt: number
endsAt: number
payload: unknown
}
/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */
export interface RoundReveal {
truth: unknown
perPlayerResult: unknown
}
interface RoomState {
connected: boolean
playerId: string | null
roomCode: string | null
/** Le joueur local a-t-il choisi son pseudo dans la room ? */
pseudoSet: boolean
snapshot: RoomSnapshot | null
lastError: ErrorPayload | null
// État de jeu (poussé par le moteur serveur).
round: ActiveRound | null
voteProgress: VoteAckPayload | null
reveal: RoundReveal | null
myChoiceIndex: number | null
hasVoted: boolean
finalScores: PlayerScore[] | null
gameTracks: BlindtestTrackInfo[] | null
gameRecap: RoundRecap[] | null
mediaSync: MediaSyncPayload | null
boomKey: number
roundKey: number
/** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
roundKind: "quiz" | "image" | "blindtest"
/** Le mode a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean
createRoom: () => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string) => Promise<RoomCreatedPayload>
setName: (name: string) => void
updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
removeTrack: (trackId: string) => Promise<boolean>
returnToLobby: () => void
leaveRoom: () => void
addBot: () => void
removeBot: () => void
vote: (choiceIndex: number) => void
voteText: (text: string) => void
voteBlindtest: (answer: BlindtestAnswer) => void
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
boom: () => void
clearError: () => void
reset: () => void
}
export const useRoomStore = create<RoomState>((set, get) => ({
connected: socket.connected,
playerId: saved?.playerId ?? null,
roomCode: saved?.roomCode ?? null,
pseudoSet: !!saved?.name,
snapshot: null,
lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
gameRecap: null,
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
createRoom: () =>
new Promise((resolve, reject) => {
socket.emit("room:create", {}, (res) => {
if (res.ok) {
setSession({ ...res.data, name: "" })
set({
playerId: res.data.playerId,
roomCode: res.data.roomCode,
pseudoSet: false,
})
resolve(res.data)
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
joinRoom: (roomCode) =>
new Promise((resolve, reject) => {
socket.emit("room:join", { roomCode }, (res) => {
if (res.ok) {
setSession({ ...res.data, name: "" })
set({
playerId: res.data.playerId,
roomCode: res.data.roomCode,
pseudoSet: false,
})
resolve(res.data)
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
setName: (name) => {
if (name.trim().length === 0) {
return
}
socket.emit("player:setName", { name: name.trim() })
patchSession({ name: name.trim() })
set({ pseudoSet: true })
},
updateSettings: (partial) => {
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
socket.emit("lobby:updateSettings", {
gameType: partial.gameType ?? current.gameType,
mixedModes: partial.mixedModes ?? current.mixedModes,
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
roundDuration: partial.roundDuration ?? current.roundDuration,
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
categories: partial.categories ?? current.categories,
botDifficulty: partial.botDifficulty ?? current.botDifficulty,
rounds: partial.rounds ?? current.rounds,
})
},
startGame: (rounds) =>
new Promise((resolve, reject) => {
get().updateSettings({ rounds })
socket.emit("game:start", (res) => {
if (res.ok) {
resolve()
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
submitTrack: (youtubeUrl) =>
new Promise((resolve) => {
socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res))
}),
removeTrack: (trackId) =>
new Promise((resolve) => {
socket.emit("blindtest:removeTrack", { trackId }, (res) => resolve(res.ok))
}),
returnToLobby: () => {
socket.emit("lobby:return")
},
leaveRoom: () => {
socket.emit("room:leave")
get().reset()
},
addBot: () => socket.emit("lobby:addBot"),
removeBot: () => socket.emit("lobby:removeBot"),
vote: (choiceIndex) => {
if (get().hasVoted) {
return
}
set({ myChoiceIndex: choiceIndex, hasVoted: true })
socket.emit("round:vote", { answer: { choiceIndex } })
},
voteText: (text) => {
if (get().hasVoted || text.trim().length === 0) {
return
}
set({ hasVoted: true })
socket.emit("round:vote", { answer: { text: text.trim() } })
},
voteBlindtest: (answer) => {
if (get().hasVoted) {
return
}
set({ hasVoted: true })
socket.emit("round:vote", { answer })
},
mediaControl: (action, positionSec) => {
socket.emit("media:control", { action, positionSec })
},
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
clearError: () => set({ lastError: null }),
reset: () => {
clearSession()
set({
roomCode: null,
pseudoSet: false,
snapshot: null,
lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
gameRecap: null,
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
})
},
}))
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
socket.on("connect", () => {
useRoomStore.setState({ connected: true })
// Reconnexion : on retente de réintégrer la room mémorisée (refresh/coupure).
const s = getSession()
if (!s) {
return
}
socket.emit(
"room:rejoin",
{ roomCode: s.roomCode, playerId: s.playerId, token: s.token },
(res) => {
if (res.ok) {
useRoomStore.setState({
roomCode: res.data.roomCode,
playerId: res.data.playerId,
pseudoSet: !!s.name,
})
} else {
useRoomStore.getState().reset()
}
}
)
})
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
socket.on("room:state", (snapshot) =>
useRoomStore.setState(
snapshot.status === "lobby"
? {
snapshot,
round: null,
reveal: null,
voteProgress: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
gameRecap: null,
mediaSync: null,
}
: { snapshot }
)
)
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("round:start", (payload) =>
useRoomStore.setState((s) => {
const fmt = (payload.payload as { format?: string } | null)?.format
const kind =
payload.type === "blindtest"
? "blindtest"
: fmt === "image_reveal"
? "image"
: "quiz"
return {
round: {
type: payload.type,
djId: payload.djId,
mine: payload.mine,
startsAt: payload.startsAt,
endsAt: payload.endsAt,
payload: payload.payload,
},
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1,
roundKind: kind,
roundModeChanged: s.roundKind !== kind,
}
})
)
socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress })
)
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
socket.on("game:end", ({ finalScores, tracks, recap }) =>
useRoomStore.setState({
finalScores,
gameTracks: tracks ?? null,
gameRecap: recap ?? null,
})
)

44
apps/web/src/types/youtube.d.ts vendored Normal file
View file

@ -0,0 +1,44 @@
// Déclarations minimales de l'API YouTube IFrame Player (ce qu'on utilise).
export {}
declare global {
namespace YT {
interface PlayerEvent {
target: Player
data: number
}
interface PlayerOptions {
videoId?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (e: PlayerEvent) => void
onStateChange?: (e: PlayerEvent) => void
}
}
class Player {
constructor(el: HTMLElement | string, opts: PlayerOptions)
playVideo(): void
pauseVideo(): void
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
getPlayerState(): number
loadVideoById(id: string): void
destroy(): void
}
const PlayerState: {
UNSTARTED: -1
ENDED: 0
PLAYING: 1
PAUSED: 2
BUFFERING: 3
CUED: 5
}
}
interface Window {
YT?: typeof YT
onYouTubeIframeAPIReady?: () => void
}
}

View file

@ -9,6 +9,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",

407
bun.lock
View file

@ -11,18 +11,54 @@
"typescript": "~6",
},
},
"apps/server": {
"name": "@nerdware/server",
"version": "0.0.1",
"dependencies": {
"@fastify/cors": "^11",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@nerdware/shared": "workspace:*",
"drizzle-orm": "^0.45",
"fastify": "^5",
"postgres": "^3.4",
"socket.io": "^4.8.1",
},
"devDependencies": {
"@eslint/js": "^10",
"@types/bun": "latest",
"@types/node": "^24",
"drizzle-kit": "^0.31",
"eslint": "^10",
"globals": "^17",
"pino-pretty": "^13",
"typescript": "~6",
"typescript-eslint": "^8",
},
},
"apps/web": {
"name": "web",
"version": "0.0.1",
"dependencies": {
"@dicebear/core": "^10",
"@dicebear/styles": "^10",
"@icons-pack/react-simple-icons": "^13",
"@nerdware/shared": "workspace:*",
"@tanstack/react-query": "^5.101.0",
"@workspace/ui": "workspace:*",
"canvas-confetti": "^1.9.4",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"socket.io-client": "^4.8.1",
"wouter": "^3.7.1",
"zustand": "^5.0.8",
},
"devDependencies": {
"@eslint/js": "^10",
"@tailwindcss/vite": "^4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
@ -36,6 +72,17 @@
"vite": "^8",
},
},
"packages/shared": {
"name": "@nerdware/shared",
"version": "0.0.1",
"devDependencies": {
"@eslint/js": "^10",
"eslint": "^10",
"globals": "^17",
"typescript": "~6",
"typescript-eslint": "^8",
},
},
"packages/ui": {
"name": "@workspace/ui",
"version": "0.0.0",
@ -127,8 +174,14 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@dicebear/core": ["@dicebear/core@10.1.0", "", {}, "sha512-OzwokGKfSJCTHNATS87Rl3Fbf00FvqKamitVaBsgm535paz09Vdgwv4QxwHR6CZUsrCdMJn+j/RfZX4o2iNk5g=="],
"@dicebear/styles": ["@dicebear/styles@10.2.0", "", {}, "sha512-o4rw0+7Q7sSAyr9YxYrUe3jCxlOAzEcBvbQvl4hQKQ+9WNmBK4p7wGYvsmGeJbxFh8y/Ux6ltrCPyXBIDV6qYA=="],
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@ -137,6 +190,10 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@ -205,6 +262,32 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="],
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/multipart": ["@fastify/multipart@10.0.0", "", { "dependencies": { "@fastify/busboy": "^3.0.0", "@fastify/deepmerge": "^3.0.0", "@fastify/error": "^4.0.0", "fastify-plugin": "^5.0.0", "secure-json-parse": "^4.0.0" } }, "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="],
"@fastify/static": ["@fastify/static@9.1.3", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@ -229,6 +312,8 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@icons-pack/react-simple-icons": ["@icons-pack/react-simple-icons@13.13.0", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="],
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
"@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="],
@ -271,12 +356,18 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nerdware/server": ["@nerdware/server@workspace:apps/server"],
"@nerdware/shared": ["@nerdware/shared@workspace:packages/shared"],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
@ -297,6 +388,8 @@
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="],
@ -453,6 +546,8 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
@ -483,6 +578,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=="],
@ -501,6 +600,12 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@ -519,6 +624,8 @@
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="],
@ -543,7 +650,9 @@
"@workspace/ui": ["@workspace/ui@workspace:packages/ui"],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@ -567,8 +676,14 @@
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
@ -579,6 +694,10 @@
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@ -591,6 +710,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
@ -613,6 +734,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@ -637,6 +760,8 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
@ -653,6 +778,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
@ -661,6 +788,10 @@
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="],
@ -673,6 +804,14 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="],
"engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
@ -729,14 +868,24 @@
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
"fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-json-stringify": ["fast-json-stringify@6.4.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@ -745,6 +894,10 @@
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fastify": ["fastify@5.8.5", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q=="],
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@ -759,6 +912,8 @@
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-my-way": ["find-my-way@9.6.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
@ -769,6 +924,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
@ -795,6 +952,10 @@
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
@ -811,6 +972,8 @@
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
@ -835,7 +998,7 @@
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
@ -877,6 +1040,8 @@
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
@ -887,6 +1052,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@ -903,6 +1070,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@ -933,7 +1102,7 @@
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
@ -951,9 +1120,11 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@ -963,6 +1134,14 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
"motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],
@ -973,7 +1152,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
@ -989,6 +1168,8 @@
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@ -1021,18 +1202,30 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@ -1043,16 +1236,22 @@
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"radix-ui": ["radix-ui@1.5.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-accessible-icon": "1.1.9", "@radix-ui/react-accordion": "1.2.13", "@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-arrow": "1.1.9", "@radix-ui/react-aspect-ratio": "1.1.9", "@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-checkbox": "1.3.4", "@radix-ui/react-collapsible": "1.1.13", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-context-menu": "2.3.0", "@radix-ui/react-dialog": "1.1.16", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-dropdown-menu": "2.1.17", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-form": "0.1.9", "@radix-ui/react-hover-card": "1.1.16", "@radix-ui/react-label": "2.1.9", "@radix-ui/react-menu": "2.1.17", "@radix-ui/react-menubar": "1.1.17", "@radix-ui/react-navigation-menu": "1.2.15", "@radix-ui/react-one-time-password-field": "0.1.9", "@radix-ui/react-password-toggle-field": "0.1.4", "@radix-ui/react-popover": "1.1.16", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-progress": "1.1.9", "@radix-ui/react-radio-group": "1.4.0", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-scroll-area": "1.2.11", "@radix-ui/react-select": "2.3.0", "@radix-ui/react-separator": "1.1.9", "@radix-ui/react-slider": "1.4.0", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-switch": "1.3.0", "@radix-ui/react-tabs": "1.1.14", "@radix-ui/react-toast": "1.2.16", "@radix-ui/react-toggle": "1.1.11", "@radix-ui/react-toggle-group": "1.1.12", "@radix-ui/react-toolbar": "1.1.12", "@radix-ui/react-tooltip": "1.2.9", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-escape-keydown": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@ -1069,20 +1268,30 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@ -1091,17 +1300,23 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@ -1123,10 +1338,24 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
"socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="],
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@ -1143,6 +1372,8 @@
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
@ -1151,6 +1382,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@ -1161,6 +1394,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
@ -1173,6 +1408,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@ -1205,6 +1442,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
@ -1221,12 +1460,18 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wouter": ["wouter@3.10.0", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@ -1249,14 +1494,28 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
@ -1275,8 +1534,6 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
"ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@ -1285,12 +1542,24 @@
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-json-stringify/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"headers-polyfill/set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@ -1301,14 +1570,24 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@ -1329,6 +1608,52 @@
"@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@ -1339,10 +1664,72 @@
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"msw/@inquirer/confirm/@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="],
"msw/@inquirer/confirm/@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="],
"send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],

60
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,60 @@
# Stack de production NerdWare (postgres + serveur + client).
# Pensé pour Dokploy (assigne les domaines via Traefik) ou un `docker compose up`.
#
# Variables attendues (UI Dokploy ou fichier .env à côté de ce fichier) :
# POSTGRES_PASSWORD mot de passe Postgres
# ADMIN_TOKEN jeton du back-office quiz
# CORS_ORIGINS domaine(s) du client, ex. https://nerdware.example.com
# VITE_SERVER_URL URL publique du serveur, ex. https://api.nerdware.example.com
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-nerdware}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nerdware}
POSTGRES_DB: ${POSTGRES_DB:-nerdware}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nerdware} -d ${POSTGRES_DB:-nerdware}"]
interval: 5s
timeout: 5s
retries: 5
server:
build:
context: .
dockerfile: apps/server/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3001
DATABASE_URL: postgres://${POSTGRES_USER:-nerdware}:${POSTGRES_PASSWORD:-nerdware}@postgres:5432/${POSTGRES_DB:-nerdware}
ADMIN_TOKEN: ${ADMIN_TOKEN}
CORS_ORIGINS: ${CORS_ORIGINS}
UPLOADS_DIR: /data/uploads
volumes:
- uploads:/data/uploads
expose:
- "3001"
web:
build:
context: .
dockerfile: apps/web/Dockerfile
args:
VITE_SERVER_URL: ${VITE_SERVER_URL}
restart: unless-stopped
depends_on:
- server
expose:
- "80"
volumes:
pgdata:
uploads:

24
docker-compose.yml Normal file
View file

@ -0,0 +1,24 @@
# Postgres de développement local pour NerdWare.
# docker compose up -d
# Puis depuis apps/server : bun run db:migrate && bun run db:seed
services:
postgres:
image: postgres:17-alpine
container_name: nerdware-postgres
restart: unless-stopped
environment:
POSTGRES_USER: nerdware
POSTGRES_PASSWORD: nerdware
POSTGRES_DB: nerdware
ports:
- "5432:5432"
volumes:
- nerdware-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nerdware -d nerdware"]
interval: 5s
timeout: 5s
retries: 5
volumes:
nerdware-pgdata:

View file

@ -1,13 +1,14 @@
{
"name": "nerdware",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"format": "turbo format",
"typecheck": "turbo typecheck"
"typecheck": "turbo typecheck",
"test": "turbo test"
},
"devDependencies": {
"prettier": "^3.8.3",

View file

@ -0,0 +1,15 @@
import js from "@eslint/js"
import globals from "globals"
import tseslint from "typescript-eslint"
import { defineConfig, globalIgnores } from "eslint/config"
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.ts"],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
globals: globals.node,
},
},
])

View file

@ -0,0 +1,23 @@
{
"name": "@nerdware/shared",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint",
"format": "prettier --write \"**/*.ts\"",
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts",
"./domain": "./src/domain.ts",
"./events": "./src/events.ts"
},
"devDependencies": {
"@eslint/js": "^10",
"eslint": "^10",
"globals": "^17",
"typescript": "~6",
"typescript-eslint": "^8"
}
}

View file

@ -0,0 +1,37 @@
// Types concrets de l'épreuve Blindtest, partagés client/serveur.
// L'audio est joué localement par chaque client mais piloté par le DJ (media:sync).
/** Payload diffusé au lancement d'une manche blindtest (round:start), SANS la réponse. */
export interface BlindtestRoundPayload {
trackId: string
youtubeId: string
}
/** Vérité révélée à tous (round:reveal → truth), inclut qui a soumis le titre. */
export interface BlindtestRevealTruth {
title: string
artist: string
youtubeId: string
submittedBy: string
submittedByName: string
}
/** Résultat d'un joueur sur la manche (détail selon le mode). */
export interface BlindtestPlayerResult {
titleCorrect?: boolean
artistCorrect?: boolean
guessedCorrect?: boolean
points: number
}
/** round:reveal → perPlayerResult : playerId → résultat. */
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
/** Titre récapitulé en fin de partie (export / récupération des liens). */
export interface BlindtestTrackInfo {
title: string
artist: string
youtubeId: string
url: string
submittedByName: string
}

View file

@ -0,0 +1,152 @@
// Modèle de domaine partagé entre client et serveur.
// Le serveur est la source de vérité ; le client n'affiche qu'un état reçu.
/** Statut courant d'une room (machine à états du jeu). */
export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */
export type RoundType = "blindtest" | "quiz"
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
/** Sous-modes activables quand la partie est "mixed". */
export type MixMode = "quiz" | "image" | "blindtest"
/** Modes de blindtest, déterminent la forme du vote et le barème. */
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
/** Niveau des bots : pilote leur probabilité de répondre juste. */
export type BotDifficulty = "easy" | "normal" | "hard"
/** Formats de question quiz. */
export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal"
/** Joueur connecté à une room. */
export interface Player {
id: string
name: string
connected: boolean
/** Joueur virtuel (bot) géré par le serveur. */
bot?: boolean
}
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
export interface RoundConfig {
type: RoundType
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique" ou images). */
pool?: "quiz" | "image"
}
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: GameType
/** Sous-modes actifs quand gameType === "mixed". */
mixedModes: MixMode[]
blindtestMode: BlindtestMode
/** Durée d'une manche en secondes (def. 60). */
roundDuration: number
/** Nombre de titres que chaque joueur soumet (blindtest). */
tracksPerPlayer: number
/** Catégories de quiz autorisées (vide = toutes). */
categories: string[]
/** Niveau des bots ajoutés à la partie. */
botDifficulty: BotDifficulty
/** Séquence d'épreuves de la partie. */
rounds: RoundConfig[]
}
/** Réglages par défaut d'une nouvelle room. */
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
gameType: "mixed",
mixedModes: ["quiz", "image"],
blindtestMode: "title_artist",
roundDuration: 60,
tracksPerPlayer: 2,
categories: [],
botDifficulty: "normal",
rounds: [],
}
// --- Votes ---------------------------------------------------------------
/** Vote blindtest, forme selon RoomSettings.blindtestMode. */
export type BlindtestAnswer =
| { title: string; artist: string }
| { guessedPlayerId: string }
| { title: string; artist: string; guessedPlayerId: string }
/** Vote quiz, forme selon le format de la question. */
export type QuizAnswer = { choiceIndex: number } | { text: string }
/** Réponse soumise par un joueur pour la manche en cours. */
export type Answer = BlindtestAnswer | QuizAnswer
// --- Scores --------------------------------------------------------------
/** Score d'un joueur. */
export interface PlayerScore {
playerId: string
score: number
}
/** Variation de score appliquée à l'issue d'une manche. */
export interface ScoreDelta {
playerId: string
delta: number
}
/** Joueur ayant marqué sur une manche (récap de fin de partie). */
export interface RoundRecapScorer {
playerId: string
points: number
}
/** Réponse d'un joueur sur une manche (récap). */
export interface RoundPlayerAnswer {
playerId: string
answer: string
correct: boolean
}
/** Récapitulatif d'une manche jouée (affiché en fin de partie). */
export interface RoundRecap {
index: number
type: RoundType
/** Intitulé (question quiz) ou libellé du mode. */
label: string
/** Bonne réponse révélée. */
answer: string
category?: string
imageUrl?: string
youtubeId?: string
url?: string
/** Blindtest : qui avait ajouté le titre. */
addedBy?: string
/** Joueurs ayant marqué sur cette manche. */
scorers: RoundRecapScorer[]
/** Réponse de chaque joueur ayant répondu. */
answers: RoundPlayerAnswer[]
}
// --- Snapshot diffusé au client (room:state) -----------------------------
/**
* Vue publique de la room poussée à tous les clients.
* Ne contient JAMAIS de réponse non révélée ni d'info secrète (ex: submittedBy).
*/
export interface RoomSnapshot {
code: string
status: RoomStatus
hostId: string
players: Player[]
settings: RoomSettings
scores: PlayerScore[]
/** Index de la manche en cours, -1 dans le lobby. */
currentRound: number
/** Nombre total de manches planifiées. */
totalRounds: number
/** Blindtest : nombre de titres soumis par joueur (comptes seulement, jamais les titres). */
submissions: { playerId: string; count: number }[]
}

View file

@ -0,0 +1,194 @@
// Events Socket.IO typés, partagés par le serveur et le client.
// Convention : `CLIENT → SERVER` et `SERVER → CLIENT`. Tout timestamp est serveur.
import type {
Answer,
BlindtestMode,
BotDifficulty,
GameType,
MixMode,
PlayerScore,
RoomSnapshot,
RoundConfig,
RoundRecap,
RoundType,
} from "./domain"
import type { BlindtestTrackInfo } from "./blindtest"
// --- Payloads ------------------------------------------------------------
export interface RoomCreatePayload {
playerName?: string
}
export interface RoomCreatedPayload {
roomCode: string
playerId: string
/** Jeton secret pour se reconnecter après un refresh/coupure. */
token: string
}
export interface RejoinPayload {
roomCode: string
playerId: string
token: string
}
export interface RoomJoinPayload {
roomCode: string
playerName?: string
}
export interface SetNamePayload {
name: string
}
export interface UpdateSettingsPayload {
gameType: GameType
mixedModes: MixMode[]
blindtestMode: BlindtestMode
roundDuration: number
tracksPerPlayer: number
categories: string[]
botDifficulty: BotDifficulty
rounds: RoundConfig[]
}
export interface PlayerRefPayload {
playerId: string
}
export interface SubmitTrackPayload {
youtubeUrl: string
}
export interface SubmitOkPayload {
accepted: boolean
reason?: string
/** Id du titre créé (pour pouvoir le supprimer). */
trackId?: string
youtubeId?: string
/** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */
title?: string
/** Nombre total de titres soumis par ce joueur après cet ajout. */
count?: number
}
export interface RemoveTrackPayload {
trackId: string
}
export interface RoundStartPayload {
type: RoundType
djId?: string
/** Timestamp serveur de début des réponses (après le délai de préparation / transition). */
startsAt: number
/** Timestamp serveur de fin de manche (startsAt + roundDuration). */
endsAt: number
/** Envoyé uniquement au contributeur du titre (blindtest) : "c'est le tien". */
mine?: boolean
/** Payload spécifique au type d'épreuve, SANS la réponse. */
payload: unknown
}
export type MediaAction = "play" | "pause" | "seek"
/** Contrôle média émis par le DJ uniquement. */
export interface MediaControlPayload {
action: MediaAction
positionSec: number
}
/** Diffusion de recalage à tous les clients. */
export interface MediaSyncPayload {
action: MediaAction
positionSec: number
atServerTs: number
}
export interface VotePayload {
answer: Answer
}
/** Progression des votes. `voted` = playerIds ayant validé (jamais leur réponse). */
export interface VoteAckPayload {
count: number
total: number
voted: string[]
}
export interface RevealPayload {
truth: unknown
perPlayerResult: unknown
}
export interface ScoreUpdatePayload {
scores: PlayerScore[]
}
export interface GameEndPayload {
finalScores: PlayerScore[]
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
tracks?: BlindtestTrackInfo[]
/** Récapitulatif de toutes les manches (question + réponse + qui a marqué). */
recap?: RoundRecap[]
spotifyExport?: unknown
}
export interface ErrorPayload {
code: string
message: string
}
// --- Maps d'events Socket.IO ---------------------------------------------
/** Ack standard renvoyé par le serveur sur les commandes critiques. */
export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorPayload }) => void
/** CLIENT → SERVER */
export interface ClientToServerEvents {
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
"player:setName": (payload: SetNamePayload) => void
"room:leave": () => void
"lobby:addBot": () => void
"lobby:removeBot": () => void
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
"lobby:return": () => void
"game:start": (ack: Ack<null>) => void
"blindtest:submitTrack": (
payload: SubmitTrackPayload,
ack: (res: SubmitOkPayload) => void
) => void
"blindtest:removeTrack": (
payload: RemoveTrackPayload,
ack: (res: { ok: boolean }) => void
) => void
"media:control": (payload: MediaControlPayload) => void
"round:vote": (payload: VotePayload) => void
}
/** SERVER → CLIENT */
export interface ServerToClientEvents {
"room:state": (payload: RoomSnapshot) => void
"player:left": (payload: PlayerRefPayload) => void
"player:rejoined": (payload: PlayerRefPayload) => void
"blindtest:submitOk": (payload: SubmitOkPayload) => void
"round:start": (payload: RoundStartPayload) => void
"media:sync": (payload: MediaSyncPayload) => void
"round:voteAck": (payload: VoteAckPayload) => void
"round:reveal": (payload: RevealPayload) => void
"score:update": (payload: ScoreUpdatePayload) => void
"game:end": (payload: GameEndPayload) => void
"error": (payload: ErrorPayload) => void
}
/** Events serveur ↔ serveur (inutilisés en V1, pas de Redis adapter). */
export type InterServerEvents = Record<string, never>
/** Données attachées à chaque socket côté serveur. */
export interface SocketData {
playerId?: string
roomCode?: string
}

View file

@ -0,0 +1,4 @@
export * from "./domain"
export * from "./events"
export * from "./quiz"
export * from "./blindtest"

Some files were not shown because too many files have changed in this diff Show more