feat(panel): implement player leaderboards page
Some checks failed
Deploy to Production / test (push) Failing after 31s
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:
@@ -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<string, { title: string; description: string }> = {
|
||||
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 */}
|
||||
<Route path="/dashboard" element={<PlayerDashboard userId={user.discordId} />} />
|
||||
<Route path="/leaderboards" element={<PlaceholderPage {...placeholders.leaderboards} />} />
|
||||
<Route path="/leaderboards" element={<Leaderboards />} />
|
||||
|
||||
{/* Game routes (both roles) */}
|
||||
<Route path="/games" element={<GameLobby />} />
|
||||
|
||||
142
panel/src/pages/Leaderboards.tsx
Normal file
142
panel/src/pages/Leaderboards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user