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 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 />} />
|
||||||
|
|||||||
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