forked from syntaxbullet/aurorabot
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user