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>
590 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|