nerdware/apps/web/src/components/lobby-view.tsx
AyoubBenziza e3315d3b35 feat: mixed games by default, free-text quiz, mode-aware transitions
Mixed mode (default):
- gameType is now mixed | quiz | blindtest, default "mixed"; the lobby
  interleaves quiz + blindtest rounds so modes alternate
- lobby reworked: 3-way game-type switch, quiz count and/or blindtest config
  + track submission shown per selected type

Free-text quiz questions:
- quiz supports the `free` format (e.g. maths) alongside mcq/truefalse:
  payload without choices, {text} vote, tolerant matching on acceptedAnswers
- shared QuizQuestionPayload.choices optional; reveal truth carries answer
- match util moved to game/match.ts (shared by quiz + blindtest)
- repo/seed include free + acceptedAnswers; in-code bank gains free questions
- client quiz view renders a text input for free questions

Mode-aware transitions:
- RoundTransition themed per mode (colors/label/emoji); announces the mode
  on change (quiz <-> blindtest), lighter teaser within the same mode
- custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared
- store tracks roundModeChanged

Verified e2e: mixed game alternates quiz/blindtest to completion; free-text
scoring covered by tests (22 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:41:29 +02:00

272 lines
8.3 KiB
TypeScript

import { useState } from "react"
import type {
BlindtestMode,
GameType,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10]
const TRACKS_OPTIONS = [1, 2, 3]
const GAME_TYPES: { value: GameType; label: string }[] = [
{ value: "mixed", label: "Mixte" },
{ value: "quiz", label: "Quiz" },
{ value: "blindtest", label: "Blindtest" },
]
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"
/** Construit la séquence de manches selon le type de partie. */
function buildRounds(
gameType: GameType,
quizCount: number,
totalTracks: number
): RoundConfig[] {
if (gameType === "quiz") {
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
}
if (gameType === "blindtest") {
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
}
// Mixte : on alterne quiz / blindtest pour multiplier les changements de mode.
const rounds: RoundConfig[] = []
let q = quizCount
let b = totalTracks
while (q > 0 || b > 0) {
if (q > 0) {
rounds.push({ type: "quiz" })
q--
}
if (b > 0) {
rounds.push({ type: "blindtest" })
b--
}
}
return 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, blindtestMode, tracksPerPlayer } = snapshot.settings
const [count, setCount] = 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
const showQuiz = gameType === "quiz" || gameType === "mixed"
const showBlindtest = gameType === "blindtest" || gameType === "mixed"
const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
const canStart = rounds.length > 0
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="flex w-full max-w-md flex-col gap-6">
<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>
{isHost && (
<div className="flex gap-2">
{GAME_TYPES.map((t) => (
<Button
key={t.value}
className="flex-1"
variant={gameType === t.value ? "default" : "secondary"}
onClick={() => updateSettings({ gameType: t.value })}
>
{t.label}
</Button>
))}
</div>
)}
{isHost && showQuiz && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Questions de quiz</span>
<div className="flex gap-1">
{QUESTION_OPTIONS.map((n) => (
<Button
key={n}
size="sm"
variant={count === n ? "default" : "secondary"}
onClick={() => setCount(n)}
>
{n}
</Button>
))}
</div>
</div>
)}
{isHost && showBlindtest && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">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="text-sm font-medium">Titres par joueur</span>
<div className="flex gap-1">
{TRACKS_OPTIONS.map((n) => (
<Button
key={n}
size="sm"
variant={tracksPerPlayer === n ? "default" : "secondary"}
onClick={() => updateSettings({ tracksPerPlayer: n })}
>
{n}
</Button>
))}
</div>
</div>
</div>
)}
{showBlindtest && (
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
)}
{isHost ? (
<Button disabled={busy || !canStart} onClick={start}>
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
</Button>
) : (
<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>}
</div>
)
}
function TrackSubmission({
tracksPerPlayer,
myCount,
}: {
tracksPerPlayer: number
myCount: number
}) {
const submitTrack = useRoomStore((s) => s.submitTrack)
const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [accepted, setAccepted] = useState<string[]>([])
const quotaReached = myCount >= tracksPerPlayer
async function submit() {
if (!url.trim()) {
return
}
setSubmitting(true)
setFeedback(null)
const res = await submitTrack(url.trim())
if (res.accepted) {
setAccepted((a) => [...a, res.title ?? url.trim()])
setUrl("")
} else {
setFeedback(res.reason ?? "Refusé")
}
setSubmitting(false)
}
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Tes titres ({myCount}/{tracksPerPlayer})
</span>
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Lien YouTube"
value={url}
disabled={quotaReached || submitting}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Button
variant="secondary"
disabled={quotaReached || submitting || !url.trim()}
onClick={submit}
>
Ajouter
</Button>
</div>
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
{accepted.length > 0 && (
<ul className="text-muted-foreground flex flex-col gap-0.5 text-xs">
{accepted.map((t, i) => (
<li key={i} className="truncate">
{t}
</li>
))}
</ul>
)}
</div>
)
}