feat(panel): implement player leaderboards page
Some checks failed
Deploy to Production / test (push) Failing after 31s

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) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-05 16:02:43 +02:00
parent b645f55f57
commit 5c40249a18
2 changed files with 144 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ import Settings from "./pages/Settings";
import Users from "./pages/Users"; import Users from "./pages/Users";
import Items from "./pages/Items"; import Items from "./pages/Items";
import PlaceholderPage from "./pages/PlaceholderPage"; import PlaceholderPage from "./pages/PlaceholderPage";
import Leaderboards from "./pages/Leaderboards";
import NotEnrolled from "./pages/NotEnrolled"; import NotEnrolled from "./pages/NotEnrolled";
import PlayerDashboard from "./pages/PlayerDashboard"; import PlayerDashboard from "./pages/PlayerDashboard";
import { GameLobby } from "./games/GameLobby"; import { GameLobby } from "./games/GameLobby";
@@ -33,10 +34,6 @@ const placeholders: Record<string, { title: string; description: string }> = {
title: "Transactions", title: "Transactions",
description: "Browse the economy transaction log with filtering by user, type, and date.", 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() { function AppRoutes() {
@@ -78,7 +75,7 @@ function AppRoutes() {
{/* Player routes */} {/* Player routes */}
<Route path="/dashboard" element={<PlayerDashboard userId={user.discordId} />} /> <Route path="/dashboard" element={<PlayerDashboard userId={user.discordId} />} />
<Route path="/leaderboards" element={<PlaceholderPage {...placeholders.leaderboards} />} /> <Route path="/leaderboards" element={<Leaderboards />} />
{/* Game routes (both roles) */} {/* Game routes (both roles) */}
<Route path="/games" element={<GameLobby />} /> <Route path="/games" element={<GameLobby />} />

View File

@@ -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 (
<div className="bg-card rounded-xl">
<div className="flex items-center gap-2 px-5 py-4">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-display font-semibold">{title}</h3>
</div>
<div className="space-y-0">
{entries.length === 0 && (
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
)}
{entries.slice(0, 10).map((entry, i) => (
<div
key={entry.username}
className="flex items-center justify-between px-5 py-3 hover:bg-raised/30 transition-colors rounded-lg mx-2 mb-1"
>
<div className="flex items-center gap-3">
<span
className={cn(
"w-6 text-xs font-mono font-medium text-right",
i === 0
? "text-gold"
: i === 1
? "text-text-secondary"
: i === 2
? "text-warning"
: "text-text-tertiary"
)}
>
#{i + 1}
</span>
<span className="text-sm">{entry.username}</span>
</div>
<span className="text-sm font-mono text-text-secondary">
{valuePrefix}
{formatNumber(entry[valueKey] as string | number)}
</span>
</div>
))}
</div>
</div>
);
}
export default function Leaderboards() {
const [leaderboards, setLeaderboards] = useState<LeaderboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center py-32">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="text-center">
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{error}</p>
</div>
</div>
);
}
if (!leaderboards) {
return (
<div className="flex items-center justify-center py-32">
<p className="text-sm text-text-tertiary">No leaderboard data available</p>
</div>
);
}
return (
<div>
<h1 className="font-display text-lg font-semibold mb-6">Leaderboards</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<LeaderboardColumn
title="Top Levels"
icon={Trophy}
entries={leaderboards.topLevels}
valueKey="level"
valuePrefix="Lv. "
/>
<LeaderboardColumn
title="Top Wealth"
icon={Crown}
entries={leaderboards.topWealth}
valueKey="balance"
/>
<LeaderboardColumn
title="Top Net Worth"
icon={Gem}
entries={leaderboards.topNetWorth}
valueKey="netWorth"
/>
</div>
</div>
);
}