Compare commits

..

16 commits
v0.1.1 ... main

Author SHA1 Message Date
9f8b39435e Merge pull request 'release/0.1.4' (#26) from release/0.1.4 into main
Reviewed-on: #26
2026-07-06 12:54:38 +00:00
a510bfdb8c Merge branch 'main' into release/0.1.4 2026-07-06 12:53:26 +00:00
AyoubBenziza
541025f713 chore(release): 0.1.4
New Rebus game mode. See CHANGELOG.
2026-07-06 14:52:25 +02:00
69811e0667 Merge pull request 'feat: rebus game mode' (#25) from feature/rebus-mode into dev
Reviewed-on: #25
2026-07-06 12:48:22 +00:00
AyoubBenziza
1727b84644 feat: rebus game mode
New "Rébus" mode: guess the geek work from an emoji puzzle (free-text answer,
tolerant matching). Implemented as a quiz format `rebus` — reuses the whole quiz
machinery (pool, anti-repeat, speed scoring, recap, bots, back-office):

- shared: QuizFormat/GameType/MixMode + RoundConfig.pool gain "rebus"
- server: rebus is a text format; pool composes a rebus sub-pool (not filtered by
  category); ~9 seeded emoji rebus in the in-code bank; admin accepts the format
- client: lobby tile + sub-mode + count; big centered emoji display in the round;
  dedicated transition theme (🧩 RÉBUS); FR/EN strings; back-office form option

Verified e2e: a rebus game serves only rebus rounds with emoji prompts and the
recap shows the right answers. typecheck/lint/build/24 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:46:31 +02:00
AyoubBenziza
7d35df2dcc Merge branch 'release/0.1.3' into dev 2026-06-15 09:22:26 +02:00
bc266da74f Merge pull request 'release/0.1.3' (#24) from release/0.1.3 into main
Reviewed-on: #24
2026-06-15 07:21:28 +00:00
AyoubBenziza
aae09961fa chore(release): 0.1.3
"What's new" patch notes modal. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:20:44 +02:00
6692fad394 Merge pull request 'feat(web): "What's new" patch notes modal' (#23) from feature/patch-notes into dev
Reviewed-on: #23
2026-06-15 07:19:25 +00:00
AyoubBenziza
e0ba4c2e23 feat(web): "What's new" patch notes modal
- curated, bilingual player-facing notes (lib/patch-notes.ts) — a public subset
  of CHANGELOG.md (which stays the technical source of truth)
- "Nouveautés" button on home with a dot badge when the app version changed
  since last seen (localStorage); opening marks the version seen
- app version injected at build via Vite define (__APP_VERSION__)
- add shadcn Dialog component (@workspace/ui, via radix-ui)

Verified: notes + version baked into the build; typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:17:52 +02:00
AyoubBenziza
8d79a52a68 Merge branch 'release/0.1.2' into dev 2026-06-15 08:55:13 +02:00
c0375db9f2 Merge pull request 'release/0.1.2' (#22) from release/0.1.2 into main
Reviewed-on: #22
2026-06-15 06:53:40 +00:00
AyoubBenziza
ea476da01e chore(release): 0.1.2
Optional Umami analytics (env-driven). See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:51:41 +02:00
89ce5e2c0b Merge pull request 'feat(web): optional Umami analytics (env-driven)' (#21) from feature/umami-analytics into dev
Reviewed-on: #21
2026-06-15 06:49:33 +00:00
AyoubBenziza
1bda0e122e feat(web): optional Umami analytics (env-driven)
- inject the Umami script at runtime only when VITE_UMAMI_SRC + VITE_UMAMI_WEBSITE_ID
  are set (so dev/local traffic isn't tracked); website-id is public, not a secret
- web Dockerfile passes them as build args; docker-compose.prod.yml + env examples
  document them (optional, empty = disabled)

Verified: build bakes the values in when set; skipped when unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:40:35 +02:00
AyoubBenziza
0d79bf3fa6 Merge branch 'release/0.1.1' into dev 2026-06-12 12:37:26 +02:00
28 changed files with 499 additions and 33 deletions

View file

@ -4,6 +4,31 @@ 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.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.
@ -55,5 +80,8 @@ fin), trois modes, back-office et internationalisation.
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
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

View file

@ -1,6 +1,6 @@
{
"name": "@nerdware/server",
"version": "0.1.1",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {

View file

@ -10,7 +10,7 @@ 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
const FORMATS = ["mcq", "truefalse", "free", "image_reveal", "rebus"] as const
type AdminFormat = (typeof FORMATS)[number]
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
@ -56,7 +56,7 @@ function validate(
lang: body.lang === "en" ? "en" : "fr",
}
if (format === "free" || format === "image_reveal") {
if (format === "free" || format === "image_reveal" || format === "rebus") {
const answers = (body.acceptedAnswers ?? [])
.map((a) => a.trim())
.filter(Boolean)

View file

@ -17,6 +17,7 @@ const poolByRoom = new WeakMap<ServerRoom, QueueItem[]>()
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
const REBUS_FORMATS: QuizFormat[] = ["rebus"]
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
@ -32,7 +33,7 @@ async function loadGroup(
room: ServerRoom,
need: number,
formats: QuizFormat[],
isImage: boolean,
allowHardcoded: boolean,
categories: string[]
): Promise<QueueItem[]> {
if (need <= 0) {
@ -47,8 +48,8 @@ async function loadGroup(
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).
if (items.length < need && allowHardcoded) {
// Complément depuis la banque en dur (quiz + rébus ; pas d'images en dur).
const bank = shuffle(
QUIZ_QUESTIONS.filter(
(q) =>
@ -68,16 +69,24 @@ async function loadGroup(
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 needRebus = quizRounds.filter((r) => r.pool === "rebus").length
const needQuiz = quizRounds.length - needImage - needRebus
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)
const quizGroup = await loadGroup(room, needQuiz, QUIZ_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).
const queue: QueueItem[] = []
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()
if (item) {
queue.push(item)

View file

@ -154,4 +154,82 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
category: "Manga",
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,
},
]

View file

@ -44,7 +44,11 @@ function asText(answer: Answer): string | null {
/** Formats à réponse libre (saisie texte + matching tolérant). */
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. */

View file

@ -1 +1,5 @@
VITE_SERVER_URL=http://localhost:3001
# Umami analytics (optionnel, prod). Vide en dev = pas de suivi.
VITE_UMAMI_SRC=
VITE_UMAMI_WEBSITE_ID=

View file

@ -15,6 +15,11 @@ COPY apps/web apps/web
ARG 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
FROM nginx:1.27-alpine AS runner

View file

@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.1",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {

View file

@ -10,6 +10,7 @@ import {
Music,
Play,
Plus,
Puzzle,
Shuffle,
Trash2,
type LucideIcon,
@ -43,6 +44,7 @@ const GAME_TYPES: {
{ value: "mixed", Icon: Shuffle, needsThree: false },
{ value: "quiz", Icon: Brain, needsThree: false },
{ value: "image", Icon: ImageIcon, needsThree: false },
{ value: "rebus", Icon: Puzzle, needsThree: false },
{ value: "blindtest", Icon: Headphones, needsThree: true },
]
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
@ -110,6 +112,7 @@ function shuffle<T>(items: T[]): T[] {
function buildRounds(opts: {
quiz: number
image: number
rebus: number
tracks: number
shuffleOrder: boolean
}): RoundConfig[] {
@ -122,6 +125,10 @@ function buildRounds(opts: {
type: "quiz" 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 })),
]
return opts.shuffleOrder ? shuffle(rounds) : rounds
@ -154,6 +161,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const [quizCount, setQuizCount] = useState(5)
const [imageCount, setImageCount] = useState(5)
const [rebusCount, setRebusCount] = useState(5)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -179,6 +187,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
const showImage =
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
const showRebus =
effectiveType === "rebus" || (isMixed && mixedModes.includes("rebus"))
const showBlindtest =
effectiveType === "blindtest" ||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
@ -186,6 +196,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const rounds = buildRounds({
quiz: showQuiz ? quizCount : 0,
image: showImage ? imageCount : 0,
rebus: showRebus ? rebusCount : 0,
tracks: showBlindtest ? totalTracks : 0,
shuffleOrder: isMixed,
})
@ -295,7 +306,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{/* 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">
<div className="grid grid-cols-3 gap-2">
{GAME_TYPES.map(({ value, Icon, needsThree }) => {
const locked = needsThree && !blindtestAvailable
const active = effectiveType === value
@ -339,7 +350,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
<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) => {
{(["quiz", "image", "rebus", "blindtest"] as const).map((m) => {
const locked = m === "blindtest" && !blindtestAvailable
const on = mixedModes.includes(m) && !locked
const tile = (
@ -400,6 +411,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</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) */}
{(showQuiz || showImage) && allCategories.length > 0 && (
<div className="flex flex-col gap-1.5">

View file

@ -35,7 +35,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
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 isRebus = question.format === "rebus"
const isText = question.format === "free" || isImage || isRebus
const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined
@ -74,9 +75,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
)}
</header>
<h2 className="font-heading text-xl font-semibold text-balance">
{question.prompt}
</h2>
{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">
{question.prompt}
</h2>
)}
{isImage && question.imageUrl && (
<ImageReveal

View file

@ -4,7 +4,7 @@ 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"
export type TransitionKind = "quiz" | "image" | "rebus" | "blindtest"
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" }
) 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[] = []
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/rebus/")) MEDIA.rebus.push(url)
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
else SHARED_MEDIA.push(url)
}
@ -46,6 +52,13 @@ const THEME: Record<
kind: "Image",
emoji: "🖼️",
},
rebus: {
gradient: "from-amber-500 via-orange-600 to-rose-600",
accent: "text-yellow-200",
label: "RÉBUS",
kind: "Rébus",
emoji: "🧩",
},
blindtest: {
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
accent: "text-yellow-300",

View 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>
)
}

View file

@ -10,6 +10,10 @@ export const en: Dict = {
loading: "Loading…",
error: "Error",
},
whatsNew: {
button: "What's new",
title: "What's new",
},
home: {
tagline: "Geek culture party game",
create: "Create a room",
@ -56,6 +60,7 @@ export const en: Dict = {
quizCount: "Quiz questions",
imageCount: "Images to guess",
imageHint: "Requires “Image” questions created in the back-office.",
rebusCount: "Rebuses to guess",
categories: "Categories",
allCategories: "All",
botLevel: "🤖 Bot difficulty",
@ -79,11 +84,13 @@ export const en: Dict = {
mixed: "Mixed",
quiz: "Quiz",
image: "Images",
rebus: "Rebus",
blindtest: "Blindtest",
},
mixModes: {
quiz: "Quiz",
image: "Images",
rebus: "Rebus",
blindtest: "Blindtest",
},
blindtestModes: {
@ -138,6 +145,7 @@ export const en: Dict = {
kind: {
quiz: "Question",
image: "Image",
rebus: "Rebus",
blindtest: "Track",
},
ready: "READY ?!",

View file

@ -8,6 +8,10 @@ export const fr = {
loading: "Chargement…",
error: "Erreur",
},
whatsNew: {
button: "Nouveautés",
title: "Nouveautés",
},
home: {
tagline: "Party game culture geek",
create: "Créer une room",
@ -54,6 +58,7 @@ export const fr = {
quizCount: "Questions de quiz",
imageCount: "Images à deviner",
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
rebusCount: "Rébus à deviner",
categories: "Catégories",
allCategories: "Toutes",
botLevel: "🤖 Niveau des bots",
@ -77,11 +82,13 @@ export const fr = {
mixed: "Mixte",
quiz: "Quiz",
image: "Images",
rebus: "Rébus",
blindtest: "Blindtest",
},
mixModes: {
quiz: "Quiz",
image: "Images",
rebus: "Rébus",
blindtest: "Blindtest",
},
blindtestModes: {
@ -137,6 +144,7 @@ export const fr = {
kind: {
quiz: "Question",
image: "Image",
rebus: "Rébus",
blindtest: "Titre",
},
ready: "PRÊT ?!",

View 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)
}

View 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.",
],
},
]

View file

@ -6,6 +6,9 @@ import "@workspace/ui/globals.css"
import { App } from "./App.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import { I18nProvider } from "@/i18n/provider"
import { initAnalytics } from "@/lib/analytics"
initAnalytics()
const queryClient = new QueryClient()

View file

@ -180,7 +180,9 @@ function QuestionRow({
{q.lang.toUpperCase()} · {q.source}
{!q.active && " · désactivée"}
</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">
{q.acceptedAnswers?.join(", ")}
</p>
@ -258,7 +260,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
function submit() {
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
if (format === "free") {
if (format === "free" || format === "rebus") {
input.acceptedAnswers = answers
.split("\n")
.map((a) => a.trim())
@ -296,6 +298,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
<option value="truefalse">Vrai / Faux</option>
<option value="free">Réponse libre</option>
<option value="image_reveal">Image à deviner</option>
<option value="rebus">Rébus</option>
</select>
<select
className={inputClass}
@ -412,7 +415,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
</div>
)}
{(format === "free" || format === "image_reveal") && (
{(format === "free" || format === "image_reveal" || format === "rebus") && (
<textarea
className={`${inputClass} h-24 py-2`}
placeholder="Réponses acceptées (une par ligne)"

View file

@ -8,6 +8,7 @@ import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
import { LanguageToggle } from "@/i18n/language-toggle"
import { ThemeToggle } from "@/components/theme-toggle"
import { WhatsNew } from "@/components/whats-new"
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"
@ -62,6 +63,7 @@ export function HomePage() {
return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
<WhatsNew />
<ThemeToggle />
<LanguageToggle />
</div>

View file

@ -68,8 +68,8 @@ interface RoomState {
mediaSync: MediaSyncPayload | null
boomKey: number
roundKey: number
/** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
roundKind: "quiz" | "image" | "blindtest"
/** Catégorie visuelle de la manche (image_reveal / rébus distincts du quiz). */
roundKind: "quiz" | "image" | "rebus" | "blindtest"
/** Le mode a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean
@ -321,7 +321,9 @@ socket.on("round:start", (payload) =>
? "blindtest"
: fmt === "image_reveal"
? "image"
: "quiz"
: fmt === "rebus"
? "rebus"
: "quiz"
return {
round: {
type: payload.type,

2
apps/web/src/types/globals.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// Version de l'app injectée par Vite (define), depuis apps/web/package.json.
declare const __APP_VERSION__: string

View file

@ -1,11 +1,19 @@
import path from "path"
import { readFileSync } from "node:fs"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
const pkg = JSON.parse(
readFileSync(path.resolve(__dirname, "package.json"), "utf-8")
) as { version: string }
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View file

@ -54,6 +54,8 @@ services:
dockerfile: apps/web/Dockerfile
args:
VITE_SERVER_URL: ${VITE_SERVER_URL}
VITE_UMAMI_SRC: ${VITE_UMAMI_SRC:-}
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
restart: unless-stopped
depends_on:
- server

View file

@ -1,6 +1,6 @@
{
"name": "nerdware",
"version": "0.1.1",
"version": "0.1.4",
"private": true,
"scripts": {
"build": "turbo build",

View file

@ -1,6 +1,6 @@
{
"name": "@nerdware/shared",
"version": "0.1.1",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {

View file

@ -8,10 +8,10 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
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"
export type GameType = "mixed" | "quiz" | "image" | "rebus" | "blindtest"
/** 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. */
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"
/** 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. */
export interface Player {
@ -34,8 +39,8 @@ export interface Player {
/** 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"
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique", images ou rébus). */
pool?: "quiz" | "image" | "rebus"
}
/** Réglages de la room, modifiables dans le lobby par l'hôte. */

View 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,
}