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>
292 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|