From d870ef69d556848cdc11a040424b0903f94e1989 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 9 Jan 2026 16:45:36 +0100 Subject: [PATCH] feat: (ui) leaderboards --- shared/modules/dashboard/dashboard.service.ts | 24 ++- shared/modules/dashboard/dashboard.types.ts | 4 + web/src/components/leaderboard-card.tsx | 170 ++++++++++++++++++ web/src/pages/Dashboard.tsx | 11 +- web/src/pages/DesignSystem.tsx | 56 +++++- web/styles/globals.css | 23 +++ 6 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 web/src/components/leaderboard-card.tsx diff --git a/shared/modules/dashboard/dashboard.service.ts b/shared/modules/dashboard/dashboard.service.ts index d493a0d..f205df0 100644 --- a/shared/modules/dashboard/dashboard.service.ts +++ b/shared/modules/dashboard/dashboard.service.ts @@ -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`${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() })) }; } }; diff --git a/shared/modules/dashboard/dashboard.types.ts b/shared/modules/dashboard/dashboard.types.ts index 3dc7107..4dc3ce4 100644 --- a/shared/modules/dashboard/dashboard.types.ts +++ b/shared/modules/dashboard/dashboard.types.ts @@ -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(), diff --git a/web/src/components/leaderboard-card.tsx b/web/src/components/leaderboard-card.tsx new file mode 100644 index 0000000..fc9d216 --- /dev/null +++ b/web/src/components/leaderboard-card.tsx @@ -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 ( + + + Top Players + + + +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+ ); + } + + 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 ( + + +
+ + {getTitle()} + +
+ + + +
+
+
+ +
+ {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 ( +
+
+ {index + 1} +
+ +
+

+ {user.username} + {isTop && } +

+
+ +
+ + {valueDisplay} + +
+
+ ); + })} + + {(!currentList || currentList.length === 0) && ( +
+ No data available +
+ )} +
+
+
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index f7dc453..de4c1f6 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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" /> + + {/* Recent Activity */} @@ -171,7 +178,7 @@ export function Dashboard() { /> - - + + ); } diff --git a/web/src/pages/DesignSystem.tsx b/web/src/pages/DesignSystem.tsx index cc68c52..088b9fe 100644 --- a/web/src/pages/DesignSystem.tsx +++ b/web/src/pages/DesignSystem.tsx @@ -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 (
@@ -341,6 +381,20 @@ export function DesignSystem() {
+ {/* Leaderboard Demo */} +
+

Leaderboard Cards

+
+ + +
+
+ {/* Testimonial Cards Demo */}