328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
// app/rankings/page.tsx
|
||
// Rankings with pagination and adjustable page size
|
||
|
||
import { query } from '@/lib/db';
|
||
import { TrophyIcon } from '@/components/ui/icons';
|
||
import Link from 'next/link';
|
||
export const dynamic = "force-dynamic";
|
||
|
||
interface RankingDriver {
|
||
driver_guid: string;
|
||
driver_name: string;
|
||
user_rank: number;
|
||
laps_completed: number;
|
||
created_at: Date;
|
||
}
|
||
|
||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||
|
||
async function getRankings(page: number = 1, pageSize: number = 50): Promise<{ drivers: RankingDriver[]; totalCount: number }> {
|
||
const offset = (page - 1) * pageSize;
|
||
|
||
// Get total count
|
||
const countResult = await query(`SELECT COUNT(*) as count FROM users`);
|
||
const totalCount = countResult[0]?.count || 0;
|
||
|
||
// Get paginated results
|
||
const sql = `
|
||
SELECT
|
||
driver_guid,
|
||
driver_name,
|
||
user_rank,
|
||
laps_completed,
|
||
created_at
|
||
FROM users
|
||
ORDER BY user_rank DESC
|
||
LIMIT ${pageSize} OFFSET ${offset}
|
||
`;
|
||
|
||
const rows = await query(sql);
|
||
return {
|
||
drivers: rows as RankingDriver[],
|
||
totalCount: totalCount
|
||
};
|
||
}
|
||
|
||
function getDriverClass(rank: number): { name: string; color: string } {
|
||
if (rank >= 5000) return { name: 'S CLASS', color: 'text-white' };
|
||
if (rank >= 2500) return { name: 'A CLASS', color: 'text-white' };
|
||
if (rank >= 1750) return { name: 'B CLASS', color: 'text-white/80' };
|
||
if (rank >= 1250) return { name: 'C CLASS', color: 'text-white/60' };
|
||
return { name: 'D CLASS', color: 'text-white/40' };
|
||
}
|
||
|
||
export default async function RankingsPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||
}) {
|
||
// Await searchParams in Next.js 15+
|
||
const params = await searchParams;
|
||
const currentPage = parseInt(params.page || '1');
|
||
const pageSize = parseInt(params.pageSize || '50');
|
||
|
||
const { drivers, totalCount } = await getRankings(currentPage, pageSize);
|
||
const totalPages = Math.ceil(totalCount / pageSize);
|
||
const startRank = (currentPage - 1) * pageSize + 1;
|
||
|
||
return (
|
||
<>
|
||
{/* Hero */}
|
||
<div className="relative border-b border-white/10 grid-overlay">
|
||
<div className="max-w-7xl mx-auto px-6 py-16">
|
||
<div className="space-y-4">
|
||
<div className="inline-flex items-center space-x-2 px-3 py-1 border border-white/20 bg-black">
|
||
<TrophyIcon className="w-4 h-4" />
|
||
<span className="text-xs font-medium tracking-wider">DRIVER RANKINGS</span>
|
||
</div>
|
||
<h1 className="text-6xl font-bold tracking-tight">
|
||
LEADERBOARD
|
||
</h1>
|
||
<p className="text-white/60 text-lg max-w-3xl">
|
||
Global driver rankings based on performance, consistency, and clean racing
|
||
</p>
|
||
<p className="text-white/40 text-sm">
|
||
{totalCount.toLocaleString()} total drivers
|
||
</p>
|
||
</div>
|
||
|
||
{/* Class Explanation */}
|
||
<div className="mt-12 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<ClassCard
|
||
name="D CLASS"
|
||
range="0 - 1250"
|
||
description="Entry level — learning the fundamentals"
|
||
/>
|
||
<ClassCard
|
||
name="C CLASS"
|
||
range="1250 - 1750"
|
||
description="Intermediate — consistent performance"
|
||
/>
|
||
<ClassCard
|
||
name="B CLASS"
|
||
range="1750 - 2500"
|
||
description="Advanced — competitive racing"
|
||
/>
|
||
<ClassCard
|
||
name="A CLASS"
|
||
range="2500+"
|
||
description="Elite — top-tier drivers"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rankings Table */}
|
||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||
{/* Page Size Selector */}
|
||
<div className="mb-6 flex items-center justify-between border border-white/10 p-4 bg-black">
|
||
<div className="text-sm text-white/60">
|
||
Items per page:
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||
<Link
|
||
key={size}
|
||
href={`/rankings?page=1&pageSize=${size}`}
|
||
className={`px-4 py-2 border text-sm transition-colors bg-black ${
|
||
pageSize === size
|
||
? 'border-white text-white'
|
||
: 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
|
||
}`}
|
||
>
|
||
{size}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border border-white/10 bg-black">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-white/10 bg-white/5">
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">RANK</th>
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">DRIVER</th>
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">RATING</th>
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">CLASS</th>
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">LAPS</th>
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">STEAM ID</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{drivers.map((driver, index) => {
|
||
const driverClass = getDriverClass(driver.user_rank);
|
||
const globalRank = startRank + index;
|
||
const isPodium = globalRank <= 3;
|
||
|
||
return (
|
||
<tr
|
||
key={driver.driver_guid}
|
||
className={`
|
||
border-b border-white/5 hover:bg-white/5 transition-colors
|
||
${isPodium ? 'bg-white/[0.02]' : ''}
|
||
`}
|
||
>
|
||
<td className="px-6 py-4">
|
||
<span className={`
|
||
text-base font-bold tracking-tight
|
||
${globalRank === 1 ? 'text-white' : ''}
|
||
${globalRank === 2 ? 'text-white/90' : ''}
|
||
${globalRank === 3 ? 'text-white/80' : ''}
|
||
${globalRank > 3 ? 'text-white/60' : ''}
|
||
`}>
|
||
{String(globalRank).padStart(2, '0')}
|
||
</span>
|
||
{isPodium && (
|
||
<span className="ml-2 text-xs text-white/40">
|
||
{globalRank === 1 ? '█' : globalRank === 2 ? '▓' : '▒'}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className="tracking-tight text-base" style={{ letterSpacing: "0.04em" }}>
|
||
{driver.driver_name}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className="font-mono text-base">
|
||
{driver.user_rank.toLocaleString()}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-block px-3 py-1 border border-white/20 text-xs font-bold tracking-wider bg-black ${driverClass.color}`}>
|
||
{driverClass.name}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className="text-white/70 font-mono text-sm">
|
||
{driver.laps_completed.toLocaleString()}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className="text-white/40 font-mono text-xs">
|
||
{driver.driver_guid}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="mt-8 flex items-center justify-between border border-white/10 p-4 bg-black">
|
||
<div className="text-sm text-white/60">
|
||
Page {currentPage} of {totalPages} • Showing {startRank}-{Math.min(startRank + pageSize - 1, totalCount)} of {totalCount.toLocaleString()}
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
{/* First Page */}
|
||
{currentPage > 1 && (
|
||
<Link
|
||
href={`/rankings?page=1&pageSize=${pageSize}`}
|
||
className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
|
||
>
|
||
««
|
||
</Link>
|
||
)}
|
||
|
||
{/* Previous Page */}
|
||
{currentPage > 1 && (
|
||
<Link
|
||
href={`/rankings?page=${currentPage - 1}&pageSize=${pageSize}`}
|
||
className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
|
||
>
|
||
‹ PREV
|
||
</Link>
|
||
)}
|
||
|
||
{/* Page Numbers */}
|
||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||
let pageNum;
|
||
if (totalPages <= 5) {
|
||
pageNum = i + 1;
|
||
} else if (currentPage <= 3) {
|
||
pageNum = i + 1;
|
||
} else if (currentPage >= totalPages - 2) {
|
||
pageNum = totalPages - 4 + i;
|
||
} else {
|
||
pageNum = currentPage - 2 + i;
|
||
}
|
||
|
||
return (
|
||
<Link
|
||
key={pageNum}
|
||
href={`/rankings?page=${pageNum}&pageSize=${pageSize}`}
|
||
className={`px-4 py-2 border text-sm transition-colors bg-black ${
|
||
currentPage === pageNum
|
||
? 'border-white text-white'
|
||
: 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
|
||
}`}
|
||
>
|
||
{pageNum}
|
||
</Link>
|
||
);
|
||
})}
|
||
|
||
{/* Next Page */}
|
||
{currentPage < totalPages && (
|
||
<Link
|
||
href={`/rankings?page=${currentPage + 1}&pageSize=${pageSize}`}
|
||
className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
|
||
>
|
||
NEXT ›
|
||
</Link>
|
||
)}
|
||
|
||
{/* Last Page */}
|
||
{currentPage < totalPages && (
|
||
<Link
|
||
href={`/rankings?page=${totalPages}&pageSize=${pageSize}`}
|
||
className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
|
||
>
|
||
»»
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Info Box */}
|
||
<div className="mt-8 border border-white/10 p-6 topo-lines-dense bg-black">
|
||
<h3 className="text-sm font-bold tracking-wider text-white/60 mb-4">RANKING SYSTEM</h3>
|
||
<div className="space-y-2 text-sm text-white/60">
|
||
<p>• Rating increases with <span className="text-white">clean racing</span> and <span className="text-white">fast lap times</span></p>
|
||
<p>• Penalties applied for <span className="text-white/40">track cuts</span> and <span className="text-white/40">collisions</span></p>
|
||
<p>• Class promotion based on consistent performance over multiple sessions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ClassCard({
|
||
name,
|
||
range,
|
||
description
|
||
}: {
|
||
name: string;
|
||
range: string;
|
||
description: string;
|
||
}) {
|
||
return (
|
||
<div className="border border-white/10 p-4 sharp-border">
|
||
<div className="text-xs font-bold tracking-wider text-white/60 mb-2">
|
||
{name}
|
||
</div>
|
||
<div className="text-2xl font-bold tracking-tight mb-1">
|
||
{range}
|
||
</div>
|
||
<div className="text-sm text-white/50">
|
||
{description}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|