- end screen: wider container (max-w-4xl), podium/awards stay centered, and the two long sections (musiques + récap des manches) sit side-by-side on desktop - back-office: 2-column layout (form | questions list) on desktop, wider Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
586 lines
19 KiB
TypeScript
586 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from "react"
|
|
import { Link } from "wouter"
|
|
import { motion } from "framer-motion"
|
|
import { Check, ClipboardList, Crown, ListChecks, Music } from "lucide-react"
|
|
import { SiSpotify, SiYoutube } from "@icons-pack/react-simple-icons"
|
|
import type {
|
|
BlindtestTrackInfo,
|
|
PlayerScore,
|
|
RoomSnapshot,
|
|
RoundRecap,
|
|
} from "@nerdware/shared"
|
|
import { Button } from "@workspace/ui/components/button"
|
|
|
|
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
|
const recapImage = (url: string) =>
|
|
url.startsWith("http") ? url : `${SERVER_URL}${url}`
|
|
import { useRoomStore } from "@/store/room"
|
|
import { Avatar } from "@/components/avatar"
|
|
import { celebrate } from "@/lib/confetti"
|
|
import { playVictory } from "@/lib/sound"
|
|
|
|
/** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */
|
|
function spotifySearch(track: BlindtestTrackInfo): string {
|
|
const query = `${track.title} ${track.artist}`.trim()
|
|
return `https://open.spotify.com/search/${encodeURIComponent(query)}`
|
|
}
|
|
|
|
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 gameRecap = useRoomStore((s) => s.gameRecap)
|
|
const reset = useRoomStore((s) => s.reset)
|
|
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
|
const isHost = snapshot.hostId === playerId
|
|
|
|
// Confettis de victoire (une fois à l'arrivée sur l'écran de fin).
|
|
const celebrated = useRef(false)
|
|
useEffect(() => {
|
|
if (!celebrated.current) {
|
|
celebrated.current = true
|
|
celebrate()
|
|
playVictory()
|
|
}
|
|
}, [])
|
|
|
|
const [copied, setCopied] = useState(false)
|
|
async function copyResults() {
|
|
const lines = ["NerdWare — Résultats", "", "🏆 Classement"]
|
|
ranked.forEach((s, i) =>
|
|
lines.push(`${i + 1}. ${nameOf(s.playerId)} — ${s.score}`)
|
|
)
|
|
if (gameRecap && gameRecap.length > 0) {
|
|
lines.push("", "Manches")
|
|
gameRecap.forEach((r) =>
|
|
lines.push(
|
|
`${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}`
|
|
)
|
|
)
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(lines.join("\n"))
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
} catch {
|
|
// presse-papier indisponible : on ignore
|
|
}
|
|
}
|
|
|
|
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 flex-col items-center gap-6">
|
|
<div className="flex w-full max-w-xl 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>
|
|
)}
|
|
|
|
{gameRecap && gameRecap.length > 0 && (
|
|
<FunStats recap={gameRecap} snapshot={snapshot} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Détails (musiques + récap) côte à côte sur desktop. */}
|
|
{((gameTracks && gameTracks.length > 0) ||
|
|
(gameRecap && gameRecap.length > 0)) && (
|
|
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
|
|
{gameTracks && gameTracks.length > 0 && (
|
|
<TracksRecap tracks={gameTracks} />
|
|
)}
|
|
{gameRecap && gameRecap.length > 0 && (
|
|
<RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex w-full max-w-sm flex-col gap-2">
|
|
<Button variant="secondary" className="w-full" onClick={copyResults}>
|
|
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
|
|
{copied ? "Copié !" : "Copier les résultats"}
|
|
</Button>
|
|
{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>
|
|
)
|
|
}
|
|
|
|
function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
async function copyPlaylist() {
|
|
const text = tracks.map((t) => `${t.title} — ${t.artist}`).join("\n")
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
} catch {
|
|
// presse-papier indisponible : on ignore
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="flex flex-col gap-2 text-left">
|
|
<div className="flex items-center justify-between">
|
|
<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>
|
|
<Button size="sm" variant="secondary" onClick={copyPlaylist}>
|
|
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
|
|
{copied ? "Copié" : "Playlist"}
|
|
</Button>
|
|
</div>
|
|
<ul className="flex flex-col gap-2">
|
|
{tracks.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={spotifySearch(t)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title="Chercher sur Spotify"
|
|
>
|
|
<Button size="icon-sm" variant="secondary">
|
|
<SiSpotify color="default" />
|
|
</Button>
|
|
</a>
|
|
<a href={t.url} target="_blank" rel="noreferrer" title="Ouvrir sur YouTube">
|
|
<Button size="icon-sm" variant="secondary">
|
|
<SiYoutube color="default" />
|
|
</Button>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
interface PlayerStat {
|
|
correct: number
|
|
answered: number
|
|
decoy: number
|
|
speed: number
|
|
}
|
|
|
|
// Points de base d'une bonne réponse quiz/image ; le surplus = bonus de vitesse.
|
|
const QUIZ_BASE_POINTS = 100
|
|
|
|
function FunStats({
|
|
recap,
|
|
snapshot,
|
|
}: {
|
|
recap: RoundRecap[]
|
|
snapshot: RoomSnapshot
|
|
}) {
|
|
const stats = new Map<string, PlayerStat>(
|
|
snapshot.players.map((p) => [
|
|
p.id,
|
|
{ correct: 0, answered: 0, decoy: 0, speed: 0 },
|
|
])
|
|
)
|
|
for (const r of recap) {
|
|
const answered = new Set(r.answers.map((a) => a.playerId))
|
|
const pointsBy = new Map(r.scorers.map((sc) => [sc.playerId, sc.points]))
|
|
for (const a of r.answers) {
|
|
const s = stats.get(a.playerId)
|
|
if (s) {
|
|
s.answered++
|
|
if (a.correct) {
|
|
s.correct++
|
|
// Bonus de vitesse = points au-dessus de la base (quiz/images).
|
|
if (r.type !== "blindtest") {
|
|
s.speed += Math.max(0, (pointsBy.get(a.playerId) ?? 0) - QUIZ_BASE_POINTS)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (r.type === "blindtest") {
|
|
for (const sc of r.scorers) {
|
|
if (!answered.has(sc.playerId)) {
|
|
const s = stats.get(sc.playerId)
|
|
if (s) s.decoy += sc.points
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const players = snapshot.players
|
|
const total = recap.length
|
|
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?"
|
|
const stat = (id: string) =>
|
|
stats.get(id) ?? { correct: 0, answered: 0, decoy: 0, speed: 0 }
|
|
const winners = (pick: (s: PlayerStat) => number, min = 1) => {
|
|
const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
|
|
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
|
|
}
|
|
|
|
const awards: { emoji: string; label: string; ids: string[] }[] = []
|
|
const perfect = players.filter(
|
|
(p) => total > 0 && stat(p.id).correct === total
|
|
)
|
|
if (perfect.length) {
|
|
awards.push({ emoji: "🎯", label: "Sans-faute", ids: perfect.map((p) => p.id) })
|
|
}
|
|
const brains = winners((s) => s.correct)
|
|
if (brains.length && brains.length < players.length) {
|
|
awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) })
|
|
}
|
|
const fastest = winners((s) => s.speed)
|
|
if (fastest.length && fastest.length < players.length) {
|
|
awards.push({
|
|
emoji: "⚡",
|
|
label: "Le plus rapide",
|
|
ids: fastest.map((p) => p.id),
|
|
})
|
|
}
|
|
const decoy = winners((s) => s.decoy)
|
|
if (decoy.length) {
|
|
awards.push({
|
|
emoji: "🦹",
|
|
label: "Roi de la tromperie",
|
|
ids: decoy.map((p) => p.id),
|
|
})
|
|
}
|
|
const boulets = players.filter(
|
|
(p) => stat(p.id).answered > 0 && stat(p.id).correct === 0
|
|
)
|
|
if (boulets.length && total >= 3) {
|
|
awards.push({ emoji: "🪨", label: "Le boulet", ids: boulets.map((p) => p.id) })
|
|
}
|
|
|
|
if (awards.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<section className="flex flex-col gap-2">
|
|
<h3 className="text-muted-foreground text-sm font-medium">Récompenses</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{awards.map((a) => (
|
|
<div
|
|
key={a.label}
|
|
className="bg-muted/40 flex flex-1 flex-col items-center gap-1 rounded-xl border px-3 py-2 text-center"
|
|
>
|
|
<span className="text-2xl">{a.emoji}</span>
|
|
<span className="text-[10px] font-medium uppercase">{a.label}</span>
|
|
<div className="flex flex-wrap items-center justify-center gap-1">
|
|
{a.ids.map((id) => (
|
|
<span key={id} className="flex items-center gap-1 text-xs">
|
|
<Avatar seed={nameOf(id)} className="size-5" />
|
|
{nameOf(id)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function RoundsRecap({
|
|
recap,
|
|
nameOf,
|
|
playerId,
|
|
}: {
|
|
recap: RoundRecap[]
|
|
nameOf: (id: string) => string
|
|
playerId: string | null
|
|
}) {
|
|
return (
|
|
<details className="rounded-xl border text-left">
|
|
<summary className="text-muted-foreground flex cursor-pointer items-center gap-1.5 p-3 text-sm font-medium select-none">
|
|
<ListChecks className="size-4" /> Récapitulatif des manches ({recap.length})
|
|
</summary>
|
|
<ul className="flex flex-col gap-2 p-3 pt-0">
|
|
{recap.map((r) => {
|
|
const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points]))
|
|
const found = r.answers.filter((a) => a.correct).length
|
|
// Tri : plus de points d'abord, puis bonnes réponses.
|
|
const answers = [...r.answers].sort(
|
|
(a, b) =>
|
|
(pointsBy.get(b.playerId) ?? 0) - (pointsBy.get(a.playerId) ?? 0) ||
|
|
Number(b.correct) - Number(a.correct)
|
|
)
|
|
return (
|
|
<li key={r.index} className="bg-muted/40 flex gap-3 rounded-lg p-2">
|
|
{r.youtubeId ? (
|
|
<img
|
|
src={`https://img.youtube.com/vi/${r.youtubeId}/mqdefault.jpg`}
|
|
alt=""
|
|
className="h-12 w-16 shrink-0 rounded object-cover"
|
|
/>
|
|
) : r.imageUrl ? (
|
|
<img
|
|
src={recapImage(r.imageUrl)}
|
|
alt=""
|
|
className="h-12 w-16 shrink-0 rounded object-contain"
|
|
/>
|
|
) : null}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-muted-foreground text-[10px] uppercase">
|
|
{r.index + 1} ·{" "}
|
|
{r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")}
|
|
{" · "}
|
|
<span className="text-green-500">✓ {found}</span>
|
|
</p>
|
|
{r.type !== "blindtest" && (
|
|
<p className="line-clamp-1 text-xs">{r.label}</p>
|
|
)}
|
|
<p className="text-sm font-medium">
|
|
{r.answer}
|
|
{r.addedBy && (
|
|
<span className="text-muted-foreground font-normal">
|
|
{" "}
|
|
· {r.addedBy}
|
|
</span>
|
|
)}
|
|
</p>
|
|
<ul className="mt-1 flex flex-col gap-0.5">
|
|
{answers.length === 0 ? (
|
|
<li className="text-muted-foreground text-[10px]">
|
|
Aucune réponse
|
|
</li>
|
|
) : (
|
|
answers.map((a) => (
|
|
<li
|
|
key={a.playerId}
|
|
className="flex items-center gap-1 text-[10px]"
|
|
>
|
|
<Avatar seed={nameOf(a.playerId)} className="size-4" />
|
|
<span
|
|
className={
|
|
a.playerId === playerId
|
|
? "font-semibold"
|
|
: "text-muted-foreground"
|
|
}
|
|
>
|
|
{nameOf(a.playerId)}
|
|
{a.playerId === playerId && " (toi)"} :
|
|
</span>
|
|
<span
|
|
className={
|
|
a.correct ? "text-green-500" : "text-red-400"
|
|
}
|
|
>
|
|
{a.answer || "—"}
|
|
</span>
|
|
{pointsBy.get(a.playerId) ? (
|
|
<span className="text-muted-foreground">
|
|
+{pointsBy.get(a.playerId)}
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</details>
|
|
)
|
|
}
|