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 = { 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 = { 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 (
{ 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" />
) } function shuffle(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(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 (

Joueurs ({snapshot.players.length})

    {snapshot.players.map((p) => (
  • {p.name} {p.id === playerId && ( (toi) )} {p.id === snapshot.hostId ? "Hôte" : ""} {!p.connected && " · hors ligne"}
  • ))}
{isHost && (

Réglages de la partie

{/* Mode de jeu */}
Mode de jeu
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => { const locked = needsThree && !blindtestAvailable const active = effectiveType === value return ( ) })}
{!blindtestAvailable && (

Le blindtest se débloque à 3 joueurs.

)}
{/* Sous-modes du mixte (toggles) */} {isMixed && (
Sous-modes inclus
{(["quiz", "image", "blindtest"] as const).map((m) => { const locked = m === "blindtest" && !blindtestAvailable const on = mixedModes.includes(m) && !locked return ( ) })}
)} {/* Nombre de questions de quiz */} {showQuiz && (
Questions de quiz
)} {/* Nombre d'images */} {showImage && (
Images à deviner

Nécessite des questions « Image » créées dans le back-office.

)} {/* Filtre de catégories (quiz / images) */} {(showQuiz || showImage) && allCategories.length > 0 && (
Catégories
{allCategories.map((c) => ( ))}
)} {/* Réglages blindtest */} {showBlindtest && (
Mode blindtest
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( ))}
Titres par joueur updateSettings({ tracksPerPlayer: v })} />
)}
)} {showBlindtest && ( )} {isHost ? (
{showBlindtest && !allSubmitted && (

En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "} titre(s).

)}
) : (

En attente du lancement par l'hôte… {showBlindtest && ` (${totalTracks} titres soumis)`}

)} {error &&

{error}

} {history.length > 0 && (
Parties précédentes ({history.length})
    {history.map((g) => { const top = [...g.results].sort((a, b) => b.score - a.score) return (
  • {new Date(g.playedAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short", })} {" · "} {g.modes.join(", ")}

    🏆 {top[0]?.name ?? "?"} {" "} — {top.map((r) => `${r.name} ${r.score}`).join(" · ")}

  • ) })}
)}
) } 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(null) const [tracks, setTracks] = useState([]) 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 (
Tes titres ({myCount}/{tracksPerPlayer}) {tracks.length > 0 && (
    {tracks.map((t) => (
  • {t.title}
  • ))}
)} {!quotaReached && (
setUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} />
)} {feedback &&

{feedback}

}
) }