- lightweight, dependency-free i18n: typed fr/en dictionaries (fr is the source of truth, en must match its shape → compile error on drift), I18nProvider + useI18n hook, browser detection + localStorage persistence - FR/EN language switcher (home + room header) - translated the whole player-facing flow: home, join, pseudo, room shell, lobby (incl. settings, categories, bots, track submission, history), quiz, blindtest, player-cards, round transitions, end screen (podium, awards, tracks, rounds recap), and server-error messages (mapped by code) - history dates localized to the active language Content (questions/categories) intentionally left as-is. Back-office (admin) not translated yet — internal, token-gated tool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
4.5 KiB
TypeScript
127 lines
4.5 KiB
TypeScript
import { useEffect, useRef } from "react"
|
|
import { AnimatePresence, motion } from "framer-motion"
|
|
import { Check, Crown } from "lucide-react"
|
|
import type { RoomSnapshot } from "@nerdware/shared"
|
|
import { Avatar } from "@/components/avatar"
|
|
import { celebrateLead } from "@/lib/confetti"
|
|
import { useI18n } from "@/i18n/context"
|
|
|
|
interface PlayerCardsProps {
|
|
snapshot: RoomSnapshot
|
|
playerId: string | null
|
|
/** Affiche l'état de vote (manche en cours uniquement). */
|
|
showVoteStatus?: boolean
|
|
/** playerIds ayant déjà validé leur réponse. */
|
|
votedIds?: string[]
|
|
}
|
|
|
|
/** HUD persistant : une card par joueur avec son score, couronne au leader. */
|
|
export function PlayerCards({
|
|
snapshot,
|
|
playerId,
|
|
showVoteStatus = false,
|
|
votedIds = [],
|
|
}: PlayerCardsProps) {
|
|
const { t } = useI18n()
|
|
const scoreOf = (id: string) =>
|
|
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
|
|
|
|
const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id)))
|
|
const ranked = [...snapshot.players].sort(
|
|
(a, b) => scoreOf(b.id) - scoreOf(a.id)
|
|
)
|
|
const voted = new Set(votedIds)
|
|
const leaderId = max > 0 ? ranked[0].id : null
|
|
|
|
// Confettis quand un joueur DÉPASSE pour prendre la tête (pas à la 1re prise).
|
|
const prevLeader = useRef<string | null>(null)
|
|
useEffect(() => {
|
|
if (leaderId && leaderId !== prevLeader.current) {
|
|
if (prevLeader.current !== null) {
|
|
celebrateLead()
|
|
}
|
|
prevLeader.current = leaderId
|
|
}
|
|
}, [leaderId])
|
|
|
|
return (
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
<AnimatePresence initial={false}>
|
|
{ranked.map((p) => {
|
|
const score = scoreOf(p.id)
|
|
const isLeader = max > 0 && score === max
|
|
const isMe = p.id === playerId
|
|
const hasVoted = voted.has(p.id)
|
|
return (
|
|
<motion.div
|
|
key={p.id}
|
|
layout
|
|
transition={{ type: "spring", stiffness: 500, damping: 40 }}
|
|
className={`relative flex min-w-24 flex-col items-center gap-1 rounded-xl border px-3 py-2.5 ${
|
|
isLeader
|
|
? "border-amber-400/60 bg-amber-400/10"
|
|
: "border-input bg-muted/40"
|
|
} ${isMe ? "ring-primary ring-2" : ""} ${
|
|
p.connected ? "" : "opacity-40"
|
|
}`}
|
|
>
|
|
{isLeader && (
|
|
<motion.span
|
|
initial={{ scale: 0, rotate: -30 }}
|
|
animate={{ scale: 1, rotate: 0 }}
|
|
transition={{ type: "spring", stiffness: 600, damping: 20 }}
|
|
className="absolute -top-2.5 text-amber-400"
|
|
>
|
|
<Crown className="size-4 fill-amber-400" />
|
|
</motion.span>
|
|
)}
|
|
<Avatar
|
|
seed={p.name}
|
|
className={`size-14 ${isLeader ? "ring-2 ring-amber-400" : ""}`}
|
|
/>
|
|
<span className="max-w-24 truncate text-xs font-medium">
|
|
{p.name}
|
|
{isMe && (
|
|
<span className="text-muted-foreground"> {t.common.you}</span>
|
|
)}
|
|
</span>
|
|
|
|
<motion.span
|
|
key={score}
|
|
initial={{ scale: 1.4 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: "spring", stiffness: 500, damping: 25 }}
|
|
className="font-heading text-lg font-bold tabular-nums"
|
|
>
|
|
{score}
|
|
</motion.span>
|
|
|
|
{showVoteStatus &&
|
|
(hasVoted ? (
|
|
<motion.span
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: "spring", stiffness: 600, damping: 18 }}
|
|
className="flex items-center gap-0.5 text-[10px] font-medium text-green-500"
|
|
>
|
|
<Check className="size-3" /> {t.playerCards.ready}
|
|
</motion.span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-[10px]">
|
|
<motion.span
|
|
animate={{ opacity: [0.3, 1, 0.3] }}
|
|
transition={{ duration: 1.1, repeat: Infinity }}
|
|
className="bg-muted-foreground size-1.5 rounded-full"
|
|
/>
|
|
<span className="text-muted-foreground">
|
|
{t.playerCards.waiting}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|