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>
This commit is contained in:
syntaxbullet
2026-02-14 12:23:13 +01:00
parent 04e5851387
commit 9471b6fdab
21 changed files with 1380 additions and 1361 deletions

View File

@@ -1,84 +1,297 @@
import { useState, useEffect, useRef } from "react";
import { get } from "../lib/api";
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";
interface Stats {
bot: { name: string; avatarUrl: string | null; status: string | null };
guilds: { count: number };
users: { total: number; active: number };
economy: { totalWealth: string; avgLevel: number; topStreak: number; totalItems: number };
commands: { total: number; active: number; disabled: number };
ping: { avg: number };
uptime: number;
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();
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
get<Stats>("/api/stats")
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false));
// Connect WebSocket for live updates
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
wsRef.current = ws;
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "STATS_UPDATE") setStats(msg.data);
} catch {}
};
return () => ws.close();
}, []);
if (loading) {
return (
<div className="flex justify-center p-12">
<span className="loading loading-spinner loading-lg" />
</div>
);
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`;
}
if (!stats) return <div className="alert alert-error">Failed to load stats</div>;
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 uptimeHours = Math.floor((stats.uptime ?? 0) / 3600);
const uptimeMins = Math.floor(((stats.uptime ?? 0) % 3600) / 60);
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>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Uptime</div>
<div className="stat-value text-lg">{uptimeHours}h {uptimeMins}m</div>
<div className="stat-desc">Ping: {stats.ping?.avg ?? 0}ms</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Guilds</div>
<div className="stat-value text-lg">{stats.guilds?.count ?? 0}</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Users</div>
<div className="stat-value text-lg">{stats.users?.total ?? 0}</div>
<div className="stat-desc">{stats.users?.active ?? 0} active</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Economy</div>
<div className="stat-value text-lg">{Number(stats.economy?.totalWealth ?? 0).toLocaleString()}g</div>
<div className="stat-desc">{stats.economy?.totalItems ?? 0} items in circulation</div>
</div>
<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>
);
}
<div className="text-sm text-base-content/50">
Live data via WebSocket updates every 5 seconds
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>
);
}