nerdware/apps/web/src/components/lobby-view.tsx
AyoubBenziza a2dc687ac0 fix(web): lobby falls back to quiz UI below 3 players
Default game type stays "mixed", but while fewer than 3 players are connected
the lobby renders the quiz config only (Quiz highlighted, Mixed/Blindtest
disabled) instead of showing blindtest settings to a lone host. It switches
back to the stored type automatically once 3 players are present — no manual
click needed.

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

292 lines
9.5 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"
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 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 : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
const rounds: RoundConfig[] = [
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
]
return shuffle(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
// 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
// Tant que le blindtest est indisponible, on retombe sur du quiz (sans
// toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs.
const effectiveType: GameType = blindtestAvailable ? gameType : "quiz"
const showQuiz = effectiveType === "quiz" || effectiveType === "mixed"
const showBlindtest =
effectiveType === "blindtest" || effectiveType === "mixed"
const rounds = buildRounds(effectiveType, 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 flex-col gap-1">
<div className="flex gap-2">
{GAME_TYPES.map((t) => {
const needsThree = t.value !== "quiz" && !blindtestAvailable
return (
<Button
key={t.value}
className="flex-1"
variant={effectiveType === t.value ? "default" : "secondary"}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: t.value })}
>
{t.label}
</Button>
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-center text-xs">
Le blindtest se débloque à 3 joueurs.
</p>
)}
</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>
)
}