Files
discord-rpg-concept/panel/src/pages/Dashboard.tsx
syntaxbullet 9471b6fdab feat: add admin dashboard with sidebar navigation and stats overview
Replace placeholder panel with a full dashboard landing page showing
bot stats, leaderboards, and recent events from /api/stats. Add
sidebar navigation with placeholder pages for Users, Items, Classes,
Quests, Lootdrops, Moderation, Transactions, and Settings. Update
theme to match Aurora design guidelines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:23:13 +01:00

298 lines
8.7 KiB
TypeScript

import {
Users,
Coins,
TrendingUp,
Gift,
Loader2,
AlertTriangle,
CheckCircle,
XCircle,
Info,
Clock,
Wifi,
Trophy,
Crown,
Gem,
Wrench,
} from "lucide-react";
import { cn } from "../lib/utils";
import { useDashboard, type DashboardStats } from "../lib/useDashboard";
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 formatUptime(ms: number): string {
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
return `${hours}h ${minutes}m`;
}
function timeAgo(ts: string | Date): string {
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
const eventIcons = {
success: CheckCircle,
error: XCircle,
warn: AlertTriangle,
info: Info,
} as const;
const eventColors = {
success: "text-success",
error: "text-destructive",
warn: "text-warning",
info: "text-info",
} as const;
function StatCard({
icon: Icon,
label,
value,
sub,
accent = "border-primary",
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<div
className={cn(
"bg-gradient-to-br from-card to-surface rounded-lg border border-border p-6 border-l-4",
accent
)}
>
<div className="flex items-center gap-3 mb-3">
<Icon className="w-5 h-5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
{label}
</span>
</div>
<div className="text-3xl font-bold font-display tracking-tight">{value}</div>
{sub && <div className="text-sm text-text-tertiary mt-1">{sub}</div>}
</div>
);
}
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-lg border border-border">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="divide-y divide-border">
{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/40 transition-colors"
>
<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 Dashboard() {
const { data, loading, error } = useDashboard();
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 (!data) return null;
return <DashboardContent data={data} />;
}
function DashboardContent({ data }: { data: DashboardStats }) {
return (
<div className="space-y-8">
{/* Maintenance banner */}
{data.maintenanceMode && (
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-lg px-5 py-3">
<Wrench className="w-4 h-4 text-warning shrink-0" />
<span className="text-sm text-warning font-medium">
Maintenance mode is active
</span>
</div>
)}
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Users}
label="Total Users"
value={formatNumber(data.users.total)}
sub={`${formatNumber(data.users.active)} active`}
accent="border-info"
/>
<StatCard
icon={Coins}
label="Total Wealth"
value={formatNumber(data.economy.totalWealth)}
accent="border-gold"
/>
<StatCard
icon={TrendingUp}
label="Avg Level"
value={data.economy.avgLevel.toFixed(1)}
sub={`Top streak: ${data.economy.topStreak}`}
accent="border-success"
/>
<StatCard
icon={Gift}
label="Active Lootdrops"
value={String(data.activeLootdrops?.length ?? 0)}
accent="border-primary"
/>
</div>
{/* Leaderboards */}
{data.leaderboards && (
<section>
<h2 className="font-display text-lg font-semibold mb-4">Leaderboards</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<LeaderboardColumn
title="Top Levels"
icon={Trophy}
entries={data.leaderboards.topLevels}
valueKey="level"
valuePrefix="Lv. "
/>
<LeaderboardColumn
title="Top Wealth"
icon={Crown}
entries={data.leaderboards.topWealth}
valueKey="balance"
/>
<LeaderboardColumn
title="Top Net Worth"
icon={Gem}
entries={data.leaderboards.topNetWorth}
valueKey="netWorth"
/>
</div>
</section>
)}
{/* Recent Events */}
{data.recentEvents.length > 0 && (
<section>
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
<div className="bg-card rounded-lg border border-border divide-y divide-border">
{data.recentEvents.slice(0, 20).map((event, i) => {
const Icon = eventIcons[event.type];
return (
<div
key={i}
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/40 transition-colors"
>
<Icon
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
/>
<span className="text-sm flex-1">
{event.icon && <span className="mr-1.5">{event.icon}</span>}
{event.message}
</span>
<span className="text-xs text-text-tertiary font-mono whitespace-nowrap">
{timeAgo(event.timestamp)}
</span>
</div>
);
})}
</div>
</section>
)}
{/* Bot status footer */}
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary border-t border-border pt-6">
<span className="font-medium text-text-secondary">{data.bot.name}</span>
<span className="flex items-center gap-1.5">
<Wifi className="w-3 h-3" />
{Math.round(data.ping.avg)}ms
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formatUptime(data.uptime)}
</span>
{data.bot.status && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-success" />
{data.bot.status}
</span>
)}
</footer>
</div>
);
}