"use client"; import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface LeaderboardEntry { id: string; name: string; detail?: string; score: number; previousRank?: number; } export interface LeaderboardProps { title?: string; caption?: string; entries: LeaderboardEntry[]; youId?: string; visible?: number; format?: Intl.NumberFormatOptions; locale?: string; action?: ReactNode; className?: string; } const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const AVATAR_TONES = [ "bg-violet-500/15 text-violet-700 dark:text-violet-300", "bg-sky-500/15 text-sky-700 dark:text-sky-300", "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", "bg-amber-500/15 text-amber-800 dark:text-amber-300", "bg-rose-500/15 text-rose-700 dark:text-rose-300", "bg-teal-500/15 text-teal-700 dark:text-teal-300", "bg-indigo-500/15 text-indigo-700 dark:text-indigo-300", ]; const PODIUM = [ "bg-amber-400/20 text-amber-700 dark:text-amber-300", "bg-neutral-400/20 text-neutral-600 dark:text-neutral-300", "bg-orange-500/15 text-orange-700 dark:text-orange-300", ]; function hash(s: string) { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0; return Math.abs(h); } function RollingNumber({ text, className }: { text: string; className?: string }) { const reduce = useReducedMotion(); let d = 0; let o = 0; const glyphs = [...text] .reverse() .map((ch) => { const isDigit = ch >= "0" && ch <= "9"; return { ch, key: isDigit ? `d${d++}` : `s${o++}${ch}`, digit: isDigit ? Number(ch) : null }; }) .reverse(); return ( {text} {glyphs.map((g) => g.digit === null ? ( {g.ch} ) : ( 0 {DIGITS.map((n) => ( {n} ))} ), )} ); } function Movement({ rank, previous }: { rank: number; previous?: number }) { const diff = previous === undefined ? 0 : previous - rank; return ( 0 ? "Up" : "Down"} ${Math.abs(diff)}`} role="img" className={cn( "inline-flex w-8 items-center gap-px text-[11px] tabular-nums @max-[24rem]:w-auto @max-[24rem]:text-[10px] @max-[24rem]:leading-3", diff > 0 && "text-success-foreground", diff < 0 && "text-destructive-foreground", diff === 0 && "text-muted-foreground/60", )} > {diff > 0 ? : diff < 0 ? : } {diff !== 0 ? Math.abs(diff) : null} ); } function Row({ e, rank, isYou, gain, pinned = false, ahead, fmt, compact, }: { e: LeaderboardEntry; rank: number; isYou: boolean; gain?: { amount: number; key: number }; pinned?: boolean; ahead?: LeaderboardEntry; fmt: Intl.NumberFormat; compact: Intl.NumberFormat; }) { const reduce = useReducedMotion(); const layoutSpring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 38 }; return ( {rank} {e.name .split(" ") .map((p) => p[0]) .slice(0, 2) .join("")} {e.name} {isYou ? ( You ) : null} {pinned && ahead ? ( {compact.format(ahead.score - e.score)} behind {ahead.name.split(" ")[0]} ) : e.detail ? ( {e.detail} ) : null} {gain ? ( +{compact.format(gain.amount)} ) : null} ); } export function Leaderboard({ title = "Leaderboard", caption, entries, youId, visible = 5, format = { maximumFractionDigits: 0 }, locale = "en-US", action, className, }: LeaderboardProps) { const reduce = useReducedMotion(); const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]); const compact = useMemo( () => new Intl.NumberFormat(locale, { ...format, notation: "compact", maximumFractionDigits: 1 }), [locale, format], ); const ranked = useMemo(() => [...entries].sort((a, b) => b.score - a.score), [entries]); const rankOf = (id: string) => ranked.findIndex((e) => e.id === id) + 1; const prevScores = useRef>(new Map()); const [gains, setGains] = useState>({}); useEffect(() => { const next: Record = {}; for (const e of entries) { const before = prevScores.current.get(e.id); if (before !== undefined && e.score > before) next[e.id] = { amount: e.score - before, key: Date.now() }; prevScores.current.set(e.id, e.score); } if (Object.keys(next).length === 0) return; setGains((g) => ({ ...g, ...next })); const timer = setTimeout(() => { setGains((g) => { const copy = { ...g }; for (const id of Object.keys(next)) if (copy[id]?.key === next[id].key) delete copy[id]; return copy; }); }, 1400); return () => clearTimeout(timer); }, [entries]); const top = ranked.slice(0, visible); const you = youId ? ranked.find((e) => e.id === youId) : undefined; const youRank = you ? rankOf(you.id) : 0; const youPinned = you && youRank > visible; const ahead = you && youRank > 1 ? ranked[youRank - 2] : undefined; const behind = you ? ranked[youRank] : undefined; return (

{title}

{caption ? {caption} : null}
{action}
    {top.map((e) => ( ))}
{youPinned && you ? (
) : ( {you ? youRank === 1 ? behind ? `You're on top, ${compact.format(you.score - behind.score)} clear of ${behind.name.split(" ")[0]}` : "You're on top" : `You're #${youRank} ยท ${compact.format((ahead?.score ?? 0) - you.score)} to pass ${ahead?.name.split(" ")[0]}` : `${entries.length} people ranked`} )}
); }