nerdware/apps/web/src/components/game-end-view.tsx
AyoubBenziza 3f0cab8f5f 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>
2026-06-10 16:04:14 +02:00

258 lines
8 KiB
TypeScript

import { Link } from "wouter"
import { motion } from "framer-motion"
import { Crown, ExternalLink, Music } from "lucide-react"
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
type Place = 1 | 2 | 3
// Hauteur de marche, couleurs (or / argent / bronze) et taille d'avatar par place.
const PODIUM: Record<Place, {
bar: string
pedestal: string
text: string
ring: string
avatar: string
}> = {
1: {
bar: "h-24",
pedestal: "border-amber-400 bg-gradient-to-b from-amber-400/30 to-amber-400/5",
text: "text-amber-400",
ring: "ring-amber-400",
avatar: "size-20",
},
2: {
bar: "h-16",
pedestal: "border-zinc-300/70 bg-gradient-to-b from-zinc-300/20 to-zinc-300/5",
text: "text-zinc-300",
ring: "ring-zinc-300",
avatar: "size-16",
},
3: {
bar: "h-12",
pedestal: "border-amber-700/70 bg-gradient-to-b from-amber-700/25 to-amber-700/5",
text: "text-amber-600",
ring: "ring-amber-700",
avatar: "size-16",
},
}
function PodiumColumn({
entry,
place,
name,
isMe,
delay,
}: {
entry: PlayerScore
place: Place
name: string
isMe: boolean
delay: number
}) {
const cfg = PODIUM[place]
return (
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 22, delay }}
className="flex w-24 flex-col items-center gap-1"
>
<div className="relative">
{place === 1 && (
<motion.span
initial={{ y: -26, rotate: -25, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14, delay: delay + 0.25 }}
className="absolute -top-5 left-1/2 z-10 -translate-x-1/2"
>
<Crown className="size-8 fill-amber-400 text-amber-400 drop-shadow" />
</motion.span>
)}
<motion.div
animate={
place === 1
? {
boxShadow: [
"0 0 0px 0px rgba(251,191,36,0)",
"0 0 24px 4px rgba(251,191,36,0.5)",
"0 0 12px 2px rgba(251,191,36,0.25)",
],
}
: undefined
}
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse" }}
className="rounded-full"
>
<Avatar seed={name} className={`${cfg.avatar} ring-2 ${cfg.ring}`} />
</motion.div>
</div>
<span className="max-w-24 truncate text-sm font-medium">
{name}
{isMe && <span className="text-muted-foreground"> (toi)</span>}
</span>
<span className={`font-heading text-lg font-black tabular-nums ${cfg.text}`}>
{entry.score}
</span>
<div
className={`mt-1 flex w-full items-start justify-center rounded-t-lg border-x border-t-2 pt-1 ${cfg.bar} ${cfg.pedestal}`}
>
<span className={`font-heading text-2xl font-black ${cfg.text}`}>
{place}
</span>
</div>
</motion.div>
)
}
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks)
const reset = useRoomStore((s) => s.reset)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId
const scores: PlayerScore[] = finalScores ?? snapshot.scores
const ranked = [...scores].sort((a, b) => b.score - a.score)
const nameOf = (id: string) =>
snapshot.players.find((p) => p.id === id)?.name ?? "?"
const isMe = (id: string) => id === playerId
const [first, second, third] = ranked
const rest = ranked.slice(3)
return (
<div className="flex w-full max-w-md flex-col gap-6 text-center">
<header>
<p className="text-muted-foreground text-xs uppercase">
Partie terminée
</p>
<h2 className="font-heading text-2xl font-bold">
🏆 {first ? nameOf(first.playerId) : "?"} l'emporte !
</h2>
</header>
{/* Podium : 2e à gauche, champion au centre, 3e à droite. */}
<div className="mt-4 flex items-end justify-center gap-2">
{second && (
<PodiumColumn
entry={second}
place={2}
name={nameOf(second.playerId)}
isMe={isMe(second.playerId)}
delay={0.1}
/>
)}
{first && (
<PodiumColumn
entry={first}
place={1}
name={nameOf(first.playerId)}
isMe={isMe(first.playerId)}
delay={0.3}
/>
)}
{third && (
<PodiumColumn
entry={third}
place={3}
name={nameOf(third.playerId)}
isMe={isMe(third.playerId)}
delay={0}
/>
)}
</div>
{rest.length > 0 && (
<ol className="flex flex-col gap-1">
{rest.map((s, i) => (
<li
key={s.playerId}
className={`bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm ${
isMe(s.playerId) ? "ring-primary ring-2" : ""
}`}
>
<span className="flex items-center gap-2">
<span className="text-muted-foreground w-5 text-right tabular-nums">
{i + 4}
</span>
<Avatar seed={nameOf(s.playerId)} className="size-7" />
<span>
{nameOf(s.playerId)}
{isMe(s.playerId) && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
</span>
<span className="font-heading font-bold tabular-nums">
{s.score}
</span>
</li>
))}
</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">
{isHost ? (
<Button className="w-full" onClick={returnToLobby}>
Rejouer
</Button>
) : (
<p className="text-muted-foreground text-xs">
En attente que l'hôte relance une partie
</p>
)}
<Link href="/">
<Button variant="secondary" className="w-full" onClick={reset}>
Quitter
</Button>
</Link>
</div>
</div>
)
}