feat: (ui) leaderboards
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, moderationCases, inventory, lootdrops, type User } from "@db/schema";
|
||||
import { desc, sql, gte } from "drizzle-orm";
|
||||
import { users, transactions, moderationCases, inventory, lootdrops, items, type User } from "@db/schema";
|
||||
import { desc, sql, gte, eq } from "drizzle-orm";
|
||||
import type { RecentEvent, ActivityData } from "./dashboard.types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
|
||||
@@ -224,7 +224,7 @@ export const dashboardService = {
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.level))
|
||||
.limit(3);
|
||||
.limit(10);
|
||||
|
||||
const topWealth = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
@@ -232,11 +232,25 @@ export const dashboardService = {
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.balance))
|
||||
.limit(3);
|
||||
.limit(10);
|
||||
|
||||
|
||||
|
||||
const topNetWorth = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(inventory, eq(users.id, inventory.userId))
|
||||
.leftJoin(items, eq(inventory.itemId, items.id))
|
||||
.groupBy(users.id, users.username, users.balance)
|
||||
.orderBy(desc(sql`net_worth`))
|
||||
.limit(10);
|
||||
|
||||
return {
|
||||
topLevels,
|
||||
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() }))
|
||||
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() })),
|
||||
topNetWorth: topNetWorth.map(u => ({ ...u, netWorth: (u.netWorth || 0n).toString() }))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,6 +68,10 @@ export const DashboardStatsSchema = z.object({
|
||||
username: z.string(),
|
||||
balance: z.string(),
|
||||
})),
|
||||
topNetWorth: z.array(z.object({
|
||||
username: z.string(),
|
||||
netWorth: z.string(),
|
||||
})),
|
||||
}).optional(),
|
||||
uptime: z.number(),
|
||||
lastCommandTimestamp: z.number().nullable(),
|
||||
|
||||
170
web/src/components/leaderboard-card.tsx
Normal file
170
web/src/components/leaderboard-card.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Trophy, Coins, Award, Crown, Target } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface LocalUser {
|
||||
username: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface LeaderboardData {
|
||||
topLevels: { username: string; level: number }[];
|
||||
topWealth: { username: string; balance: string }[];
|
||||
topNetWorth: { username: string; netWorth: string }[];
|
||||
}
|
||||
|
||||
interface LeaderboardCardProps {
|
||||
data?: LeaderboardData;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LeaderboardCard({ data, isLoading, className }: LeaderboardCardProps) {
|
||||
const [view, setView] = useState<"wealth" | "levels" | "networth">("wealth");
|
||||
|
||||
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">Top Players</CardTitle>
|
||||
<Trophy className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-1 flex-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const currentList = view === "wealth" ? data?.topWealth : view === "networth" ? data?.topNetWorth : data?.topLevels;
|
||||
|
||||
const getTitle = () => {
|
||||
switch (view) {
|
||||
case "wealth": return "Richest Users";
|
||||
case "networth": return "Highest Net Worth";
|
||||
case "levels": return "Top Levels";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("glass-card border-none transition-all duration-300", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2 whitespace-nowrap">
|
||||
{getTitle()}
|
||||
</CardTitle>
|
||||
<div className="flex bg-muted/50 rounded-lg p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs rounded-md transition-all",
|
||||
view === "wealth" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setView("wealth")}
|
||||
>
|
||||
<Coins className="w-3 h-3 mr-1" />
|
||||
Wealth
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs rounded-md transition-all",
|
||||
view === "levels" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setView("levels")}
|
||||
>
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Levels
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs rounded-md transition-all",
|
||||
view === "networth" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setView("networth")}
|
||||
>
|
||||
<Target className="w-3 h-3 mr-1" />
|
||||
Net Worth
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4 animate-in fade-in slide-up duration-300 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar" key={view}>
|
||||
{currentList?.map((user, index) => {
|
||||
const isTop = index === 0;
|
||||
const RankIcon = index === 0 ? Crown : index === 1 ? Trophy : Award;
|
||||
const rankColor = index === 0 ? "text-yellow-500" : index === 1 ? "text-slate-400" : "text-orange-500";
|
||||
const bgColor = index === 0 ? "bg-yellow-500/10 border-yellow-500/20" : index === 1 ? "bg-slate-400/10 border-slate-400/20" : "bg-orange-500/10 border-orange-500/20";
|
||||
|
||||
// Type guard or simple check because structure differs slightly or we can normalize
|
||||
let valueDisplay = "";
|
||||
if (view === "wealth") {
|
||||
valueDisplay = `${Number((user as any).balance).toLocaleString()} AU`;
|
||||
} else if (view === "networth") {
|
||||
valueDisplay = `${Number((user as any).netWorth).toLocaleString()} AU`;
|
||||
} else {
|
||||
valueDisplay = `Lvl ${(user as any).level}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={user.username} className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-lg border transition-colors",
|
||||
"hover:bg-muted/50 border-transparent hover:border-border/50",
|
||||
isTop && "bg-primary/5 border-primary/10"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-full border text-xs font-bold",
|
||||
bgColor, rankColor
|
||||
)}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate flex items-center gap-1.5">
|
||||
{user.username}
|
||||
{isTop && <Crown className="w-3 h-3 text-yellow-500 fill-yellow-500" />}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className={cn(
|
||||
"text-xs font-bold font-mono",
|
||||
view === "wealth" ? "text-emerald-500" : view === "networth" ? "text-purple-500" : "text-blue-500"
|
||||
)}>
|
||||
{valueDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{(!currentList || currentList.length === 0) && (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card >
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Badge } from "../components/ui/badge";
|
||||
import { StatCard } from "../components/stat-card";
|
||||
import { RecentActivity } from "../components/recent-activity";
|
||||
import { LootdropCard } from "../components/lootdrop-card";
|
||||
import { LeaderboardCard } from "../components/leaderboard-card";
|
||||
import { Server, Users, Terminal, Activity, Coins, TrendingUp, Flame, Package } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -153,6 +154,12 @@ export function Dashboard() {
|
||||
iconClassName="text-destructive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LeaderboardCard
|
||||
data={stats?.leaderboards}
|
||||
isLoading={!stats}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
@@ -171,7 +178,7 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ 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 { Activity, Coins, Flame, Trophy } from "lucide-react";
|
||||
|
||||
import { RecentActivity } from "../components/recent-activity";
|
||||
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
|
||||
import { LeaderboardCard, type LeaderboardData } from "../components/leaderboard-card";
|
||||
|
||||
const mockEvents: RecentEvent[] = [
|
||||
{ type: 'success', message: 'User leveled up to 5', timestamp: new Date(Date.now() - 1000 * 60 * 5), icon: '⬆️' },
|
||||
@@ -28,6 +29,45 @@ const mockManyEvents: RecentEvent[] = Array.from({ length: 15 }).map((_, i) => (
|
||||
icon: i % 3 === 0 ? '✨' : i % 3 === 1 ? 'ℹ️' : '🚨',
|
||||
}));
|
||||
|
||||
const mockLeaderboardData: LeaderboardData = {
|
||||
topLevels: [
|
||||
{ username: "StellarMage", level: 99 },
|
||||
{ username: "MoonWalker", level: 85 },
|
||||
{ username: "SunChaser", level: 72 },
|
||||
{ username: "NebulaKnight", level: 68 },
|
||||
{ username: "CometRider", level: 65 },
|
||||
{ username: "VoidWalker", level: 60 },
|
||||
{ username: "AstroBard", level: 55 },
|
||||
{ username: "StarGazer", level: 50 },
|
||||
{ username: "CosmicDruid", level: 45 },
|
||||
{ username: "GalaxyGuard", level: 42 }
|
||||
],
|
||||
topWealth: [
|
||||
{ username: "GoldHoarder", balance: "1000000" },
|
||||
{ username: "MerchantKing", balance: "750000" },
|
||||
{ username: "LuckyLooter", balance: "500000" },
|
||||
{ username: "CryptoMiner", balance: "450000" },
|
||||
{ username: "MarketMaker", balance: "300000" },
|
||||
{ username: "TradeWind", balance: "250000" },
|
||||
{ username: "CoinKeeper", balance: "150000" },
|
||||
{ username: "GemHunter", balance: "100000" },
|
||||
{ username: "DustCollector", balance: "50000" },
|
||||
{ username: "BrokeBeginner", balance: "100" }
|
||||
],
|
||||
topNetWorth: [
|
||||
{ username: "MerchantKing", netWorth: "1500000" },
|
||||
{ username: "GoldHoarder", netWorth: "1250000" },
|
||||
{ username: "LuckyLooter", netWorth: "850000" },
|
||||
{ username: "MarketMaker", netWorth: "700000" },
|
||||
{ username: "GemHunter", netWorth: "650000" },
|
||||
{ username: "CryptoMiner", netWorth: "550000" },
|
||||
{ username: "TradeWind", netWorth: "400000" },
|
||||
{ username: "CoinKeeper", netWorth: "250000" },
|
||||
{ username: "DustCollector", netWorth: "150000" },
|
||||
{ username: "BrokeBeginner", netWorth: "5000" }
|
||||
]
|
||||
};
|
||||
|
||||
export function DesignSystem() {
|
||||
return (
|
||||
<div className="min-h-screen bg-aurora-page text-foreground font-outfit">
|
||||
@@ -341,6 +381,20 @@ export function DesignSystem() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Demo */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-muted-foreground">Leaderboard Cards</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<LeaderboardCard
|
||||
isLoading={true}
|
||||
/>
|
||||
<LeaderboardCard
|
||||
data={mockLeaderboardData}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial Cards Demo */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<TestimonialCard
|
||||
|
||||
@@ -114,6 +114,29 @@
|
||||
box-shadow: 0 0 40px oklch(0.82 0.18 85 / 0.12);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar Global */
|
||||
.custom-scrollbar::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track,
|
||||
body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb {
|
||||
background: var(--muted);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Entrance Animations */
|
||||
.animate-in {
|
||||
animation-duration: 0.6s;
|
||||
|
||||
Reference in New Issue
Block a user