feat: more stat components
This commit is contained in:
@@ -37,8 +37,38 @@ export const DashboardStatsSchema = z.object({
|
||||
totalWealth: z.string(),
|
||||
avgLevel: z.number(),
|
||||
topStreak: z.number(),
|
||||
totalItems: z.number().optional(),
|
||||
}),
|
||||
recentEvents: z.array(RecentEventSchema),
|
||||
activeLootdrops: z.array(z.object({
|
||||
rewardAmount: z.number(),
|
||||
currency: z.string(),
|
||||
createdAt: z.string(),
|
||||
expiresAt: z.string().nullable(),
|
||||
})).optional(),
|
||||
lootdropState: z.object({
|
||||
monitoredChannels: z.number(),
|
||||
hottestChannel: z.object({
|
||||
id: z.string(),
|
||||
messages: z.number(),
|
||||
progress: z.number(),
|
||||
cooldown: z.boolean(),
|
||||
}).nullable(),
|
||||
config: z.object({
|
||||
requiredMessages: z.number(),
|
||||
dropChance: z.number(),
|
||||
}),
|
||||
}).optional(),
|
||||
leaderboards: z.object({
|
||||
topLevels: z.array(z.object({
|
||||
username: z.string(),
|
||||
level: z.number(),
|
||||
})),
|
||||
topWealth: z.array(z.object({
|
||||
username: z.string(),
|
||||
balance: z.string(),
|
||||
})),
|
||||
}).optional(),
|
||||
uptime: z.number(),
|
||||
lastCommandTimestamp: z.number().nullable(),
|
||||
maintenanceMode: z.boolean(),
|
||||
|
||||
@@ -163,6 +163,43 @@ class LootdropService {
|
||||
return { success: false, error: "An error occurred while processing the reward." };
|
||||
}
|
||||
}
|
||||
public getLootdropState() {
|
||||
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
|
||||
let maxMessages = -1;
|
||||
|
||||
const window = config.lootdrop.activityWindowMs;
|
||||
const now = Date.now();
|
||||
const required = config.lootdrop.minMessages;
|
||||
|
||||
for (const [channelId, timestamps] of this.channelActivity.entries()) {
|
||||
// Filter valid just to be sure we are reporting accurate numbers
|
||||
const validCount = timestamps.filter(t => now - t < window).length;
|
||||
|
||||
// Check cooldown
|
||||
const cooldownUntil = this.channelCooldowns.get(channelId);
|
||||
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
|
||||
|
||||
if (validCount > maxMessages) {
|
||||
maxMessages = validCount;
|
||||
hottestChannel = {
|
||||
id: channelId,
|
||||
messages: validCount,
|
||||
progress: Math.min(100, (validCount / required) * 100),
|
||||
cooldown: isOnCooldown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
monitoredChannels: this.channelActivity.size,
|
||||
hottestChannel,
|
||||
config: {
|
||||
requiredMessages: required,
|
||||
dropChance: config.lootdrop.spawnChance
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async clearCaches() {
|
||||
this.channelActivity.clear();
|
||||
this.channelCooldowns.clear();
|
||||
|
||||
128
web/src/components/lootdrop-card.tsx
Normal file
128
web/src/components/lootdrop-card.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Progress } from "./ui/progress";
|
||||
import { Gift, Clock, Sparkles, Zap, Timer } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
export interface LootdropData {
|
||||
rewardAmount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface LootdropState {
|
||||
monitoredChannels: number;
|
||||
hottestChannel: {
|
||||
id: string;
|
||||
messages: number;
|
||||
progress: number;
|
||||
cooldown: boolean;
|
||||
} | null;
|
||||
config: {
|
||||
requiredMessages: number;
|
||||
dropChance: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface LootdropCardProps {
|
||||
drop?: LootdropData | null;
|
||||
state?: LootdropState;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LootdropCard({ drop, state, isLoading, className }: LootdropCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn("glass-card border-none bg-card/40", className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Lootdrop Status</CardTitle>
|
||||
<Gift className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-[120px]" />
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = !!drop;
|
||||
const progress = state?.hottestChannel?.progress || 0;
|
||||
const isCooldown = state?.hottestChannel?.cooldown || false;
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"glass-card border-none transition-all duration-500 overflow-hidden relative",
|
||||
isActive ? "bg-primary/5 border-primary/20 hover-glow ring-1 ring-primary/20" : "bg-card/40",
|
||||
className
|
||||
)}>
|
||||
{/* Ambient Background Effect */}
|
||||
{isActive && (
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-primary/20 blur-3xl rounded-full pointer-events-none animate-pulse" />
|
||||
)}
|
||||
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
{isActive ? "Active Lootdrop" : "Lootdrop Potential"}
|
||||
{isActive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Gift className={cn("h-4 w-4 transition-colors", isActive ? "text-primary " : "text-muted-foreground")} />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
{isActive ? (
|
||||
<div className="space-y-3 animate-in fade-in slide-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold text-primary flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-yellow-500 fill-yellow-500 animate-pulse" />
|
||||
{drop.rewardAmount.toLocaleString()} {drop.currency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Dropped {new Date(drop.createdAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isCooldown ? (
|
||||
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground space-y-1">
|
||||
<Timer className="w-6 h-6 text-yellow-500 opacity-80" />
|
||||
<p className="text-sm font-medium text-yellow-500/80">Cooling Down...</p>
|
||||
<p className="text-xs opacity-50">Channels are recovering.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className={cn("w-3 h-3", progress > 80 ? "text-yellow-500" : "text-muted-foreground")} />
|
||||
<span>Next Drop Chance</span>
|
||||
</div>
|
||||
<span className="font-mono">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" indicatorClassName={cn(progress > 80 ? "bg-yellow-500" : "bg-primary")} />
|
||||
{state?.hottestChannel ? (
|
||||
<p className="text-[10px] text-muted-foreground text-right opacity-70">
|
||||
{state.hottestChannel.messages} / {state.config.requiredMessages} msgs
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground text-center opacity-50 pt-1">
|
||||
No recent activity
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
98
web/src/components/recent-activity.tsx
Normal file
98
web/src/components/recent-activity.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
function timeAgo(dateInput: Date | string) {
|
||||
const date = new Date(dateInput);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
interface RecentActivityProps {
|
||||
events: RecentEvent[];
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RecentActivity({ events, isLoading, className }: RecentActivityProps) {
|
||||
return (
|
||||
<Card className={cn("glass-card border-none bg-card/40 h-full", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-lg font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live Activity
|
||||
</span>
|
||||
{!isLoading && events.length > 0 && (
|
||||
<Badge variant="glass" className="text-[10px] font-mono">
|
||||
{events.length} EVENTS
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-2">
|
||||
<div className="text-4xl">😴</div>
|
||||
<p>No recent activity</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 -mr-2 custom-scrollbar">
|
||||
{events.map((event, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex items-start gap-3 p-3 rounded-xl bg-background/30 hover:bg-background/50 border border-transparent hover:border-border/50 transition-all duration-300"
|
||||
>
|
||||
<div className="text-2xl p-2 rounded-lg bg-background/50 group-hover:scale-110 transition-transform">
|
||||
{event.icon || "📝"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-1">
|
||||
<p className="text-sm font-medium leading-none truncate mb-1.5 text-foreground/90">
|
||||
{event.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
event.type === 'error' ? 'destructive' :
|
||||
event.type === 'warn' ? 'destructive' :
|
||||
event.type === 'success' ? 'aurora' : 'secondary'
|
||||
}
|
||||
className="text-[10px] h-4 px-1.5"
|
||||
>
|
||||
{event.type}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{timeAgo(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
53
web/src/components/stat-card.tsx
Normal file
53
web/src/components/stat-card.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
isLoading = false,
|
||||
className,
|
||||
valueClassName,
|
||||
iconClassName,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card className={cn("glass-card border-none bg-card/40 hover-glow", className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className={cn("h-4 w-4", iconClassName || "text-muted-foreground")} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn("text-2xl font-bold", valueClassName)}>{value}</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
24
web/src/components/ui/progress.tsx
Normal file
24
web/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value?: number | null, indicatorClassName?: string }
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
@@ -2,9 +2,10 @@ import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSocket } from "../hooks/use-socket";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { Server, Users, Terminal, Activity } from "lucide-react";
|
||||
import { StatCard } from "../components/stat-card";
|
||||
import { RecentActivity } from "../components/recent-activity";
|
||||
import { LootdropCard } from "../components/lootdrop-card";
|
||||
import { Server, Users, Terminal, Activity, Coins, TrendingUp, Flame, Package } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function Dashboard() {
|
||||
@@ -60,102 +61,115 @@ export function Dashboard() {
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 animate-in fade-in slide-up">
|
||||
<Card className="glass-card border-none bg-card/40 hover-glow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Servers</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{stats.guilds.count.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.guilds.changeFromLastMonth
|
||||
? `${stats.guilds.changeFromLastMonth > 0 ? '+' : ''}${stats.guilds.changeFromLastMonth} from last month`
|
||||
: "Active Guilds"
|
||||
}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Total Servers"
|
||||
icon={Server}
|
||||
isLoading={!stats}
|
||||
value={stats?.guilds.count.toLocaleString()}
|
||||
subtitle={stats?.guilds.changeFromLastMonth
|
||||
? `${stats.guilds.changeFromLastMonth > 0 ? '+' : ''}${stats.guilds.changeFromLastMonth} from last month`
|
||||
: "Active Guilds"
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-card border-none bg-card/40 hover-glow delay-100">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{stats.users.total.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.users.active.toLocaleString()} active now
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
icon={Users}
|
||||
isLoading={!stats}
|
||||
value={stats?.users.total.toLocaleString()}
|
||||
subtitle={stats ? `${stats.users.active.toLocaleString()} active now` : undefined}
|
||||
className="delay-100"
|
||||
/>
|
||||
|
||||
<Card className="glass-card border-none bg-card/40 hover-glow delay-200">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Commands</CardTitle>
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{stats.commands.total.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.commands.active} active · {stats.commands.disabled} disabled
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Commands"
|
||||
icon={Terminal}
|
||||
isLoading={!stats}
|
||||
value={stats?.commands.total.toLocaleString()}
|
||||
subtitle={stats ? `${stats.commands.active} active · ${stats.commands.disabled} disabled` : undefined}
|
||||
className="delay-200"
|
||||
/>
|
||||
|
||||
<Card className="glass-card border-none bg-card/40 hover-glow delay-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">System Ping</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
<>
|
||||
<div className={cn(
|
||||
"text-2xl font-bold transition-colors duration-300",
|
||||
stats.ping.avg < 100 ? "text-emerald-500" :
|
||||
stats.ping.avg < 200 ? "text-yellow-500" : "text-red-500"
|
||||
)}>
|
||||
{Math.round(stats.ping.avg)}ms
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Average latency
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="System Ping"
|
||||
icon={Activity}
|
||||
isLoading={!stats}
|
||||
value={stats ? `${Math.round(stats.ping.avg)}ms` : undefined}
|
||||
subtitle="Average latency"
|
||||
className="delay-300"
|
||||
valueClassName={stats ? cn(
|
||||
"transition-colors duration-300",
|
||||
stats.ping.avg < 100 ? "text-emerald-500" :
|
||||
stats.ping.avg < 200 ? "text-yellow-500" : "text-red-500"
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3 animate-in fade-in slide-up delay-300">
|
||||
{/* Economy Stats */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-xl font-semibold tracking-tight">Economy Overview</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StatCard
|
||||
title="Total Wealth"
|
||||
icon={Coins}
|
||||
isLoading={!stats}
|
||||
value={stats ? `${Number(stats.economy.totalWealth).toLocaleString()} AU` : undefined}
|
||||
subtitle="Astral Units in circulation"
|
||||
valueClassName="text-primary"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Items Circulating"
|
||||
icon={Package}
|
||||
isLoading={!stats}
|
||||
value={stats?.economy.totalItems?.toLocaleString()}
|
||||
subtitle="Total items owned by users"
|
||||
className="delay-75"
|
||||
valueClassName="text-blue-500"
|
||||
iconClassName="text-blue-500"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Average Level"
|
||||
icon={TrendingUp}
|
||||
isLoading={!stats}
|
||||
value={stats ? `Lvl ${stats.economy.avgLevel}` : undefined}
|
||||
subtitle="Global player average"
|
||||
className="delay-100"
|
||||
valueClassName="text-secondary"
|
||||
iconClassName="text-secondary"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Top /daily Streak"
|
||||
icon={Flame}
|
||||
isLoading={!stats}
|
||||
value={stats?.economy.topStreak}
|
||||
subtitle="Days daily streak"
|
||||
className="delay-200"
|
||||
valueClassName="text-destructive"
|
||||
iconClassName="text-destructive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{/* Recent Activity & Lootdrops */}
|
||||
<div className="space-y-4">
|
||||
<LootdropCard
|
||||
drop={stats?.activeLootdrops?.[0]}
|
||||
state={stats?.lootdropState}
|
||||
isLoading={!stats}
|
||||
/>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Live Feed</h2>
|
||||
<RecentActivity
|
||||
events={stats?.recentEvents || []}
|
||||
isLoading={!stats}
|
||||
className="h-[calc(100%-2rem)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,25 @@ import { FeatureCard } from "../components/feature-card";
|
||||
import { InfoCard } from "../components/info-card";
|
||||
import { SectionHeader } from "../components/section-header";
|
||||
import { TestimonialCard } from "../components/testimonial-card";
|
||||
import { StatCard } from "../components/stat-card";
|
||||
import { LootdropCard } from "../components/lootdrop-card";
|
||||
import { Activity, Coins, Flame } from "lucide-react";
|
||||
|
||||
import { RecentActivity } from "../components/recent-activity";
|
||||
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
|
||||
|
||||
const mockEvents: RecentEvent[] = [
|
||||
{ type: 'success', message: 'User leveled up to 5', timestamp: new Date(Date.now() - 1000 * 60 * 5), icon: '⬆️' },
|
||||
{ type: 'info', message: 'New user joined', timestamp: new Date(Date.now() - 1000 * 60 * 15), icon: '👋' },
|
||||
{ type: 'warn', message: 'Failed login attempt', timestamp: new Date(Date.now() - 1000 * 60 * 60), icon: '⚠️' }
|
||||
];
|
||||
|
||||
const mockManyEvents: RecentEvent[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||
type: i % 3 === 0 ? 'success' : i % 3 === 1 ? 'info' : 'error', // Use string literals matching the type definition
|
||||
message: `Event #${i + 1} generated for testing scroll behavior`,
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * i * 10),
|
||||
icon: i % 3 === 0 ? '✨' : i % 3 === 1 ? 'ℹ️' : '🚨',
|
||||
}));
|
||||
|
||||
export function DesignSystem() {
|
||||
return (
|
||||
@@ -249,6 +268,79 @@ export function DesignSystem() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards Demo */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
title="Standard Stat"
|
||||
value="1,234"
|
||||
subtitle="Active users"
|
||||
icon={Activity}
|
||||
isLoading={false}
|
||||
/>
|
||||
<StatCard
|
||||
title="Colored Stat"
|
||||
value="9,999 AU"
|
||||
subtitle="Total Wealth"
|
||||
icon={Coins}
|
||||
isLoading={false}
|
||||
valueClassName="text-primary"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Loading State"
|
||||
value={null}
|
||||
icon={Flame}
|
||||
isLoading={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Game Event Cards Demo */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-muted-foreground">Game Event Cards</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<LootdropCard
|
||||
isLoading={true}
|
||||
/>
|
||||
<LootdropCard
|
||||
drop={null}
|
||||
state={{
|
||||
monitoredChannels: 3,
|
||||
hottestChannel: {
|
||||
id: "123",
|
||||
messages: 42,
|
||||
progress: 42,
|
||||
cooldown: false
|
||||
},
|
||||
config: { requiredMessages: 100, dropChance: 0.1 }
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
<LootdropCard
|
||||
drop={null}
|
||||
state={{
|
||||
monitoredChannels: 3,
|
||||
hottestChannel: {
|
||||
id: "123",
|
||||
messages: 100,
|
||||
progress: 100,
|
||||
cooldown: true
|
||||
},
|
||||
config: { requiredMessages: 100, dropChance: 0.1 }
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
<LootdropCard
|
||||
drop={{
|
||||
rewardAmount: 500,
|
||||
currency: "AU",
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString()
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial Cards Demo */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<TestimonialCard
|
||||
@@ -258,6 +350,33 @@ export function DesignSystem() {
|
||||
avatarGradient="bg-gradient-to-br from-pink-500 to-rose-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Demo */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-muted-foreground">Recent Activity Feed</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 h-[500px]">
|
||||
<RecentActivity
|
||||
events={[]}
|
||||
isLoading={true}
|
||||
className="h-full"
|
||||
/>
|
||||
<RecentActivity
|
||||
events={[]}
|
||||
isLoading={false}
|
||||
className="h-full"
|
||||
/>
|
||||
<RecentActivity
|
||||
events={mockEvents}
|
||||
isLoading={false}
|
||||
className="h-full"
|
||||
/>
|
||||
<RecentActivity
|
||||
events={mockManyEvents}
|
||||
isLoading={false}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
async function getFullDashboardStats() {
|
||||
// Import services (dynamic to avoid circular deps)
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { getClientStats } = await import("../../bot/lib/clientStats");
|
||||
|
||||
// Fetch all data in parallel with error isolation
|
||||
@@ -375,6 +376,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
dashboardService.getTotalItems(),
|
||||
dashboardService.getActiveLootdrops(),
|
||||
dashboardService.getLeaderboards(),
|
||||
Promise.resolve(lootdropService.getLootdropState()),
|
||||
]);
|
||||
|
||||
// Helper to unwrap result or return default
|
||||
@@ -402,6 +404,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
const totalItems = unwrap(results[5], 0, 'totalItems');
|
||||
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
||||
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [] }, 'leaderboards');
|
||||
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
|
||||
|
||||
return {
|
||||
bot: clientStats.bot,
|
||||
@@ -430,6 +433,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
||||
// Explicitly excluding channelId/messageId to prevent sniping
|
||||
})),
|
||||
lootdropState,
|
||||
leaderboards,
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
|
||||
Reference in New Issue
Block a user