From d60d5fe5af83ffe905de5f0a281f8c1846e73a0b Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 3 Apr 2025 22:15:54 +0200 Subject: [PATCH] wip --- Dockerfile | 2 +- bun.lock | 5 + package.json | 1 + src/app/_components/leaderboard.tsx | 485 ++++++++++++++++++++++++++ src/app/page.tsx | 19 +- src/app/players/[id]/user.tsx | 8 +- src/server/api/routers/history.ts | 5 +- src/server/api/routers/leaderboard.ts | 9 +- 8 files changed, 520 insertions(+), 14 deletions(-) create mode 100644 src/app/_components/leaderboard.tsx diff --git a/Dockerfile b/Dockerfile index 7a6d74f..8850383 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN SKIP_ENV_VALIDATION=1 bun run build ##### RUNNER -FROM --platform=linux/amd64 gcr.io/distroless/nodejs20-debian12 AS runner +FROM --platform=linux/amd64 imbios/bun-node:latest-current-debian AS runner WORKDIR /app ENV NODE_ENV production diff --git a/bun.lock b/bun.lock index 3a4702e..899ebe0 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", + "@tanstack/react-virtual": "^3.13.6", "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", @@ -391,6 +392,10 @@ "@tanstack/react-query": ["@tanstack/react-query@5.71.5", "", { "dependencies": { "@tanstack/query-core": "5.71.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-WpxZWy4fDASjY+iAaXB+aY+LC95PQ34W6EWVkjJ0hdzWWbczFnr9nHvHkVDpwdR18I1NO8igNGQJFrLrgyzI8Q=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.6", "", { "dependencies": { "@tanstack/virtual-core": "3.13.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.6", "", {}, "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg=="], + "@trpc/client": ["@trpc/client@11.0.1", "", { "peerDependencies": { "@trpc/server": "11.0.1", "typescript": ">=5.7.2" } }, "sha512-HvOrvWAXbGBwt4om+NfuxrK4f+ik2aaNIXq7WLrJbEp7U+YXfh5++1a5p4JDaikrvSaObJ389DhYAGWz90xSGw=="], "@trpc/react-query": ["@trpc/react-query@11.0.1", "", { "peerDependencies": { "@tanstack/react-query": "^5.67.1", "@trpc/client": "11.0.1", "@trpc/server": "11.0.1", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-astHk2cchLjyXx4cScUZj4zOM5um10zdX4ExVUllBI+kU5sUTbztigDZCGDz8WbdsOiSzYHL4KINdERaQrXWAQ=="], diff --git a/package.json b/package.json index 86ef112..86aac5a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", + "@tanstack/react-virtual": "^3.13.6", "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", diff --git a/src/app/_components/leaderboard.tsx b/src/app/_components/leaderboard.tsx new file mode 100644 index 0000000..62db5fb --- /dev/null +++ b/src/app/_components/leaderboard.tsx @@ -0,0 +1,485 @@ +'use client' + +import type React from 'react' +import { useRef } from 'react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { cn } from '@/lib/utils' +import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants' +import { api } from '@/trpc/react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Flame, + Info, + Medal, + Search, + TrendingUp, + Trophy, + Users, +} from 'lucide-react' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { useState } from 'react' + +export function LeaderboardPage() { + const router = useRouter() + const searchParams = useSearchParams() + + // Get the leaderboard type from URL or default to 'ranked' + const leaderboardType = searchParams.get('type') || 'ranked' + + // State for search and sorting + const [searchQuery, setSearchQuery] = useState('') + const [sortColumn, setSortColumn] = useState('rank') + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + + // Fetch leaderboard data + const [rankedLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery({ + channel_id: RANKED_CHANNEL, + }) + + const [vanillaLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery( + { + channel_id: VANILLA_CHANNEL, + } + ) + + // Handle tab change + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams) + params.set('type', value) + router.push(`?${params.toString()}`) + } + + // Get the current leaderboard based on selected tab + const currentLeaderboard = + leaderboardType === 'ranked' + ? rankedLeaderboard.alltime + : vanillaLeaderboard.alltime + + // Filter leaderboard by search query + const filteredLeaderboard = currentLeaderboard.filter((entry) => + entry.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + // Sort leaderboard + const sortedLeaderboard = [...filteredLeaderboard].sort((a, b) => { + // biome-ignore lint/style/useSingleVarDeclarator: + // biome-ignore lint/suspicious/noImplicitAnyLet: + let valueA, valueB + + // Handle special case for rank which is already sorted + if (sortColumn === 'rank') { + valueA = a.data.rank + valueB = b.data.rank + } else if (sortColumn === 'name') { + valueA = a.name.toLowerCase() + valueB = b.name.toLowerCase() + return sortDirection === 'asc' + ? valueA.localeCompare(valueB) + : valueB.localeCompare(valueA) + } else { + valueA = a.data[sortColumn as keyof typeof a.data] as number + valueB = b.data[sortColumn as keyof typeof b.data] as number + } + + return sortDirection === 'asc' ? valueA - valueB : valueB - valueA + }) + + // Handle column sort + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') + } else { + setSortColumn(column) + setSortDirection('asc') + } + } + + // Get medal for top 3 players + const getMedal = (rank: number) => { + if (rank === 1) return + if (rank === 2) return + if (rank === 3) return + return null + } + console.log(currentLeaderboard) + + return ( +
+
+ + +
+
+

+ + Leaderboards +

+

+ View player rankings and statistics +

+
+ +
+ + + {currentLeaderboard.length} Players + + + +
+
+
+ + + +
+ + Ranked Leaderboard + Vanilla Leaderboard + + +
+ + setSearchQuery(e.target.value)} + /> +
+
+ + + + + + + + +
+
+
+
+
+ ) +} + +interface LeaderboardTableProps { + leaderboard: any[] + sortColumn: string + sortDirection: 'asc' | 'desc' + onSort: (column: string) => void + getMedal: (rank: number) => React.ReactNode + type: string +} + +function LeaderboardTable({ + leaderboard, + sortColumn, + sortDirection, + onSort, + getMedal, + type, +}: LeaderboardTableProps) { + const tableContainerRef = useRef(null) + + // Set a fixed row height for virtualization + const ROW_HEIGHT = 56 // Adjust based on your actual row height + + // Create virtualizer instance + const rowVirtualizer = useVirtualizer({ + count: leaderboard.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 10, // Number of items to render before/after the visible area + }) + + // Get the virtualized rows + const virtualRows = rowVirtualizer.getVirtualItems() + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start : 0 + const paddingBottom = + virtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - + (virtualRows?.[virtualRows.length - 1]?.end ?? 0) + : 0 + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {leaderboard.length > 0 ? ( + leaderboard.map((entry) => { + const winrate = entry.data.winrate * 100 + return ( + + +
+ {getMedal(entry.data.rank)} + {entry.data.rank} +
+
+ + + {/**/} + {/* */} + {/* */} + {/* {entry.name.slice(0, 2).toUpperCase()}*/} + {/* */} + {/**/} + {entry.name} + {entry.data.streak >= 3 && ( + + + Hot Streak + + )} + + + + {Math.round(entry.data.mmr)} + + +
+ + {Math.round(entry.data.peak_mmr)} +
+
+ + 60 + ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300' + : winrate < 40 + ? 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300' + : 'border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300' + )} + > + {Math.round(winrate)}% + + + + {entry.data.wins} + + + {entry.data.losses} + + + {entry.data.totalgames} + + + {entry.data.streak > 0 ? ( + + + {entry.data.streak} + + ) : entry.data.streak < 0 ? ( + + + {Math.abs(entry.data.streak)} + + ) : ( + 0 + )} + +
+ ) + }) + ) : ( + + + No players found + + + )} +
+
+
+
+ ) +} + +interface SortableHeaderProps { + column: string + label: string + currentSort: string + direction: 'asc' | 'desc' + onSort: (column: string) => void +} + +function SortableHeader({ + column, + label, + currentSort, + direction, + onSort, +}: SortableHeaderProps) { + const isActive = currentSort === column + + return ( + + ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 972d16f..2247758 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,20 @@ +import { LeaderboardPage } from '@/app/_components/leaderboard' import { UserStats } from '@/app/_components/user-stats' import { auth } from '@/server/auth' -import { HydrateClient } from '@/trpc/server' +import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants' +import { HydrateClient, api } from '@/trpc/server' +import { Suspense } from 'react' export default async function Home() { const session = await auth() - + await Promise.all([ + api.leaderboard.get_leaderboard.prefetch({ + channel_id: RANKED_CHANNEL, + }), + api.leaderboard.get_leaderboard.prefetch({ + channel_id: VANILLA_CHANNEL, + }), + ]) if (session?.user) { console.log('user', session.user) // void api.post.getLatest.prefetch() @@ -12,7 +22,10 @@ export default async function Home() { return ( - + + + + ) } diff --git a/src/app/players/[id]/user.tsx b/src/app/players/[id]/user.tsx index f1635c1..2f7a3bd 100644 --- a/src/app/players/[id]/user.tsx +++ b/src/app/players/[id]/user.tsx @@ -186,8 +186,8 @@ export function UserInfo() { > Vanilla Queue:{' '} - {isNonNullish(vanillaLeaderboard?.rank) - ? `#${vanillaLeaderboard.rank}` + {isNonNullish(vanillaUserRank?.rank) + ? `#${vanillaUserRank.rank}` : 'N/A'} )} @@ -451,7 +451,7 @@ export function UserInfo() { {(rankedLeaderboard || lastGameLeaderboard1) && ( { - return await service.getLeaderboard(input.channel_id) + return (await service.getLeaderboard( + input.channel_id + )) as LeaderboardResponse }), get_user_rank: publicProcedure .input(