Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f8b39435e | |||
| a510bfdb8c | |||
|
|
541025f713 | ||
| 69811e0667 | |||
|
|
1727b84644 | ||
|
|
7d35df2dcc | ||
| bc266da74f | |||
|
|
aae09961fa | ||
| 6692fad394 | |||
|
|
e0ba4c2e23 | ||
|
|
8d79a52a68 | ||
| c0375db9f2 | |||
|
|
ea476da01e | ||
| 89ce5e2c0b | |||
|
|
1bda0e122e | ||
|
|
0d79bf3fa6 | ||
| 88188cebaa | |||
|
|
7116d63c81 | ||
| 4497d697f7 | |||
|
|
7a41bbf8c1 | ||
|
|
6362b97508 | ||
|
|
cfc5339a25 | ||
|
|
65beeff4f0 |
44 changed files with 820 additions and 93 deletions
53
CHANGELOG.md
53
CHANGELOG.md
|
|
@ -4,6 +4,55 @@ Toutes les évolutions notables de NerdWare. Format basé sur
|
||||||
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
||||||
[SemVer](https://semver.org/lang/fr/).
|
[SemVer](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [0.1.4] — 2026-06-12
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Mode Rébus** : devine l'œuvre geek à partir d'un rébus emoji (réponse libre,
|
||||||
|
matching tolérant). Implémenté comme format de quiz `rebus` (réutilise pool,
|
||||||
|
anti-doublons, scoring, récap, bots, back-office). Tuile dédiée au lobby,
|
||||||
|
affichage emoji en grand, transition 🧩, 9 rébus fournis + création au back-office.
|
||||||
|
|
||||||
|
## [0.1.3] — 2026-06-12
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Notes de version « Nouveautés »** : modale sur l'accueil listant les nouveautés
|
||||||
|
par version (curées, FR/EN), avec une pastille tant que la dernière version n'a
|
||||||
|
pas été vue. Composant `Dialog` réutilisable ajouté à `@workspace/ui`.
|
||||||
|
|
||||||
|
## [0.1.2] — 2026-06-12
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Umami analytics** (optionnel) : suivi d'audience injecté seulement si
|
||||||
|
`VITE_UMAMI_SRC` + `VITE_UMAMI_WEBSITE_ID` sont définis au build (sinon désactivé,
|
||||||
|
pas de suivi en dev). L'ID de site est public, pas un secret.
|
||||||
|
|
||||||
|
## [0.1.1] — 2026-06-12
|
||||||
|
|
||||||
|
Correctifs et améliorations issus des premières parties entre amis.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **DB** : connexion via variables `PG*` (au lieu d'un DSN inline) — robuste aux
|
||||||
|
caractères spéciaux dans le mot de passe Postgres.
|
||||||
|
- **Blindtest** : son débloqué côté non-DJ via un geste « Activer le son »
|
||||||
|
(politique d'autoplay) + `origin`/`enablejsapi` du lecteur ; état de vote
|
||||||
|
masqué en mode « qui l'a ajouté » (le contributeur ne se trahit plus).
|
||||||
|
- **Blindtest** : liste de ses titres rendue depuis le serveur (autoritaire) —
|
||||||
|
fin du « 3/3 mais 2 affichés » après remount/reconnexion.
|
||||||
|
- **Quiz** : le champ de réponse libre se réinitialise entre les questions.
|
||||||
|
- **Décompte** : synchro d'horloge serveur → le timer est identique sur tous les
|
||||||
|
appareils (fini les écarts dus au décalage d'horloge).
|
||||||
|
|
||||||
|
### Ajouté / changé
|
||||||
|
|
||||||
|
- **Blindtest** : le DJ peut être n'importe qui, y compris le contributeur ;
|
||||||
|
manches blindtest portées à 90 s.
|
||||||
|
- **Volume** audio réglable par joueur (mémorisé).
|
||||||
|
- **Thème** clair / sombre / système (sélecteur sur l'accueil et la room).
|
||||||
|
|
||||||
## [0.1.0] — 2026-06-11
|
## [0.1.0] — 2026-06-11
|
||||||
|
|
||||||
Première version déployable. Cycle de jeu temps réel complet (lobby → manches →
|
Première version déployable. Cycle de jeu temps réel complet (lobby → manches →
|
||||||
|
|
@ -31,4 +80,8 @@ fin), trois modes, back-office et internationalisation.
|
||||||
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
||||||
prêt pour Dokploy.
|
prêt pour Dokploy.
|
||||||
|
|
||||||
|
[0.1.4]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.4
|
||||||
|
[0.1.3]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.3
|
||||||
|
[0.1.2]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.2
|
||||||
|
[0.1.1]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.1
|
||||||
[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0
|
[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nerdware/server",
|
"name": "@nerdware/server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { env } from "../env"
|
||||||
import { db } from "../db"
|
import { db } from "../db"
|
||||||
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
||||||
|
|
||||||
const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const
|
const FORMATS = ["mcq", "truefalse", "free", "image_reveal", "rebus"] as const
|
||||||
type AdminFormat = (typeof FORMATS)[number]
|
type AdminFormat = (typeof FORMATS)[number]
|
||||||
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ function validate(
|
||||||
lang: body.lang === "en" ? "en" : "fr",
|
lang: body.lang === "en" ? "en" : "fr",
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "free" || format === "image_reveal") {
|
if (format === "free" || format === "image_reveal" || format === "rebus") {
|
||||||
const answers = (body.acceptedAnswers ?? [])
|
const answers = (body.acceptedAnswers ?? [])
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
|
||||||
21
apps/server/src/db/client.ts
Normal file
21
apps/server/src/db/client.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Création du client postgres-js. Deux modes :
|
||||||
|
// 1. DATABASE_URL (DSN complet) — pratique en dev.
|
||||||
|
// 2. Variables PG* (PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE) — recommandé en
|
||||||
|
// prod : le mot de passe est passé tel quel (pas d'encodage d'URL à gérer).
|
||||||
|
// postgres-js lit automatiquement les PG* de l'environnement quand on ne lui
|
||||||
|
// passe pas de DSN.
|
||||||
|
|
||||||
|
import postgres from "postgres"
|
||||||
|
import { env } from "../env"
|
||||||
|
|
||||||
|
export const hasDbConfig = !!env.databaseUrl || !!process.env.PGHOST
|
||||||
|
|
||||||
|
export function createSqlClient(opts: { max?: number } = {}) {
|
||||||
|
if (env.databaseUrl) {
|
||||||
|
return postgres(env.databaseUrl, opts)
|
||||||
|
}
|
||||||
|
if (process.env.PGHOST) {
|
||||||
|
return postgres(opts)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -2,17 +2,16 @@
|
||||||
// le jeu retombe sur la banque de questions en dur (dev sans infra).
|
// le jeu retombe sur la banque de questions en dur (dev sans infra).
|
||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js"
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
import postgres from "postgres"
|
import { createSqlClient } from "./client"
|
||||||
import { env } from "../env"
|
|
||||||
import * as schema from "./schema"
|
import * as schema from "./schema"
|
||||||
|
|
||||||
export type Database = ReturnType<typeof drizzle<typeof schema>>
|
export type Database = ReturnType<typeof drizzle<typeof schema>>
|
||||||
|
|
||||||
function createDb(): Database | null {
|
function createDb(): Database | null {
|
||||||
if (!env.databaseUrl) {
|
const client = createSqlClient()
|
||||||
|
if (!client) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const client = postgres(env.databaseUrl)
|
|
||||||
return drizzle(client, { schema })
|
return drizzle(client, { schema })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@
|
||||||
// bun run db:migrate
|
// bun run db:migrate
|
||||||
import { drizzle } from "drizzle-orm/postgres-js"
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
import { migrate } from "drizzle-orm/postgres-js/migrator"
|
import { migrate } from "drizzle-orm/postgres-js/migrator"
|
||||||
import postgres from "postgres"
|
import { createSqlClient } from "./client"
|
||||||
import { env } from "../env"
|
|
||||||
|
|
||||||
if (!env.databaseUrl) {
|
const client = createSqlClient({ max: 1 })
|
||||||
console.error("DATABASE_URL manquant — rien à migrer.")
|
if (!client) {
|
||||||
|
console.error("Aucune config DB (DATABASE_URL ou PG*) — rien à migrer.")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = postgres(env.databaseUrl, { max: 1 })
|
|
||||||
const db = drizzle(client)
|
const db = drizzle(client)
|
||||||
|
|
||||||
await migrate(db, { migrationsFolder: "./drizzle" })
|
await migrate(db, { migrationsFolder: "./drizzle" })
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,19 @@
|
||||||
|
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { drizzle } from "drizzle-orm/postgres-js"
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
import postgres from "postgres"
|
import { createSqlClient } from "./client"
|
||||||
import { env } from "../env"
|
|
||||||
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema"
|
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 COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
|
||||||
const CATEGORY = "Pokémon"
|
const CATEGORY = "Pokémon"
|
||||||
const PROMPT = "Quel est ce Pokémon ?"
|
const PROMPT = "Quel est ce Pokémon ?"
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
const client = postgres(env.databaseUrl, { max: 1 })
|
const client = createSqlClient({ max: 1 })
|
||||||
|
if (!client) {
|
||||||
|
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
||||||
|
|
||||||
async function upsertCategory(name: string): Promise<string> {
|
async function upsertCategory(name: string): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
|
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { drizzle } from "drizzle-orm/postgres-js"
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
import postgres from "postgres"
|
import { createSqlClient } from "./client"
|
||||||
import { env } from "../env"
|
|
||||||
import {
|
import {
|
||||||
quizCategory,
|
quizCategory,
|
||||||
quizQuestion,
|
quizQuestion,
|
||||||
|
|
@ -14,12 +13,11 @@ import {
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
|
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
|
||||||
|
|
||||||
if (!env.databaseUrl) {
|
const client = createSqlClient({ max: 1 })
|
||||||
console.error("DATABASE_URL manquant — impossible de seeder.")
|
if (!client) {
|
||||||
|
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = postgres(env.databaseUrl, { max: 1 })
|
|
||||||
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
||||||
|
|
||||||
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).
|
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ describe("match", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("BlindtestRound", () => {
|
describe("BlindtestRound", () => {
|
||||||
test("start : le DJ est neutre (jamais le contributeur)", () => {
|
test("start : le DJ est un humain connecté (le contributeur peut tomber DJ)", () => {
|
||||||
const rooms = new RoomManager()
|
const rooms = new RoomManager()
|
||||||
const { room, player: a } = rooms.create("Alice", "sa")
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
|
@ -53,10 +53,24 @@ describe("BlindtestRound", () => {
|
||||||
|
|
||||||
const round = new BlindtestRound()
|
const round = new BlindtestRound()
|
||||||
const start = round.start(room)
|
const start = round.start(room)
|
||||||
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
|
// N'importe quel humain (Alice incluse), jamais un bot ni null.
|
||||||
|
expect([a.id, b.id]).toContain(start.djId!)
|
||||||
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("start : un bot n'est jamais DJ", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
rooms.addBot(room)
|
||||||
|
rooms.addBot(room)
|
||||||
|
room.blindtestTracks = [track(a.id)]
|
||||||
|
prepareBlindtestForRoom(room)
|
||||||
|
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
// Alice est la seule humaine → DJ forcément Alice (les bots sont exclus).
|
||||||
|
expect(round.start(room).djId).toBe(a.id)
|
||||||
|
})
|
||||||
|
|
||||||
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
||||||
const rooms = new RoomManager()
|
const rooms = new RoomManager()
|
||||||
const { room, player: a } = rooms.create("Alice", "sa")
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,14 @@ interface BlindtestRoundData {
|
||||||
track: BlindtestTrack
|
track: BlindtestTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */
|
// Le blindtest dure plus longtemps (écoute + saisie).
|
||||||
function pickDj(room: ServerRoom, submittedBy: string): string | null {
|
const BLINDTEST_DURATION_SEC = 90
|
||||||
|
|
||||||
|
/** Tire un DJ : n'importe quel humain connecté (y compris le contributeur, pour
|
||||||
|
* ne pas donner d'« immunité » à celui qui n'est jamais tiré). */
|
||||||
|
function pickDj(room: ServerRoom): string | null {
|
||||||
const eligible = [...room.players.values()].filter(
|
const eligible = [...room.players.values()].filter(
|
||||||
(p) => p.connected && !p.isBot && p.id !== submittedBy
|
(p) => p.connected && !p.isBot
|
||||||
)
|
)
|
||||||
if (eligible.length === 0) {
|
if (eligible.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -77,13 +81,14 @@ export class BlindtestRound implements GameRound {
|
||||||
|
|
||||||
start(room: ServerRoom): RoundStart {
|
start(room: ServerRoom): RoundStart {
|
||||||
const track = takeTrack(room)
|
const track = takeTrack(room)
|
||||||
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
|
// DJ = n'importe quel humain (le contributeur peut tomber DJ).
|
||||||
const djId = track ? pickDj(room, track.submittedBy) : null
|
const djId = track ? pickDj(room) : null
|
||||||
const payload: BlindtestRoundPayload | null = track
|
const payload: BlindtestRoundPayload | null = track
|
||||||
? { trackId: track.id, youtubeId: track.youtubeId }
|
? { trackId: track.id, youtubeId: track.youtubeId }
|
||||||
: null
|
: null
|
||||||
return {
|
return {
|
||||||
djId,
|
djId,
|
||||||
|
durationSec: BLINDTEST_DURATION_SEC,
|
||||||
payload,
|
payload,
|
||||||
secretPlayerId: track?.submittedBy,
|
secretPlayerId: track?.submittedBy,
|
||||||
data: track ? { track } : null,
|
data: track ? { track } : null,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const poolByRoom = new WeakMap<ServerRoom, QueueItem[]>()
|
||||||
|
|
||||||
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
||||||
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
|
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
|
||||||
|
const REBUS_FORMATS: QuizFormat[] = ["rebus"]
|
||||||
|
|
||||||
function shuffle<T>(items: T[]): T[] {
|
function shuffle<T>(items: T[]): T[] {
|
||||||
const arr = [...items]
|
const arr = [...items]
|
||||||
|
|
@ -32,7 +33,7 @@ async function loadGroup(
|
||||||
room: ServerRoom,
|
room: ServerRoom,
|
||||||
need: number,
|
need: number,
|
||||||
formats: QuizFormat[],
|
formats: QuizFormat[],
|
||||||
isImage: boolean,
|
allowHardcoded: boolean,
|
||||||
categories: string[]
|
categories: string[]
|
||||||
): Promise<QueueItem[]> {
|
): Promise<QueueItem[]> {
|
||||||
if (need <= 0) {
|
if (need <= 0) {
|
||||||
|
|
@ -47,8 +48,8 @@ async function loadGroup(
|
||||||
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (items.length < need && !isImage) {
|
if (items.length < need && allowHardcoded) {
|
||||||
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
|
// Complément depuis la banque en dur (quiz + rébus ; pas d'images en dur).
|
||||||
const bank = shuffle(
|
const bank = shuffle(
|
||||||
QUIZ_QUESTIONS.filter(
|
QUIZ_QUESTIONS.filter(
|
||||||
(q) =>
|
(q) =>
|
||||||
|
|
@ -68,16 +69,24 @@ async function loadGroup(
|
||||||
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz")
|
const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz")
|
||||||
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
||||||
const needQuiz = quizRounds.length - needImage
|
const needRebus = quizRounds.filter((r) => r.pool === "rebus").length
|
||||||
|
const needQuiz = quizRounds.length - needImage - needRebus
|
||||||
|
|
||||||
const cats = room.settings.categories
|
const cats = room.settings.categories
|
||||||
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats)
|
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, true, cats)
|
||||||
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats)
|
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, false, cats)
|
||||||
|
// Les rébus ne sont pas filtrés par catégorie (catégorie « Rébus » à part).
|
||||||
|
const rebusGroup = await loadGroup(room, needRebus, REBUS_FORMATS, true, [])
|
||||||
|
|
||||||
// On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
|
// On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
|
||||||
const queue: QueueItem[] = []
|
const queue: QueueItem[] = []
|
||||||
for (const r of quizRounds) {
|
for (const r of quizRounds) {
|
||||||
const group = r.pool === "image" ? imageGroup : quizGroup
|
const group =
|
||||||
|
r.pool === "image"
|
||||||
|
? imageGroup
|
||||||
|
: r.pool === "rebus"
|
||||||
|
? rebusGroup
|
||||||
|
: quizGroup
|
||||||
const item = group.shift()
|
const item = group.shift()
|
||||||
if (item) {
|
if (item) {
|
||||||
queue.push(item)
|
queue.push(item)
|
||||||
|
|
|
||||||
|
|
@ -154,4 +154,82 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||||
category: "Manga",
|
category: "Manga",
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
},
|
},
|
||||||
|
// Rébus : devine l'œuvre à partir des emojis (réponse libre, matching tolérant).
|
||||||
|
{
|
||||||
|
id: "r-sonic",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🦔 + 💨 + 💍",
|
||||||
|
acceptedAnswers: ["Sonic", "Sonic the Hedgehog"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-mario",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🍄 + 🔨 + 🐢",
|
||||||
|
acceptedAnswers: ["Mario", "Super Mario", "Super Mario Bros"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-pacman",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🟡 + 👻 + 🕹️",
|
||||||
|
acceptedAnswers: ["Pac-Man", "Pacman", "Pac Man"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-pokemon",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⚡ + 🐭 + 🔴⚪",
|
||||||
|
acceptedAnswers: ["Pokémon", "Pokemon"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-harry-potter",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⚡ + 🧙♂️ + 🎓",
|
||||||
|
acceptedAnswers: ["Harry Potter", "Harry"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-lotr",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "💍 + 🌋 + 🧙♂️",
|
||||||
|
acceptedAnswers: [
|
||||||
|
"Le Seigneur des Anneaux",
|
||||||
|
"Seigneur des Anneaux",
|
||||||
|
"Lord of the Rings",
|
||||||
|
"LOTR",
|
||||||
|
],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-star-wars",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⭐ + ⚔️ + 🌌",
|
||||||
|
acceptedAnswers: ["Star Wars", "La Guerre des Étoiles"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-minecraft",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⛏️ + 🟩 + 🧟",
|
||||||
|
acceptedAnswers: ["Minecraft"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-zelda",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🗡️ + 🛡️ + 🧝♂️ + 🐴",
|
||||||
|
acceptedAnswers: ["Zelda", "The Legend of Zelda", "Legend of Zelda"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ function asText(answer: Answer): string | null {
|
||||||
|
|
||||||
/** Formats à réponse libre (saisie texte + matching tolérant). */
|
/** Formats à réponse libre (saisie texte + matching tolérant). */
|
||||||
function isTextFormat(question: QuizQuestion): boolean {
|
function isTextFormat(question: QuizQuestion): boolean {
|
||||||
return question.format === "free" || question.format === "image_reveal"
|
return (
|
||||||
|
question.format === "free" ||
|
||||||
|
question.format === "image_reveal" ||
|
||||||
|
question.format === "rebus"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réponse correcte ? Selon le format de la question. */
|
/** Réponse correcte ? Selon le format de la question. */
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,18 @@ export function registerRoomHandlers(
|
||||||
io.to(room.code).emit("room:state", rooms.toSnapshot(room))
|
io.to(room.code).emit("room:state", rooms.toSnapshot(room))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renvoie au joueur SES propres titres (source de vérité, anti-désync client).
|
||||||
|
const emitMyTracks = (room: ServerRoom, playerId: string) => {
|
||||||
|
socket.emit("blindtest:myTracks", {
|
||||||
|
tracks: room.blindtestTracks
|
||||||
|
.filter((t) => t.submittedBy === playerId)
|
||||||
|
.map((t) => ({ trackId: t.id, title: t.title, youtubeId: t.youtubeId })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchro d'horloge : le client mesure l'offset avec l'horloge serveur.
|
||||||
|
socket.on("time:sync", (ack) => ack(Date.now()))
|
||||||
|
|
||||||
socket.on("room:create", (payload, ack) => {
|
socket.on("room:create", (payload, ack) => {
|
||||||
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
|
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
|
||||||
const name = cleanName(payload?.playerName) ?? ""
|
const name = cleanName(payload?.playerName) ?? ""
|
||||||
|
|
@ -111,6 +123,7 @@ export function registerRoomHandlers(
|
||||||
})
|
})
|
||||||
io.to(room.code).emit("player:rejoined", { playerId: player.id })
|
io.to(room.code).emit("player:rejoined", { playerId: player.id })
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
|
emitMyTracks(room, player.id)
|
||||||
// Re-pousse la manche en cours au socket reconnecté (si partie en cours).
|
// Re-pousse la manche en cours au socket reconnecté (si partie en cours).
|
||||||
room.game?.resync(socket.id, player.id)
|
room.game?.resync(socket.id, player.id)
|
||||||
})
|
})
|
||||||
|
|
@ -293,6 +306,7 @@ export function registerRoomHandlers(
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
count: mine.length + 1,
|
count: mine.length + 1,
|
||||||
})
|
})
|
||||||
|
emitMyTracks(room, playerId)
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -311,6 +325,7 @@ export function registerRoomHandlers(
|
||||||
const removed = room.blindtestTracks.length < before
|
const removed = room.blindtestTracks.length < before
|
||||||
ack({ ok: removed })
|
ack({ ok: removed })
|
||||||
if (removed) {
|
if (removed) {
|
||||||
|
emitMyTracks(room, playerId)
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1 +1,5 @@
|
||||||
VITE_SERVER_URL=http://localhost:3001
|
VITE_SERVER_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Umami analytics (optionnel, prod). Vide en dev = pas de suivi.
|
||||||
|
VITE_UMAMI_SRC=
|
||||||
|
VITE_UMAMI_WEBSITE_ID=
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ COPY apps/web apps/web
|
||||||
|
|
||||||
ARG VITE_SERVER_URL
|
ARG VITE_SERVER_URL
|
||||||
ENV VITE_SERVER_URL=$VITE_SERVER_URL
|
ENV VITE_SERVER_URL=$VITE_SERVER_URL
|
||||||
|
# Umami analytics (optionnel) — figé au build.
|
||||||
|
ARG VITE_UMAMI_SRC
|
||||||
|
ENV VITE_UMAMI_SRC=$VITE_UMAMI_SRC
|
||||||
|
ARG VITE_UMAMI_WEBSITE_ID
|
||||||
|
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
|
||||||
RUN bun run --filter web build
|
RUN bun run --filter web build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runner
|
FROM nginx:1.27-alpine AS runner
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Disc3, Pause, Play, Rewind } from "lucide-react"
|
import { Disc3, Pause, Play, Rewind, Volume2 } from "lucide-react"
|
||||||
import type {
|
import type {
|
||||||
BlindtestAnswer,
|
BlindtestAnswer,
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
|
|
@ -15,6 +15,7 @@ import { useYoutube, type YoutubeApi } from "@/lib/youtube"
|
||||||
import { Countdown } from "@/components/countdown"
|
import { Countdown } from "@/components/countdown"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
|
import { serverNow } from "@/lib/clock"
|
||||||
|
|
||||||
const inputClass =
|
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"
|
"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"
|
||||||
|
|
@ -42,22 +43,56 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
|
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
|
||||||
const showReveal = truth !== null
|
const showReveal = truth !== null
|
||||||
|
|
||||||
|
// Les navigateurs bloquent la lecture audible sans geste utilisateur : le non-DJ
|
||||||
|
// doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé).
|
||||||
|
const [audioArmed, setAudioArmed] = useState(false)
|
||||||
|
|
||||||
|
// Volume local (par joueur), mémorisé entre les manches/parties.
|
||||||
|
const [volume, setVolume] = useState(() => {
|
||||||
|
const v = Number(localStorage.getItem("nerdware-volume"))
|
||||||
|
return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 100
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) {
|
||||||
|
api.setVolume(volume)
|
||||||
|
}
|
||||||
|
}, [ready, volume, api])
|
||||||
|
function changeVolume(v: number) {
|
||||||
|
setVolume(v)
|
||||||
|
try {
|
||||||
|
localStorage.setItem("nerdware-volume", String(v))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
|
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDj || !ready || !mediaSync) {
|
if (isDj || !ready || !mediaSync) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
|
const elapsed = (serverNow() - mediaSync.atServerTs) / 1000
|
||||||
if (mediaSync.action === "play") {
|
if (mediaSync.action === "play") {
|
||||||
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
||||||
|
if (audioArmed) {
|
||||||
api.play()
|
api.play()
|
||||||
|
}
|
||||||
} else if (mediaSync.action === "pause") {
|
} else if (mediaSync.action === "pause") {
|
||||||
api.seek(mediaSync.positionSec)
|
api.seek(mediaSync.positionSec)
|
||||||
api.pause()
|
api.pause()
|
||||||
} else {
|
} else {
|
||||||
api.seek(mediaSync.positionSec)
|
api.seek(mediaSync.positionSec)
|
||||||
}
|
}
|
||||||
}, [mediaSync, isDj, ready, api])
|
}, [mediaSync, isDj, ready, api, audioArmed])
|
||||||
|
|
||||||
|
function armAudio() {
|
||||||
|
setAudioArmed(true)
|
||||||
|
if (mediaSync && mediaSync.action !== "pause") {
|
||||||
|
const elapsed = (serverNow() - mediaSync.atServerTs) / 1000
|
||||||
|
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
||||||
|
}
|
||||||
|
api.play()
|
||||||
|
}
|
||||||
|
|
||||||
// Au reveal, on coupe le son.
|
// Au reveal, on coupe le son.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -98,8 +133,36 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */}
|
||||||
|
{!showReveal && !isDj && !audioArmed && (
|
||||||
|
<button
|
||||||
|
onClick={armAudio}
|
||||||
|
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/60 text-white backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<Volume2 className="size-10" />
|
||||||
|
<span className="font-heading font-bold">{t.blindtest.enableSound}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!showReveal && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||||
|
className="accent-primary flex-1"
|
||||||
|
aria-label={t.blindtest.volume}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground w-8 text-right text-xs tabular-nums">
|
||||||
|
{volume}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isDj && !showReveal && (
|
{isDj && !showReveal && (
|
||||||
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { serverNow } from "@/lib/clock"
|
||||||
|
|
||||||
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Secondes restantes. Avant `startsAt` (phase de préparation / transition),
|
* Secondes restantes, basées sur l'horloge serveur (serverNow) pour que tous les
|
||||||
* on affiche la durée pleine sans décompter.
|
* clients affichent le même décompte. Avant `startsAt` (préparation), on affiche
|
||||||
|
* la durée pleine sans décompter.
|
||||||
*/
|
*/
|
||||||
function secondsLeft(startsAt: number, endsAt: number): number {
|
function secondsLeft(startsAt: number, endsAt: number): number {
|
||||||
const now = Date.now()
|
const now = serverNow()
|
||||||
if (now < startsAt) {
|
if (now < startsAt) {
|
||||||
return Math.ceil((endsAt - startsAt) / 1000)
|
return Math.ceil((endsAt - startsAt) / 1000)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Music,
|
Music,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Puzzle,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
Trash2,
|
Trash2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
|
|
@ -43,6 +44,7 @@ const GAME_TYPES: {
|
||||||
{ value: "mixed", Icon: Shuffle, needsThree: false },
|
{ value: "mixed", Icon: Shuffle, needsThree: false },
|
||||||
{ value: "quiz", Icon: Brain, needsThree: false },
|
{ value: "quiz", Icon: Brain, needsThree: false },
|
||||||
{ value: "image", Icon: ImageIcon, needsThree: false },
|
{ value: "image", Icon: ImageIcon, needsThree: false },
|
||||||
|
{ value: "rebus", Icon: Puzzle, needsThree: false },
|
||||||
{ value: "blindtest", Icon: Headphones, needsThree: true },
|
{ value: "blindtest", Icon: Headphones, needsThree: true },
|
||||||
]
|
]
|
||||||
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
|
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
|
||||||
|
|
@ -110,6 +112,7 @@ function shuffle<T>(items: T[]): T[] {
|
||||||
function buildRounds(opts: {
|
function buildRounds(opts: {
|
||||||
quiz: number
|
quiz: number
|
||||||
image: number
|
image: number
|
||||||
|
rebus: number
|
||||||
tracks: number
|
tracks: number
|
||||||
shuffleOrder: boolean
|
shuffleOrder: boolean
|
||||||
}): RoundConfig[] {
|
}): RoundConfig[] {
|
||||||
|
|
@ -122,6 +125,10 @@ function buildRounds(opts: {
|
||||||
type: "quiz" as const,
|
type: "quiz" as const,
|
||||||
pool: "image" as const,
|
pool: "image" as const,
|
||||||
})),
|
})),
|
||||||
|
...Array.from({ length: opts.rebus }, () => ({
|
||||||
|
type: "quiz" as const,
|
||||||
|
pool: "rebus" as const,
|
||||||
|
})),
|
||||||
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
|
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
|
||||||
]
|
]
|
||||||
return opts.shuffleOrder ? shuffle(rounds) : rounds
|
return opts.shuffleOrder ? shuffle(rounds) : rounds
|
||||||
|
|
@ -154,6 +161,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
const [quizCount, setQuizCount] = useState(5)
|
const [quizCount, setQuizCount] = useState(5)
|
||||||
const [imageCount, setImageCount] = useState(5)
|
const [imageCount, setImageCount] = useState(5)
|
||||||
|
const [rebusCount, setRebusCount] = useState(5)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -179,6 +187,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
|
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
|
||||||
const showImage =
|
const showImage =
|
||||||
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
|
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
|
||||||
|
const showRebus =
|
||||||
|
effectiveType === "rebus" || (isMixed && mixedModes.includes("rebus"))
|
||||||
const showBlindtest =
|
const showBlindtest =
|
||||||
effectiveType === "blindtest" ||
|
effectiveType === "blindtest" ||
|
||||||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
|
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
|
||||||
|
|
@ -186,6 +196,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const rounds = buildRounds({
|
const rounds = buildRounds({
|
||||||
quiz: showQuiz ? quizCount : 0,
|
quiz: showQuiz ? quizCount : 0,
|
||||||
image: showImage ? imageCount : 0,
|
image: showImage ? imageCount : 0,
|
||||||
|
rebus: showRebus ? rebusCount : 0,
|
||||||
tracks: showBlindtest ? totalTracks : 0,
|
tracks: showBlindtest ? totalTracks : 0,
|
||||||
shuffleOrder: isMixed,
|
shuffleOrder: isMixed,
|
||||||
})
|
})
|
||||||
|
|
@ -295,7 +306,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{/* Mode de jeu */}
|
{/* Mode de jeu */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">{t.lobby.gameMode}</span>
|
<span className="text-sm font-medium">{t.lobby.gameMode}</span>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{GAME_TYPES.map(({ value, Icon, needsThree }) => {
|
{GAME_TYPES.map(({ value, Icon, needsThree }) => {
|
||||||
const locked = needsThree && !blindtestAvailable
|
const locked = needsThree && !blindtestAvailable
|
||||||
const active = effectiveType === value
|
const active = effectiveType === value
|
||||||
|
|
@ -339,7 +350,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">{t.lobby.subModes}</span>
|
<span className="text-sm font-medium">{t.lobby.subModes}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(["quiz", "image", "blindtest"] as const).map((m) => {
|
{(["quiz", "image", "rebus", "blindtest"] as const).map((m) => {
|
||||||
const locked = m === "blindtest" && !blindtestAvailable
|
const locked = m === "blindtest" && !blindtestAvailable
|
||||||
const on = mixedModes.includes(m) && !locked
|
const on = mixedModes.includes(m) && !locked
|
||||||
const tile = (
|
const tile = (
|
||||||
|
|
@ -400,6 +411,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Nombre de rébus */}
|
||||||
|
{showRebus && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Puzzle className="size-4" /> {t.lobby.rebusCount}
|
||||||
|
</span>
|
||||||
|
<Stepper
|
||||||
|
value={rebusCount}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
onChange={setRebusCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filtre de catégories (quiz / images) */}
|
{/* Filtre de catégories (quiz / images) */}
|
||||||
{(showQuiz || showImage) && allCategories.length > 0 && (
|
{(showQuiz || showImage) && allCategories.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
@ -550,12 +576,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MyTrack {
|
|
||||||
trackId: string
|
|
||||||
title: string
|
|
||||||
youtubeId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrackSubmission({
|
function TrackSubmission({
|
||||||
tracksPerPlayer,
|
tracksPerPlayer,
|
||||||
myCount,
|
myCount,
|
||||||
|
|
@ -565,11 +585,12 @@ function TrackSubmission({
|
||||||
}) {
|
}) {
|
||||||
const submitTrack = useRoomStore((s) => s.submitTrack)
|
const submitTrack = useRoomStore((s) => s.submitTrack)
|
||||||
const removeTrack = useRoomStore((s) => s.removeTrack)
|
const removeTrack = useRoomStore((s) => s.removeTrack)
|
||||||
|
// Source de vérité : le serveur renvoie nos titres (anti-désync au remount).
|
||||||
|
const myTracks = useRoomStore((s) => s.myTracks)
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [url, setUrl] = useState("")
|
const [url, setUrl] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [feedback, setFeedback] = useState<string | null>(null)
|
const [feedback, setFeedback] = useState<string | null>(null)
|
||||||
const [tracks, setTracks] = useState<MyTrack[]>([])
|
|
||||||
const quotaReached = myCount >= tracksPerPlayer
|
const quotaReached = myCount >= tracksPerPlayer
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
|
@ -579,11 +600,8 @@ function TrackSubmission({
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setFeedback(null)
|
setFeedback(null)
|
||||||
const res = await submitTrack(url.trim())
|
const res = await submitTrack(url.trim())
|
||||||
if (res.accepted && res.trackId && res.youtubeId) {
|
if (res.accepted) {
|
||||||
setTracks((t) => [
|
// La liste se met à jour via l'event serveur blindtest:myTracks.
|
||||||
...t,
|
|
||||||
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
|
|
||||||
])
|
|
||||||
setUrl("")
|
setUrl("")
|
||||||
} else {
|
} else {
|
||||||
setFeedback(res.reason ?? t.lobby.rejected)
|
setFeedback(res.reason ?? t.lobby.rejected)
|
||||||
|
|
@ -592,10 +610,7 @@ function TrackSubmission({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(trackId: string) {
|
async function remove(trackId: string) {
|
||||||
const ok = await removeTrack(trackId)
|
await removeTrack(trackId)
|
||||||
if (ok) {
|
|
||||||
setTracks((t) => t.filter((x) => x.trackId !== trackId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -604,9 +619,9 @@ function TrackSubmission({
|
||||||
{t.lobby.myTracks(myCount, tracksPerPlayer)}
|
{t.lobby.myTracks(myCount, tracksPerPlayer)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{tracks.length > 0 && (
|
{myTracks.length > 0 && (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{tracks.map((track) => (
|
{myTracks.map((track) => (
|
||||||
<li
|
<li
|
||||||
key={track.trackId}
|
key={track.trackId}
|
||||||
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||||
const showReveal = truth !== null
|
const showReveal = truth !== null
|
||||||
const isImage = question.format === "image_reveal"
|
const isImage = question.format === "image_reveal"
|
||||||
const isText = question.format === "free" || isImage
|
const isRebus = question.format === "rebus"
|
||||||
|
const isText = question.format === "free" || isImage || isRebus
|
||||||
const myResult = reveal
|
const myResult = reveal
|
||||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -74,9 +75,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{isRebus ? (
|
||||||
|
<div className="bg-card flex min-h-32 items-center justify-center rounded-2xl p-6 text-center text-4xl leading-relaxed text-balance sm:text-5xl">
|
||||||
|
{question.prompt}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<h2 className="font-heading text-xl font-semibold text-balance">
|
<h2 className="font-heading text-xl font-semibold text-balance">
|
||||||
{question.prompt}
|
{question.prompt}
|
||||||
</h2>
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
{isImage && question.imageUrl && (
|
{isImage && question.imageUrl && (
|
||||||
<ImageReveal
|
<ImageReveal
|
||||||
|
|
@ -92,7 +99,12 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isText ? (
|
{isText ? (
|
||||||
<FreeAnswer disabled={showReveal || hasVoted} onSubmit={voteText} t={t} />
|
<FreeAnswer
|
||||||
|
key={round.endsAt}
|
||||||
|
disabled={showReveal || hasVoted}
|
||||||
|
onSubmit={voteText}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{(question.choices ?? []).map((choice, index) => (
|
{(question.choices ?? []).map((choice, index) => (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
|
|
||||||
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
|
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
|
||||||
export type TransitionKind = "quiz" | "image" | "blindtest"
|
export type TransitionKind = "quiz" | "image" | "rebus" | "blindtest"
|
||||||
|
|
||||||
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
||||||
|
|
||||||
|
|
@ -19,11 +19,17 @@ const ALL_MEDIA = import.meta.glob(
|
||||||
{ eager: true, import: "default" }
|
{ eager: true, import: "default" }
|
||||||
) as Record<string, string>
|
) as Record<string, string>
|
||||||
|
|
||||||
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
|
const MEDIA: Record<TransitionKind, string[]> = {
|
||||||
|
quiz: [],
|
||||||
|
image: [],
|
||||||
|
rebus: [],
|
||||||
|
blindtest: [],
|
||||||
|
}
|
||||||
const SHARED_MEDIA: string[] = []
|
const SHARED_MEDIA: string[] = []
|
||||||
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
||||||
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
|
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
|
||||||
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
|
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
|
||||||
|
else if (path.includes("/transitions/rebus/")) MEDIA.rebus.push(url)
|
||||||
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
|
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
|
||||||
else SHARED_MEDIA.push(url)
|
else SHARED_MEDIA.push(url)
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +52,13 @@ const THEME: Record<
|
||||||
kind: "Image",
|
kind: "Image",
|
||||||
emoji: "🖼️",
|
emoji: "🖼️",
|
||||||
},
|
},
|
||||||
|
rebus: {
|
||||||
|
gradient: "from-amber-500 via-orange-600 to-rose-600",
|
||||||
|
accent: "text-yellow-200",
|
||||||
|
label: "RÉBUS",
|
||||||
|
kind: "Rébus",
|
||||||
|
emoji: "🧩",
|
||||||
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
||||||
accent: "text-yellow-300",
|
accent: "text-yellow-300",
|
||||||
|
|
|
||||||
31
apps/web/src/components/theme-toggle.tsx
Normal file
31
apps/web/src/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "@/components/theme-provider"
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ value: "light", Icon: Sun },
|
||||||
|
{ value: "dark", Icon: Moon },
|
||||||
|
{ value: "system", Icon: Monitor },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Sélecteur de thème : clair / sombre / système. */
|
||||||
|
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-1 ${className}`}>
|
||||||
|
{OPTIONS.map(({ value, Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
title={value}
|
||||||
|
className={`rounded-md p-1.5 transition-colors ${
|
||||||
|
theme === value
|
||||||
|
? "bg-primary/10 text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
apps/web/src/components/whats-new.tsx
Normal file
79
apps/web/src/components/whats-new.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Sparkles } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog"
|
||||||
|
import { PATCH_NOTES } from "@/lib/patch-notes"
|
||||||
|
import { useI18n } from "@/i18n/context"
|
||||||
|
|
||||||
|
const SEEN_KEY = "nerdware-seen-version"
|
||||||
|
|
||||||
|
/** Bouton « Nouveautés » + modale des notes de version (pastille si version vue ≠ actuelle). */
|
||||||
|
export function WhatsNew({ className = "" }: { className?: string }) {
|
||||||
|
const { t, lang } = useI18n()
|
||||||
|
const [seen, setSeen] = useState<string | null>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SEEN_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hasNew = seen !== __APP_VERSION__
|
||||||
|
|
||||||
|
function markSeen() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SEEN_KEY, __APP_VERSION__)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setSeen(__APP_VERSION__)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
markSeen()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={`text-muted-foreground hover:text-foreground relative inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${className}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" /> {t.whatsNew.button}
|
||||||
|
{hasNew && (
|
||||||
|
<span className="bg-primary absolute -top-0.5 -right-0.5 size-2 rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t.whatsNew.title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto">
|
||||||
|
{PATCH_NOTES.map((note) => (
|
||||||
|
<div key={note.version} className="flex flex-col gap-1">
|
||||||
|
<p className="font-heading text-sm font-bold">
|
||||||
|
v{note.version}
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
{" "}
|
||||||
|
· {note.date}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="text-muted-foreground list-disc pl-5 text-sm">
|
||||||
|
{(lang === "fr" ? note.fr : note.en).map((line, i) => (
|
||||||
|
<li key={i}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,10 @@ export const en: Dict = {
|
||||||
loading: "Loading…",
|
loading: "Loading…",
|
||||||
error: "Error",
|
error: "Error",
|
||||||
},
|
},
|
||||||
|
whatsNew: {
|
||||||
|
button: "What's new",
|
||||||
|
title: "What's new",
|
||||||
|
},
|
||||||
home: {
|
home: {
|
||||||
tagline: "Geek culture party game",
|
tagline: "Geek culture party game",
|
||||||
create: "Create a room",
|
create: "Create a room",
|
||||||
|
|
@ -56,6 +60,7 @@ export const en: Dict = {
|
||||||
quizCount: "Quiz questions",
|
quizCount: "Quiz questions",
|
||||||
imageCount: "Images to guess",
|
imageCount: "Images to guess",
|
||||||
imageHint: "Requires “Image” questions created in the back-office.",
|
imageHint: "Requires “Image” questions created in the back-office.",
|
||||||
|
rebusCount: "Rebuses to guess",
|
||||||
categories: "Categories",
|
categories: "Categories",
|
||||||
allCategories: "All",
|
allCategories: "All",
|
||||||
botLevel: "🤖 Bot difficulty",
|
botLevel: "🤖 Bot difficulty",
|
||||||
|
|
@ -79,11 +84,13 @@ export const en: Dict = {
|
||||||
mixed: "Mixed",
|
mixed: "Mixed",
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rebus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
mixModes: {
|
mixModes: {
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rebus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
blindtestModes: {
|
blindtestModes: {
|
||||||
|
|
@ -109,6 +116,8 @@ export const en: Dict = {
|
||||||
validate: "Submit",
|
validate: "Submit",
|
||||||
},
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
|
enableSound: "Enable sound",
|
||||||
|
volume: "Volume",
|
||||||
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
|
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
|
||||||
play: "Play",
|
play: "Play",
|
||||||
pause: "Pause",
|
pause: "Pause",
|
||||||
|
|
@ -136,6 +145,7 @@ export const en: Dict = {
|
||||||
kind: {
|
kind: {
|
||||||
quiz: "Question",
|
quiz: "Question",
|
||||||
image: "Image",
|
image: "Image",
|
||||||
|
rebus: "Rebus",
|
||||||
blindtest: "Track",
|
blindtest: "Track",
|
||||||
},
|
},
|
||||||
ready: "READY ?!",
|
ready: "READY ?!",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ export const fr = {
|
||||||
loading: "Chargement…",
|
loading: "Chargement…",
|
||||||
error: "Erreur",
|
error: "Erreur",
|
||||||
},
|
},
|
||||||
|
whatsNew: {
|
||||||
|
button: "Nouveautés",
|
||||||
|
title: "Nouveautés",
|
||||||
|
},
|
||||||
home: {
|
home: {
|
||||||
tagline: "Party game culture geek",
|
tagline: "Party game culture geek",
|
||||||
create: "Créer une room",
|
create: "Créer une room",
|
||||||
|
|
@ -54,6 +58,7 @@ export const fr = {
|
||||||
quizCount: "Questions de quiz",
|
quizCount: "Questions de quiz",
|
||||||
imageCount: "Images à deviner",
|
imageCount: "Images à deviner",
|
||||||
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
|
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
|
||||||
|
rebusCount: "Rébus à deviner",
|
||||||
categories: "Catégories",
|
categories: "Catégories",
|
||||||
allCategories: "Toutes",
|
allCategories: "Toutes",
|
||||||
botLevel: "🤖 Niveau des bots",
|
botLevel: "🤖 Niveau des bots",
|
||||||
|
|
@ -77,11 +82,13 @@ export const fr = {
|
||||||
mixed: "Mixte",
|
mixed: "Mixte",
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rébus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
mixModes: {
|
mixModes: {
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rébus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
blindtestModes: {
|
blindtestModes: {
|
||||||
|
|
@ -107,6 +114,8 @@ export const fr = {
|
||||||
validate: "Valider",
|
validate: "Valider",
|
||||||
},
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
|
enableSound: "Activer le son",
|
||||||
|
volume: "Volume",
|
||||||
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
|
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
|
||||||
play: "Play",
|
play: "Play",
|
||||||
pause: "Pause",
|
pause: "Pause",
|
||||||
|
|
@ -135,6 +144,7 @@ export const fr = {
|
||||||
kind: {
|
kind: {
|
||||||
quiz: "Question",
|
quiz: "Question",
|
||||||
image: "Image",
|
image: "Image",
|
||||||
|
rebus: "Rébus",
|
||||||
blindtest: "Titre",
|
blindtest: "Titre",
|
||||||
},
|
},
|
||||||
ready: "PRÊT ?!",
|
ready: "PRÊT ?!",
|
||||||
|
|
|
||||||
20
apps/web/src/lib/analytics.ts
Normal file
20
apps/web/src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Umami analytics : on injecte le script seulement si les variables de build
|
||||||
|
// sont présentes (donc pas en dev → pas de trafic local dans les stats).
|
||||||
|
// L'ID de site est public (visible dans le HTML), ce n'est pas un secret.
|
||||||
|
|
||||||
|
const SRC = import.meta.env.VITE_UMAMI_SRC
|
||||||
|
const WEBSITE_ID = import.meta.env.VITE_UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
|
export function initAnalytics(): void {
|
||||||
|
if (!SRC || !WEBSITE_ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (document.querySelector("script[data-website-id]")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement("script")
|
||||||
|
script.defer = true
|
||||||
|
script.src = SRC
|
||||||
|
script.setAttribute("data-website-id", WEBSITE_ID)
|
||||||
|
document.head.appendChild(script)
|
||||||
|
}
|
||||||
45
apps/web/src/lib/clock.ts
Normal file
45
apps/web/src/lib/clock.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Synchro d'horloge avec le serveur : on estime l'offset (horloge serveur −
|
||||||
|
// horloge locale) pour que tous les clients affichent le même décompte, quel que
|
||||||
|
// soit le décalage d'horloge de chaque machine.
|
||||||
|
|
||||||
|
import { socket } from "./socket"
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
/** Instant courant aligné sur l'horloge serveur. */
|
||||||
|
export function serverNow(): number {
|
||||||
|
return Date.now() + offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une mesure : renvoie [rtt, offset estimé]. */
|
||||||
|
async function syncOnce(): Promise<[number, number]> {
|
||||||
|
const t0 = Date.now()
|
||||||
|
const serverTime = await new Promise<number>((resolve) =>
|
||||||
|
socket.emit("time:sync", resolve)
|
||||||
|
)
|
||||||
|
const t1 = Date.now()
|
||||||
|
const rtt = t1 - t0
|
||||||
|
// Au moment de la réponse (t1), l'horloge serveur valait ~ serverTime + rtt/2.
|
||||||
|
return [rtt, serverTime + rtt / 2 - t1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plusieurs mesures : on garde celle de plus faible latence (plus fiable). */
|
||||||
|
async function sync(): Promise<void> {
|
||||||
|
let bestRtt = Infinity
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try {
|
||||||
|
const [rtt, est] = await syncOnce()
|
||||||
|
if (rtt < bestRtt) {
|
||||||
|
bestRtt = rtt
|
||||||
|
offset = est
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("connect", () => void sync())
|
||||||
|
if (socket.connected) {
|
||||||
|
void sync()
|
||||||
|
}
|
||||||
56
apps/web/src/lib/patch-notes.ts
Normal file
56
apps/web/src/lib/patch-notes.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// Notes de version côté joueur (curées, bilingues). Sous-ensemble grand public
|
||||||
|
// du CHANGELOG.md (qui reste la source de vérité technique).
|
||||||
|
|
||||||
|
export interface PatchNote {
|
||||||
|
version: string
|
||||||
|
date: string
|
||||||
|
fr: string[]
|
||||||
|
en: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATCH_NOTES: PatchNote[] = [
|
||||||
|
{
|
||||||
|
version: "0.1.4",
|
||||||
|
date: "2026-06-12",
|
||||||
|
fr: ["Nouveau mode Rébus 🧩 : devine l'œuvre à partir des emojis !"],
|
||||||
|
en: ["New Rebus mode 🧩: guess the work from emojis!"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.1.2",
|
||||||
|
date: "2026-06-12",
|
||||||
|
fr: ["Statistiques d'audience (Umami)."],
|
||||||
|
en: ["Audience analytics (Umami)."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.1.1",
|
||||||
|
date: "2026-06-12",
|
||||||
|
fr: [
|
||||||
|
"Blindtest : bouton « Activer le son » et volume réglable.",
|
||||||
|
"Blindtest porté à 90 s ; le DJ peut désormais être n'importe qui.",
|
||||||
|
"« Qui l'a ajouté ? » : l'état de validation est caché (plus de triche).",
|
||||||
|
"Thème clair / sombre / système.",
|
||||||
|
"Décompte synchronisé sur tous les appareils.",
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
"Blindtest: “Enable sound” button and volume control.",
|
||||||
|
"Blindtest is now 90 s; the DJ can be anyone.",
|
||||||
|
"“Who added it?”: vote status is hidden (no more giveaway).",
|
||||||
|
"Light / dark / system theme.",
|
||||||
|
"Countdown synced across all devices.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.1.0",
|
||||||
|
date: "2026-06-11",
|
||||||
|
fr: [
|
||||||
|
"Première version : Quiz, Images, Blindtest et mode Mixte.",
|
||||||
|
"Reconnexion après refresh, bots, historique des parties.",
|
||||||
|
"Interface FR / EN.",
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
"First release: Quiz, Images, Blindtest and Mixed mode.",
|
||||||
|
"Reconnect after refresh, bots, game history.",
|
||||||
|
"FR / EN interface.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -29,6 +29,8 @@ export interface YoutubeApi {
|
||||||
seek: (seconds: number) => void
|
seek: (seconds: number) => void
|
||||||
time: () => number
|
time: () => number
|
||||||
duration: () => number
|
duration: () => number
|
||||||
|
/** Volume 0–100. */
|
||||||
|
setVolume: (v: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,6 +67,10 @@ export function useYoutube(youtubeId: string) {
|
||||||
modestbranding: 1,
|
modestbranding: 1,
|
||||||
rel: 0,
|
rel: 0,
|
||||||
playsinline: 1,
|
playsinline: 1,
|
||||||
|
// Évite l'avertissement postMessage "target origin does not match"
|
||||||
|
// et fiabilise la transmission des commandes au lecteur.
|
||||||
|
enablejsapi: 1,
|
||||||
|
origin: window.location.origin,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
|
|
@ -91,6 +97,7 @@ export function useYoutube(youtubeId: string) {
|
||||||
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
||||||
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
||||||
duration: () => playerRef.current?.getDuration() ?? 0,
|
duration: () => playerRef.current?.getDuration() ?? 0,
|
||||||
|
setVolume: (v) => playerRef.current?.setVolume(v),
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import "@workspace/ui/globals.css"
|
||||||
import { App } from "./App.tsx"
|
import { App } from "./App.tsx"
|
||||||
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
||||||
import { I18nProvider } from "@/i18n/provider"
|
import { I18nProvider } from "@/i18n/provider"
|
||||||
|
import { initAnalytics } from "@/lib/analytics"
|
||||||
|
|
||||||
|
initAnalytics()
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,9 @@ function QuestionRow({
|
||||||
{q.lang.toUpperCase()} · {q.source}
|
{q.lang.toUpperCase()} · {q.source}
|
||||||
{!q.active && " · désactivée"}
|
{!q.active && " · désactivée"}
|
||||||
</p>
|
</p>
|
||||||
{q.format === "free" || q.format === "image_reveal" ? (
|
{q.format === "free" ||
|
||||||
|
q.format === "image_reveal" ||
|
||||||
|
q.format === "rebus" ? (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
✔ {q.acceptedAnswers?.join(", ")}
|
✔ {q.acceptedAnswers?.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -258,7 +260,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
|
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
|
||||||
if (format === "free") {
|
if (format === "free" || format === "rebus") {
|
||||||
input.acceptedAnswers = answers
|
input.acceptedAnswers = answers
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
|
|
@ -296,6 +298,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
<option value="truefalse">Vrai / Faux</option>
|
<option value="truefalse">Vrai / Faux</option>
|
||||||
<option value="free">Réponse libre</option>
|
<option value="free">Réponse libre</option>
|
||||||
<option value="image_reveal">Image à deviner</option>
|
<option value="image_reveal">Image à deviner</option>
|
||||||
|
<option value="rebus">Rébus</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
|
@ -412,7 +415,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(format === "free" || format === "image_reveal") && (
|
{(format === "free" || format === "image_reveal" || format === "rebus") && (
|
||||||
<textarea
|
<textarea
|
||||||
className={`${inputClass} h-24 py-2`}
|
className={`${inputClass} h-24 py-2`}
|
||||||
placeholder="Réponses acceptées (une par ligne)"
|
placeholder="Réponses acceptées (une par ligne)"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { useRoomStore } from "@/store/room"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
import { errorMessage } from "@/i18n/error"
|
import { errorMessage } from "@/i18n/error"
|
||||||
import { LanguageToggle } from "@/i18n/language-toggle"
|
import { LanguageToggle } from "@/i18n/language-toggle"
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
import { WhatsNew } from "@/components/whats-new"
|
||||||
|
|
||||||
const inputClass =
|
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"
|
"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"
|
||||||
|
|
@ -60,7 +62,11 @@ export function HomePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
||||||
<LanguageToggle className="absolute top-4 right-4 z-10" />
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||||
|
<WhatsNew />
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageToggle />
|
||||||
|
</div>
|
||||||
{/* Halo animé */}
|
{/* Halo animé */}
|
||||||
<motion.div
|
<motion.div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { RoundTransition } from "@/components/round-transition"
|
||||||
import { PseudoScreen } from "@/components/pseudo-screen"
|
import { PseudoScreen } from "@/components/pseudo-screen"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
import { LanguageToggle } from "@/i18n/language-toggle"
|
import { LanguageToggle } from "@/i18n/language-toggle"
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
|
||||||
export function RoomPage({ code }: { code: string }) {
|
export function RoomPage({ code }: { code: string }) {
|
||||||
const [, navigate] = useLocation()
|
const [, navigate] = useLocation()
|
||||||
|
|
@ -100,6 +101,7 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
{connected ? t.room.connected : t.room.disconnected}
|
{connected ? t.room.connected : t.room.disconnected}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<ThemeToggle />
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -129,7 +131,15 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
<PlayerCards
|
<PlayerCards
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
playerId={playerId}
|
playerId={playerId}
|
||||||
showVoteStatus={snapshot.status === "in_round"}
|
// En blindtest "qui l'a ajouté" (et mixte), on cache l'état de
|
||||||
|
// vote : le contributeur ne vote jamais → sinon il est repérable.
|
||||||
|
showVoteStatus={
|
||||||
|
snapshot.status === "in_round" &&
|
||||||
|
!(
|
||||||
|
round?.type === "blindtest" &&
|
||||||
|
snapshot.settings.blindtestMode !== "title_artist"
|
||||||
|
)
|
||||||
|
}
|
||||||
votedIds={voteProgress?.voted}
|
votedIds={voteProgress?.voted}
|
||||||
/>
|
/>
|
||||||
{round?.type === "blindtest" ? (
|
{round?.type === "blindtest" ? (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
type ErrorPayload,
|
type ErrorPayload,
|
||||||
type MediaControlPayload,
|
type MediaControlPayload,
|
||||||
type MediaSyncPayload,
|
type MediaSyncPayload,
|
||||||
|
type MyTrackInfo,
|
||||||
type PlayerScore,
|
type PlayerScore,
|
||||||
type RoomCreatedPayload,
|
type RoomCreatedPayload,
|
||||||
type RoomSnapshot,
|
type RoomSnapshot,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
type VoteAckPayload,
|
type VoteAckPayload,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import { socket } from "@/lib/socket"
|
import { socket } from "@/lib/socket"
|
||||||
|
import "@/lib/clock" // démarre la synchro d'horloge avec le serveur
|
||||||
import {
|
import {
|
||||||
clearSession,
|
clearSession,
|
||||||
getSession,
|
getSession,
|
||||||
|
|
@ -51,6 +53,8 @@ interface RoomState {
|
||||||
pseudoSet: boolean
|
pseudoSet: boolean
|
||||||
snapshot: RoomSnapshot | null
|
snapshot: RoomSnapshot | null
|
||||||
lastError: ErrorPayload | null
|
lastError: ErrorPayload | null
|
||||||
|
/** Mes titres blindtest (renvoyés par le serveur, source de vérité). */
|
||||||
|
myTracks: MyTrackInfo[]
|
||||||
|
|
||||||
// État de jeu (poussé par le moteur serveur).
|
// État de jeu (poussé par le moteur serveur).
|
||||||
round: ActiveRound | null
|
round: ActiveRound | null
|
||||||
|
|
@ -64,8 +68,8 @@ interface RoomState {
|
||||||
mediaSync: MediaSyncPayload | null
|
mediaSync: MediaSyncPayload | null
|
||||||
boomKey: number
|
boomKey: number
|
||||||
roundKey: number
|
roundKey: number
|
||||||
/** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
|
/** Catégorie visuelle de la manche (image_reveal / rébus distincts du quiz). */
|
||||||
roundKind: "quiz" | "image" | "blindtest"
|
roundKind: "quiz" | "image" | "rebus" | "blindtest"
|
||||||
/** Le mode a-t-il changé par rapport à la manche précédente ? */
|
/** Le mode a-t-il changé par rapport à la manche précédente ? */
|
||||||
roundModeChanged: boolean
|
roundModeChanged: boolean
|
||||||
|
|
||||||
|
|
@ -96,6 +100,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
pseudoSet: !!saved?.name,
|
pseudoSet: !!saved?.name,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
myTracks: [],
|
||||||
round: null,
|
round: null,
|
||||||
voteProgress: null,
|
voteProgress: null,
|
||||||
reveal: null,
|
reveal: null,
|
||||||
|
|
@ -242,6 +247,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
pseudoSet: false,
|
pseudoSet: false,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
myTracks: [],
|
||||||
round: null,
|
round: null,
|
||||||
voteProgress: null,
|
voteProgress: null,
|
||||||
reveal: null,
|
reveal: null,
|
||||||
|
|
@ -303,6 +309,9 @@ socket.on("room:state", (snapshot) =>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||||
|
socket.on("blindtest:myTracks", ({ tracks }) =>
|
||||||
|
useRoomStore.setState({ myTracks: tracks })
|
||||||
|
)
|
||||||
|
|
||||||
socket.on("round:start", (payload) =>
|
socket.on("round:start", (payload) =>
|
||||||
useRoomStore.setState((s) => {
|
useRoomStore.setState((s) => {
|
||||||
|
|
@ -312,6 +321,8 @@ socket.on("round:start", (payload) =>
|
||||||
? "blindtest"
|
? "blindtest"
|
||||||
: fmt === "image_reveal"
|
: fmt === "image_reveal"
|
||||||
? "image"
|
? "image"
|
||||||
|
: fmt === "rebus"
|
||||||
|
? "rebus"
|
||||||
: "quiz"
|
: "quiz"
|
||||||
return {
|
return {
|
||||||
round: {
|
round: {
|
||||||
|
|
|
||||||
2
apps/web/src/types/globals.d.ts
vendored
Normal file
2
apps/web/src/types/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Version de l'app injectée par Vite (define), depuis apps/web/package.json.
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
1
apps/web/src/types/youtube.d.ts
vendored
1
apps/web/src/types/youtube.d.ts
vendored
|
|
@ -24,6 +24,7 @@ declare global {
|
||||||
getCurrentTime(): number
|
getCurrentTime(): number
|
||||||
getDuration(): number
|
getDuration(): number
|
||||||
getPlayerState(): number
|
getPlayerState(): number
|
||||||
|
setVolume(volume: number): void
|
||||||
loadVideoById(id: string): void
|
loadVideoById(id: string): void
|
||||||
destroy(): void
|
destroy(): void
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(path.resolve(__dirname, "package.json"), "utf-8")
|
||||||
|
) as { version: string }
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,12 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-nerdware}:${POSTGRES_PASSWORD:-nerdware}@postgres:5432/${POSTGRES_DB:-nerdware}
|
# Connexion via PG* (mot de passe passé tel quel, pas d'encodage d'URL).
|
||||||
|
PGHOST: postgres
|
||||||
|
PGPORT: 5432
|
||||||
|
PGUSER: ${POSTGRES_USER:-nerdware}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD:-nerdware}
|
||||||
|
PGDATABASE: ${POSTGRES_DB:-nerdware}
|
||||||
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||||
UPLOADS_DIR: /data/uploads
|
UPLOADS_DIR: /data/uploads
|
||||||
|
|
@ -49,6 +54,8 @@ services:
|
||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_SERVER_URL: ${VITE_SERVER_URL}
|
VITE_SERVER_URL: ${VITE_SERVER_URL}
|
||||||
|
VITE_UMAMI_SRC: ${VITE_UMAMI_SRC:-}
|
||||||
|
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "nerdware",
|
"name": "nerdware",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nerdware/shared",
|
"name": "@nerdware/shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
|
||||||
export type RoundType = "blindtest" | "quiz"
|
export type RoundType = "blindtest" | "quiz"
|
||||||
|
|
||||||
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
||||||
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
|
export type GameType = "mixed" | "quiz" | "image" | "rebus" | "blindtest"
|
||||||
|
|
||||||
/** Sous-modes activables quand la partie est "mixed". */
|
/** Sous-modes activables quand la partie est "mixed". */
|
||||||
export type MixMode = "quiz" | "image" | "blindtest"
|
export type MixMode = "quiz" | "image" | "rebus" | "blindtest"
|
||||||
|
|
||||||
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||||
|
|
@ -20,7 +20,12 @@ export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||||
export type BotDifficulty = "easy" | "normal" | "hard"
|
export type BotDifficulty = "easy" | "normal" | "hard"
|
||||||
|
|
||||||
/** Formats de question quiz. */
|
/** Formats de question quiz. */
|
||||||
export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal"
|
export type QuizFormat =
|
||||||
|
| "mcq"
|
||||||
|
| "truefalse"
|
||||||
|
| "free"
|
||||||
|
| "image_reveal"
|
||||||
|
| "rebus"
|
||||||
|
|
||||||
/** Joueur connecté à une room. */
|
/** Joueur connecté à une room. */
|
||||||
export interface Player {
|
export interface Player {
|
||||||
|
|
@ -34,8 +39,8 @@ export interface Player {
|
||||||
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
||||||
export interface RoundConfig {
|
export interface RoundConfig {
|
||||||
type: RoundType
|
type: RoundType
|
||||||
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique" ou images). */
|
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique", images ou rébus). */
|
||||||
pool?: "quiz" | "image"
|
pool?: "quiz" | "image" | "rebus"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ export interface RemoveTrackPayload {
|
||||||
trackId: string
|
trackId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Un titre soumis par le joueur lui-même (renvoyé en privé, source de vérité). */
|
||||||
|
export interface MyTrackInfo {
|
||||||
|
trackId: string
|
||||||
|
title: string
|
||||||
|
youtubeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyTracksPayload {
|
||||||
|
tracks: MyTrackInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoundStartPayload {
|
export interface RoundStartPayload {
|
||||||
type: RoundType
|
type: RoundType
|
||||||
djId?: string
|
djId?: string
|
||||||
|
|
@ -147,6 +158,7 @@ export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
|
||||||
|
|
||||||
/** CLIENT → SERVER */
|
/** CLIENT → SERVER */
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
|
"time:sync": (ack: (serverNow: number) => void) => void
|
||||||
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
|
|
@ -175,6 +187,7 @@ export interface ServerToClientEvents {
|
||||||
"player:left": (payload: PlayerRefPayload) => void
|
"player:left": (payload: PlayerRefPayload) => void
|
||||||
"player:rejoined": (payload: PlayerRefPayload) => void
|
"player:rejoined": (payload: PlayerRefPayload) => void
|
||||||
"blindtest:submitOk": (payload: SubmitOkPayload) => void
|
"blindtest:submitOk": (payload: SubmitOkPayload) => void
|
||||||
|
"blindtest:myTracks": (payload: MyTracksPayload) => void
|
||||||
"round:start": (payload: RoundStartPayload) => void
|
"round:start": (payload: RoundStartPayload) => void
|
||||||
"media:sync": (payload: MediaSyncPayload) => void
|
"media:sync": (payload: MediaSyncPayload) => void
|
||||||
"round:voteAck": (payload: VoteAckPayload) => void
|
"round:voteAck": (payload: VoteAckPayload) => void
|
||||||
|
|
|
||||||
94
packages/ui/src/components/dialog.tsx
Normal file
94
packages/ui/src/components/dialog.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@workspace/ui/lib/utils"
|
||||||
|
|
||||||
|
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger(
|
||||||
|
props: React.ComponentProps<typeof DialogPrimitive.Trigger>
|
||||||
|
) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border p-6 shadow-lg duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:outline-none">
|
||||||
|
<X className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("font-heading text-lg font-bold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue