nerdware/apps/web/src/components/lobby-view.tsx
AyoubBenziza b3efa27287 feat: game history + question lang/active management
DB:
- quiz_question gains `lang` ('fr' default) and `active` (true default); migration 0002
- only active questions are served to games (pool filter)

History:
- engine writes a game_history row at game end (modes + ranked results), fire-and-forget
- public GET /api/history/:code lists a room's recent games
- lobby shows a "Parties précédentes" collapsible (date, modes, winner/scores)

Back-office:
- create form has a FR/EN language select; lang stored
- per-question active toggle (PATCH /questions/:id) — disable without deleting;
  inactive rows dimmed and labelled
- seeds set lang (opentdb=en, manual/pokemon=fr)

Verified e2e: migration applied; game played → /api/history returns it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:12:07 +02:00

590 lines
19 KiB
TypeScript

import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import {
Brain,
Filter,
Headphones,
Image as ImageIcon,
ListMusic,
Minus,
Music,
Play,
Plus,
Shuffle,
Trash2,
type LucideIcon,
} from "lucide-react"
import { SiYoutube } from "@icons-pack/react-simple-icons"
import { fetchCategories } from "@/lib/categories"
import { fetchHistory } from "@/lib/history"
import type {
BlindtestMode,
GameType,
MixMode,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
const MIX_LABELS: Record<MixMode, string> = {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
}
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
const MAX_TRACKS = 10
const GAME_TYPES: {
value: GameType
label: string
Icon: LucideIcon
needsThree: boolean
}[] = [
{ value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: false },
{ value: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
{ value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
]
const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
mixed: "Mixte",
}
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
function Stepper({
value,
min = 1,
max = MAX_TRACKS,
onChange,
}: {
value: number
min?: number
max?: number
onChange: (v: number) => void
}) {
const clamp = (v: number) => Math.max(min, Math.min(max, v))
return (
<div className="flex items-center gap-1">
<Button
size="icon-sm"
variant="secondary"
disabled={value <= min}
onClick={() => onChange(clamp(value - 1))}
>
<Minus />
</Button>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => {
const n = parseInt(e.target.value, 10)
if (!Number.isNaN(n)) {
onChange(clamp(n))
}
}}
className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<Button
size="icon-sm"
variant="secondary"
disabled={value >= max}
onClick={() => onChange(clamp(value + 1))}
>
<Plus />
</Button>
</div>
)
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Construit la séquence de manches (chaque manche quiz porte son sous-pool). */
function buildRounds(opts: {
quiz: number
image: number
tracks: number
shuffleOrder: boolean
}): RoundConfig[] {
const rounds: RoundConfig[] = [
...Array.from({ length: opts.quiz }, () => ({
type: "quiz" as const,
pool: "quiz" as const,
})),
...Array.from({ length: opts.image }, () => ({
type: "quiz" as const,
pool: "image" as const,
})),
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
]
return opts.shuffleOrder ? shuffle(rounds) : rounds
}
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const updateSettings = useRoomStore((s) => s.updateSettings)
const startGame = useRoomStore((s) => s.startGame)
const isHost = snapshot.hostId === playerId
const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } =
snapshot.settings
const allCategories =
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
const history =
useQuery({
queryKey: ["history", snapshot.code],
queryFn: () => fetchHistory(snapshot.code),
}).data ?? []
const [quizCount, setQuizCount] = useState(5)
const [imageCount, setImageCount] = useState(5)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
const myCount =
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
const connectedCount = snapshot.players.filter((p) => p.connected).length
const blindtestAvailable = connectedCount >= 3
// Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
const effectiveType: GameType =
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
const isMixed = effectiveType === "mixed"
// Sous-modes effectivement actifs (un réglage par sous-mode).
const showQuiz =
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
const showImage =
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
const showBlindtest =
effectiveType === "blindtest" ||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
const rounds = buildRounds({
quiz: showQuiz ? quizCount : 0,
image: showImage ? imageCount : 0,
tracks: showBlindtest ? totalTracks : 0,
shuffleOrder: isMixed,
})
// Blindtest : tout le monde doit avoir soumis son quota de titres.
const allSubmitted =
!showBlindtest ||
(snapshot.submissions.length > 0 &&
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
const canStart = rounds.length > 0 && allSubmitted
function toggleMix(mode: MixMode) {
const set = new Set(mixedModes)
if (set.has(mode)) {
set.delete(mode)
} else {
set.add(mode)
}
updateSettings({ mixedModes: [...set] })
}
function toggleCategory(name: string) {
const set = new Set(categories)
if (set.has(name)) {
set.delete(name)
} else {
set.add(name)
}
updateSettings({ categories: [...set] })
}
async function start() {
setError(null)
setBusy(true)
try {
await startGame(rounds)
} catch (err) {
setError((err as { message?: string }).message ?? "Erreur")
} finally {
setBusy(false)
}
}
return (
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
Joueurs ({snapshot.players.length})
</h2>
<ul className="flex flex-col gap-1">
{snapshot.players.map((p) => (
<li
key={p.id}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span
className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`}
>
<Avatar seed={p.name} className="size-11" />
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{p.id === snapshot.hostId ? "Hôte" : ""}
{!p.connected && " · hors ligne"}
</span>
</li>
))}
</ul>
</section>
<div className="flex flex-col gap-6">
{isHost && (
<section className="flex flex-col gap-4 rounded-xl border p-4">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Réglages de la partie
</h2>
{/* Mode de jeu */}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Mode de jeu</span>
<div className="grid grid-cols-4 gap-2">
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
const locked = needsThree && !blindtestAvailable
const active = effectiveType === value
return (
<button
key={value}
disabled={locked}
title={locked ? "3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: value })}
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
active
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
<Icon className="size-5" />
{label}
</button>
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-xs">
Le blindtest se débloque à 3 joueurs.
</p>
)}
</div>
{/* Sous-modes du mixte (toggles) */}
{isMixed && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Sous-modes inclus</span>
<div className="flex gap-2">
{(["quiz", "image", "blindtest"] as const).map((m) => {
const locked = m === "blindtest" && !blindtestAvailable
const on = mixedModes.includes(m) && !locked
return (
<button
key={m}
disabled={locked}
title={locked ? "3 joueurs minimum" : undefined}
onClick={() => toggleMix(m)}
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
on
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{MIX_LABELS[m]}
</button>
)
})}
</div>
</div>
)}
{/* Nombre de questions de quiz */}
{showQuiz && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="size-4" /> Questions de quiz
</span>
<Stepper value={quizCount} min={1} max={20} onChange={setQuizCount} />
</div>
)}
{/* Nombre d'images */}
{showImage && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<ImageIcon className="size-4" /> Images à deviner
</span>
<Stepper
value={imageCount}
min={1}
max={20}
onChange={setImageCount}
/>
</div>
<p className="text-muted-foreground text-xs">
Nécessite des questions « Image » créées dans le back-office.
</p>
</div>
)}
{/* Filtre de catégories (quiz / images) */}
{(showQuiz || showImage) && allCategories.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Filter className="size-4" /> Catégories
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => updateSettings({ categories: [] })}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
categories.length === 0
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
Toutes
</button>
{allCategories.map((c) => (
<button
key={c}
onClick={() => toggleCategory(c)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
categories.includes(c)
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
{c}
</button>
))}
</div>
</div>
)}
{/* Réglages blindtest */}
{showBlindtest && (
<div className="flex flex-col gap-3 border-t pt-3">
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> Mode blindtest
</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
key={m}
size="sm"
className="flex-1"
variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => updateSettings({ blindtestMode: m })}
>
{MODE_LABELS[m]}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
<ListMusic className="size-4" /> Titres par joueur
</span>
<Stepper
value={tracksPerPlayer}
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
/>
</div>
</div>
)}
</section>
)}
{showBlindtest && (
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
)}
{isHost ? (
<div className="flex flex-col gap-1">
<Button disabled={busy || !canStart} onClick={start}>
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
</Button>
{showBlindtest && !allSubmitted && (
<p className="text-muted-foreground text-center text-xs">
En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "}
titre(s).
</p>
)}
</div>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte
{showBlindtest && ` (${totalTracks} titres soumis)`}
</p>
)}
{error && <p className="text-destructive text-center text-sm">{error}</p>}
{history.length > 0 && (
<details className="rounded-xl border text-left">
<summary className="text-muted-foreground cursor-pointer p-3 text-sm font-medium select-none">
Parties précédentes ({history.length})
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{history.map((g) => {
const top = [...g.results].sort((a, b) => b.score - a.score)
return (
<li key={g.id} className="bg-muted/40 rounded-lg p-2 text-xs">
<p className="text-muted-foreground text-[10px] uppercase">
{new Date(g.playedAt).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
})}
{" · "}
{g.modes.join(", ")}
</p>
<p>
🏆 {top[0]?.name ?? "?"}
<span className="text-muted-foreground">
{" "}
{top.map((r) => `${r.name} ${r.score}`).join(" · ")}
</span>
</p>
</li>
)
})}
</ul>
</details>
)}
</div>
</div>
)
}
interface MyTrack {
trackId: string
title: string
youtubeId: string
}
function TrackSubmission({
tracksPerPlayer,
myCount,
}: {
tracksPerPlayer: number
myCount: number
}) {
const submitTrack = useRoomStore((s) => s.submitTrack)
const removeTrack = useRoomStore((s) => s.removeTrack)
const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [tracks, setTracks] = useState<MyTrack[]>([])
const quotaReached = myCount >= tracksPerPlayer
async function submit() {
if (!url.trim()) {
return
}
setSubmitting(true)
setFeedback(null)
const res = await submitTrack(url.trim())
if (res.accepted && res.trackId && res.youtubeId) {
setTracks((t) => [
...t,
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
])
setUrl("")
} else {
setFeedback(res.reason ?? "Refusé")
}
setSubmitting(false)
}
async function remove(trackId: string) {
const ok = await removeTrack(trackId)
if (ok) {
setTracks((t) => t.filter((x) => x.trackId !== trackId))
}
}
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Tes titres ({myCount}/{tracksPerPlayer})
</span>
{tracks.length > 0 && (
<ul className="flex flex-col gap-2">
{tracks.map((t) => (
<li
key={t.trackId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="line-clamp-2 flex-1 text-xs">{t.title}</span>
<a
href={`https://www.youtube.com/watch?v=${t.youtubeId}`}
target="_blank"
rel="noreferrer"
title="Ouvrir sur YouTube"
>
<Button size="icon-sm" variant="secondary">
<SiYoutube color="default" />
</Button>
</a>
<Button
size="icon-sm"
variant="secondary"
onClick={() => remove(t.trackId)}
title="Supprimer"
>
<Trash2 />
</Button>
</li>
))}
</ul>
)}
{!quotaReached && (
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Lien YouTube"
value={url}
disabled={submitting}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Button
variant="secondary"
disabled={submitting || !url.trim()}
onClick={submit}
>
<SiYoutube color="default" /> Ajouter
</Button>
</div>
)}
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
</div>
)
}