From 5c40249a1845460baebc51700a299b922cda0cf4 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 5 Apr 2026 16:02:43 +0200 Subject: [PATCH] feat(panel): implement player leaderboards page Replaces the placeholder with a real leaderboard view showing top 10 players by level, wealth, and net worth. Reuses the existing /api/stats endpoint which players already have access to. Co-Authored-By: Claude Opus 4.6 (1M context) --- panel/src/App.tsx | 7 +- panel/src/pages/Leaderboards.tsx | 142 +++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 panel/src/pages/Leaderboards.tsx diff --git a/panel/src/App.tsx b/panel/src/App.tsx index b9d7546..20a9657 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -7,6 +7,7 @@ import Settings from "./pages/Settings"; import Users from "./pages/Users"; import Items from "./pages/Items"; import PlaceholderPage from "./pages/PlaceholderPage"; +import Leaderboards from "./pages/Leaderboards"; import NotEnrolled from "./pages/NotEnrolled"; import PlayerDashboard from "./pages/PlayerDashboard"; import { GameLobby } from "./games/GameLobby"; @@ -33,10 +34,6 @@ const placeholders: Record = { title: "Transactions", description: "Browse the economy transaction log with filtering by user, type, and date.", }, - leaderboards: { - title: "Leaderboards", - description: "View top players by level, wealth, and net worth.", - }, }; function AppRoutes() { @@ -78,7 +75,7 @@ function AppRoutes() { {/* Player routes */} } /> - } /> + } /> {/* Game routes (both roles) */} } /> diff --git a/panel/src/pages/Leaderboards.tsx b/panel/src/pages/Leaderboards.tsx new file mode 100644 index 0000000..fa646f1 --- /dev/null +++ b/panel/src/pages/Leaderboards.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect } from "react"; +import { Trophy, Crown, Gem, Loader2, AlertTriangle } from "lucide-react"; +import { get } from "../lib/api"; +import { cn } from "../lib/utils"; + +interface LeaderboardData { + topLevels: Array<{ username: string; level: number }>; + topWealth: Array<{ username: string; balance: string }>; + topNetWorth: Array<{ username: string; netWorth: string }>; +} + +function formatNumber(n: number | string): string { + const num = typeof n === "string" ? parseFloat(n) : n; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toLocaleString(); +} + +function LeaderboardColumn({ + title, + icon: Icon, + entries, + valueKey, + valuePrefix, +}: { + title: string; + icon: React.ComponentType<{ className?: string }>; + entries: Array<{ username: string; [k: string]: unknown }>; + valueKey: string; + valuePrefix?: string; +}) { + return ( +
+
+ +

{title}

+
+
+ {entries.length === 0 && ( +
No data
+ )} + {entries.slice(0, 10).map((entry, i) => ( +
+
+ + #{i + 1} + + {entry.username} +
+ + {valuePrefix} + {formatNumber(entry[valueKey] as string | number)} + +
+ ))} +
+
+ ); +} + +export default function Leaderboards() { + const [leaderboards, setLeaderboards] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + get<{ leaderboards?: LeaderboardData }>("/api/stats") + .then((data) => { + setLeaderboards(data.leaderboards ?? null); + }) + .catch(() => setError("Failed to load leaderboards")) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +

{error}

+
+
+ ); + } + + if (!leaderboards) { + return ( +
+

No leaderboard data available

+
+ ); + } + + return ( +
+

Leaderboards

+
+ + + +
+
+ ); +}