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:
AyoubBenziza 2026-06-10 16:04:14 +02:00
parent 1071a59b68
commit 3f0cab8f5f
8 changed files with 187 additions and 49 deletions

View file

@ -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. */

View file

@ -295,23 +295,22 @@ 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
<button .filter((p) => p.id !== playerId)
key={p.id} .map((p) => (
onClick={() => setGuessed(p.id)} <button
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${ key={p.id}
guessed === p.id onClick={() => setGuessed(p.id)}
? "border-primary bg-primary/10" className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
: "border-input hover:bg-muted/60" guessed === p.id
}`} ? "border-primary bg-primary/10"
> : "border-input hover:bg-muted/60"
<Avatar seed={p.name} className="size-5" /> }`}
{p.name} >
{p.id === playerId && ( <Avatar seed={p.name} className="size-5" />
<span className="text-muted-foreground">(toi)</span> {p.name}
)} </button>
</button> ))}
))}
</div> </div>
)} )}
<Button disabled={!canSubmit} onClick={submit}> <Button disabled={!canSubmit} onClick={submit}>

View file

@ -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}>

View file

@ -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">

View file

@ -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
</Button> size="lg"
className="w-full"
disabled={!connected || busy}
onClick={handleCreate}
>
<Sparkles /> Créer une room
</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>
) )
} }

View file

@ -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 })
) )

View file

@ -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
}

View file

@ -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
} }