feat: end-of-game track recap, no self-vote in who_added, festive home + icons
- game:end now carries the played blindtest tracks (title/artist/youtubeId/ url/submittedByName); the end screen lists them as cards with a YouTube open link so players can recover/export the songs - who_added vote no longer lets you pick yourself (filtered out) - home page: animated halo, floating geek emojis, springy gradient title, hover/tap on buttons (Framer Motion) + Lucide icons - lobby settings get Lucide icons (game type, quiz, blindtest mode, tracks, start) Verified e2e: game:end includes the track list with links. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1071a59b68
commit
3f0cab8f5f
8 changed files with 187 additions and 49 deletions
|
|
@ -72,7 +72,18 @@ export class GameEngine implements RoomGameController {
|
||||||
}
|
}
|
||||||
this.room.status = "ended"
|
this.room.status = "ended"
|
||||||
this.broadcastState()
|
this.broadcastState()
|
||||||
this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() })
|
// Récap des titres blindtest joués (pour récupérer les liens YouTube).
|
||||||
|
const tracks = this.room.blindtestTracks.map((t) => ({
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
youtubeId: t.youtubeId,
|
||||||
|
url: t.url,
|
||||||
|
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
|
||||||
|
}))
|
||||||
|
this.io.to(this.room.code).emit("game:end", {
|
||||||
|
finalScores: this.scoreboard(),
|
||||||
|
tracks: tracks.length > 0 ? tracks : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,9 @@ function VoteForm({
|
||||||
)}
|
)}
|
||||||
{needsWho && (
|
{needsWho && (
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{snapshot.players.map((p) => (
|
{snapshot.players
|
||||||
|
.filter((p) => p.id !== playerId)
|
||||||
|
.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => setGuessed(p.id)}
|
onClick={() => setGuessed(p.id)}
|
||||||
|
|
@ -307,9 +309,6 @@ function VoteForm({
|
||||||
>
|
>
|
||||||
<Avatar seed={p.name} className="size-5" />
|
<Avatar seed={p.name} className="size-5" />
|
||||||
{p.name}
|
{p.name}
|
||||||
{p.id === playerId && (
|
|
||||||
<span className="text-muted-foreground">(toi)</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from "wouter"
|
import { Link } from "wouter"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Crown } from "lucide-react"
|
import { Crown, ExternalLink, Music } from "lucide-react"
|
||||||
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
|
|
@ -112,6 +112,7 @@ function PodiumColumn({
|
||||||
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const finalScores = useRoomStore((s) => s.finalScores)
|
const finalScores = useRoomStore((s) => s.finalScores)
|
||||||
|
const gameTracks = useRoomStore((s) => s.gameTracks)
|
||||||
const reset = useRoomStore((s) => s.reset)
|
const reset = useRoomStore((s) => s.reset)
|
||||||
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
|
|
@ -196,6 +197,46 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{gameTracks && gameTracks.length > 0 && (
|
||||||
|
<section className="flex flex-col gap-2 text-left">
|
||||||
|
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Music className="size-4" /> Les musiques de la partie
|
||||||
|
</h3>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{gameTracks.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.youtubeId}
|
||||||
|
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="min-w-0 flex-1">
|
||||||
|
<span className="line-clamp-1 text-xs font-medium">
|
||||||
|
{t.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground line-clamp-1 text-[10px]">
|
||||||
|
ajouté par {t.submittedByName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={t.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Ouvrir sur YouTube"
|
||||||
|
>
|
||||||
|
<Button size="icon-sm" variant="secondary">
|
||||||
|
<ExternalLink />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{isHost ? (
|
{isHost ? (
|
||||||
<Button className="w-full" onClick={returnToLobby}>
|
<Button className="w-full" onClick={returnToLobby}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Trash2 } from "lucide-react"
|
import {
|
||||||
|
Brain,
|
||||||
|
Headphones,
|
||||||
|
ListMusic,
|
||||||
|
Music,
|
||||||
|
Play,
|
||||||
|
Shuffle,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
|
@ -12,10 +21,10 @@ import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
const QUESTION_OPTIONS = [3, 5, 10]
|
const QUESTION_OPTIONS = [3, 5, 10]
|
||||||
const TRACKS_OPTIONS = [1, 2, 3]
|
const TRACKS_OPTIONS = [1, 2, 3]
|
||||||
const GAME_TYPES: { value: GameType; label: string }[] = [
|
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
||||||
{ value: "mixed", label: "Mixte" },
|
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
||||||
{ value: "quiz", label: "Quiz" },
|
{ value: "quiz", label: "Quiz", Icon: Brain },
|
||||||
{ value: "blindtest", label: "Blindtest" },
|
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
|
||||||
]
|
]
|
||||||
const MODE_LABELS: Record<BlindtestMode, string> = {
|
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
title_artist: "Titre & artiste",
|
title_artist: "Titre & artiste",
|
||||||
|
|
@ -128,18 +137,18 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{GAME_TYPES.map((t) => {
|
{GAME_TYPES.map(({ value, label, Icon }) => {
|
||||||
const needsThree = t.value !== "quiz" && !blindtestAvailable
|
const needsThree = value !== "quiz" && !blindtestAvailable
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={t.value}
|
key={value}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
variant={effectiveType === t.value ? "default" : "secondary"}
|
variant={effectiveType === value ? "default" : "secondary"}
|
||||||
disabled={needsThree}
|
disabled={needsThree}
|
||||||
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
||||||
onClick={() => updateSettings({ gameType: t.value })}
|
onClick={() => updateSettings({ gameType: value })}
|
||||||
>
|
>
|
||||||
{t.label}
|
<Icon /> {label}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -154,7 +163,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
{isHost && showQuiz && (
|
{isHost && showQuiz && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Questions de quiz</span>
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Brain className="size-4" /> Questions de quiz
|
||||||
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{QUESTION_OPTIONS.map((n) => (
|
{QUESTION_OPTIONS.map((n) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -173,7 +184,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{isHost && showBlindtest && (
|
{isHost && showBlindtest && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-sm font-medium">Mode blindtest</span>
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Music className="size-4" /> Mode blindtest
|
||||||
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -189,7 +202,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Titres par joueur</span>
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<ListMusic className="size-4" /> Titres par joueur
|
||||||
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{TRACKS_OPTIONS.map((n) => (
|
{TRACKS_OPTIONS.map((n) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -212,7 +227,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
{isHost ? (
|
{isHost ? (
|
||||||
<Button disabled={busy || !canStart} onClick={start}>
|
<Button disabled={busy || !canStart} onClick={start}>
|
||||||
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { LogIn, Sparkles } from "lucide-react"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
|
|
||||||
|
// Emojis geek qui flottent en fond.
|
||||||
|
const FLOATERS = [
|
||||||
|
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
|
||||||
|
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
|
||||||
|
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
|
||||||
|
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
|
||||||
|
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
|
||||||
|
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
|
||||||
|
]
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [, navigate] = useLocation()
|
const [, navigate] = useLocation()
|
||||||
const createRoom = useRoomStore((s) => s.createRoom)
|
const createRoom = useRoomStore((s) => s.createRoom)
|
||||||
|
|
@ -43,20 +55,63 @@ export function HomePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center p-6">
|
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
||||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
{/* Halo animé */}
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
|
||||||
|
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
|
||||||
|
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
{/* Emojis flottants */}
|
||||||
|
{FLOATERS.map((f) => (
|
||||||
|
<motion.span
|
||||||
|
key={f.emoji}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
|
||||||
|
style={{ left: f.x, top: f.y }}
|
||||||
|
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
|
||||||
|
transition={{
|
||||||
|
duration: 5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: f.delay,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.emoji}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex w-full max-w-sm flex-col gap-6"
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
>
|
||||||
<header className="text-center">
|
<header className="text-center">
|
||||||
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
<motion.h1
|
||||||
|
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
|
||||||
|
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 14 }}
|
||||||
|
>
|
||||||
NerdWare
|
NerdWare
|
||||||
</h1>
|
</motion.h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
|
||||||
Party game culture geek
|
<Sparkles className="size-3.5" /> Party game culture geek
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Button disabled={!connected || busy} onClick={handleCreate}>
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
Créer une room
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!connected || busy}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
<Sparkles /> Créer une room
|
||||||
</Button>
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
|
|
@ -66,8 +121,8 @@ export function HomePage() {
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
className={`${inputClass} uppercase`}
|
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
|
||||||
placeholder="Code"
|
placeholder="CODE"
|
||||||
value={code}
|
value={code}
|
||||||
maxLength={4}
|
maxLength={4}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
|
@ -78,7 +133,7 @@ export function HomePage() {
|
||||||
disabled={!connected || busy || code.trim().length === 0}
|
disabled={!connected || busy || code.trim().length === 0}
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
>
|
>
|
||||||
Rejoindre
|
<LogIn /> Rejoindre
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -90,7 +145,7 @@ export function HomePage() {
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-destructive text-center text-sm">{error}</p>
|
<p className="text-destructive text-center text-sm">{error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { create } from "zustand"
|
||||||
import {
|
import {
|
||||||
DEFAULT_ROOM_SETTINGS,
|
DEFAULT_ROOM_SETTINGS,
|
||||||
type BlindtestAnswer,
|
type BlindtestAnswer,
|
||||||
|
type BlindtestTrackInfo,
|
||||||
type ErrorPayload,
|
type ErrorPayload,
|
||||||
type MediaControlPayload,
|
type MediaControlPayload,
|
||||||
type MediaSyncPayload,
|
type MediaSyncPayload,
|
||||||
|
|
@ -49,6 +50,7 @@ interface RoomState {
|
||||||
myChoiceIndex: number | null
|
myChoiceIndex: number | null
|
||||||
hasVoted: boolean
|
hasVoted: boolean
|
||||||
finalScores: PlayerScore[] | null
|
finalScores: PlayerScore[] | null
|
||||||
|
gameTracks: BlindtestTrackInfo[] | null
|
||||||
mediaSync: MediaSyncPayload | null
|
mediaSync: MediaSyncPayload | null
|
||||||
boomKey: number
|
boomKey: number
|
||||||
roundKey: number
|
roundKey: number
|
||||||
|
|
@ -85,6 +87,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
hasVoted: false,
|
hasVoted: false,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
gameTracks: null,
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
|
@ -213,6 +216,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
hasVoted: false,
|
hasVoted: false,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
gameTracks: null,
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
|
@ -234,6 +238,7 @@ socket.on("room:state", (snapshot) =>
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
hasVoted: false,
|
hasVoted: false,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
gameTracks: null,
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
}
|
}
|
||||||
: { snapshot }
|
: { snapshot }
|
||||||
|
|
@ -265,6 +270,6 @@ socket.on("round:voteAck", (voteProgress) =>
|
||||||
)
|
)
|
||||||
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
||||||
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
|
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
|
||||||
socket.on("game:end", ({ finalScores }) =>
|
socket.on("game:end", ({ finalScores, tracks }) =>
|
||||||
useRoomStore.setState({ finalScores })
|
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,12 @@ export interface BlindtestPlayerResult {
|
||||||
|
|
||||||
/** round:reveal → perPlayerResult : playerId → résultat. */
|
/** round:reveal → perPlayerResult : playerId → résultat. */
|
||||||
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
|
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
|
||||||
|
|
||||||
|
/** Titre récapitulé en fin de partie (export / récupération des liens). */
|
||||||
|
export interface BlindtestTrackInfo {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
youtubeId: string
|
||||||
|
url: string
|
||||||
|
submittedByName: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
RoundType,
|
RoundType,
|
||||||
} from "./domain"
|
} from "./domain"
|
||||||
|
import type { BlindtestTrackInfo } from "./blindtest"
|
||||||
|
|
||||||
// --- Payloads ------------------------------------------------------------
|
// --- Payloads ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -113,6 +114,8 @@ export interface ScoreUpdatePayload {
|
||||||
|
|
||||||
export interface GameEndPayload {
|
export interface GameEndPayload {
|
||||||
finalScores: PlayerScore[]
|
finalScores: PlayerScore[]
|
||||||
|
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
|
||||||
|
tracks?: BlindtestTrackInfo[]
|
||||||
spotifyExport?: unknown
|
spotifyExport?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue