631 lines
23 KiB
TypeScript
631 lines
23 KiB
TypeScript
// app/rankings/page.tsx
|
||
// Rankings with pagination, adjustable page size, and filters
|
||
|
||
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;
|
||
driver_team: string | null;
|
||
car_model: string | null;
|
||
user_rank: number;
|
||
laps_completed: number;
|
||
cuts_alltime: number;
|
||
contacts_alltime: number;
|
||
created_at: Date;
|
||
}
|
||
|
||
interface FilterOptions {
|
||
teams: string[];
|
||
carModels: string[];
|
||
}
|
||
|
||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||
|
||
async function getFilterOptions(): Promise<FilterOptions> {
|
||
const teamsQuery = `
|
||
SELECT DISTINCT driver_team
|
||
FROM users
|
||
WHERE driver_team IS NOT NULL AND driver_team != ''
|
||
ORDER BY driver_team
|
||
`;
|
||
|
||
const carsQuery = `
|
||
SELECT DISTINCT car_model
|
||
FROM users
|
||
WHERE car_model IS NOT NULL AND car_model != ''
|
||
ORDER BY car_model
|
||
`;
|
||
|
||
const teams = await query(teamsQuery);
|
||
const cars = await query(carsQuery);
|
||
|
||
return {
|
||
teams: teams.map((t: any) => t.driver_team),
|
||
carModels: cars.map((c: any) => c.car_model)
|
||
};
|
||
}
|
||
|
||
async function getRankings(
|
||
page: number = 1,
|
||
pageSize: number = 50,
|
||
filters: {
|
||
search?: string;
|
||
class?: string;
|
||
team?: string;
|
||
carModel?: string;
|
||
} = {}
|
||
): Promise<{ drivers: RankingDriver[]; totalCount: number }> {
|
||
const offset = (page - 1) * pageSize;
|
||
|
||
// Build WHERE clause
|
||
const conditions: string[] = [];
|
||
|
||
if (filters.search) {
|
||
conditions.push(`LOWER(driver_name) LIKE LOWER('%${filters.search}%')`);
|
||
}
|
||
|
||
if (filters.class) {
|
||
switch (filters.class) {
|
||
case 'S':
|
||
conditions.push('user_rank >= 5000');
|
||
break;
|
||
case 'A':
|
||
conditions.push('user_rank >= 2500 AND user_rank < 5000');
|
||
break;
|
||
case 'B':
|
||
conditions.push('user_rank >= 1750 AND user_rank < 2500');
|
||
break;
|
||
case 'C':
|
||
conditions.push('user_rank >= 1250 AND user_rank < 1750');
|
||
break;
|
||
case 'D':
|
||
conditions.push('user_rank < 1250');
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (filters.team) {
|
||
conditions.push(`driver_team = '${filters.team}'`);
|
||
}
|
||
|
||
if (filters.carModel) {
|
||
conditions.push(`car_model = '${filters.carModel}'`);
|
||
}
|
||
|
||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||
|
||
// Get total count
|
||
const countResult = await query(`SELECT COUNT(*) as count FROM users ${whereClause}`);
|
||
const totalCount = countResult[0]?.count || 0;
|
||
|
||
// Get paginated results
|
||
const sql = `
|
||
SELECT
|
||
driver_guid,
|
||
driver_name,
|
||
driver_team,
|
||
car_model,
|
||
user_rank,
|
||
laps_completed,
|
||
cuts_alltime,
|
||
contacts_alltime,
|
||
created_at
|
||
FROM users
|
||
${whereClause}
|
||
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; code: string } {
|
||
if (rank >= 5000) return { name: 'S CLASS', color: 'text-white', code: 'S' };
|
||
if (rank >= 2500) return { name: 'A CLASS', color: 'text-white', code: 'A' };
|
||
if (rank >= 1750) return { name: 'B CLASS', color: 'text-white/80', code: 'B' };
|
||
if (rank >= 1250) return { name: 'C CLASS', color: 'text-white/60', code: 'C' };
|
||
return { name: 'D CLASS', color: 'text-white/40', code: 'D' };
|
||
}
|
||
|
||
function buildQueryString(params: Record<string, string | undefined>): string {
|
||
const filtered = Object.entries(params)
|
||
.filter(([_, value]) => value !== undefined && value !== '')
|
||
.map(([key, value]) => `${key}=${encodeURIComponent(value!)}`)
|
||
.join('&');
|
||
return filtered ? `?${filtered}` : '';
|
||
}
|
||
|
||
export default async function RankingsPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<{
|
||
page?: string;
|
||
pageSize?: string;
|
||
search?: string;
|
||
class?: string;
|
||
team?: string;
|
||
carModel?: string;
|
||
}>;
|
||
}) {
|
||
const params = await searchParams;
|
||
const currentPage = parseInt(params.page || '1');
|
||
const pageSize = parseInt(params.pageSize || '50');
|
||
const searchQuery = params.search || '';
|
||
const classFilter = params.class || '';
|
||
const teamFilter = params.team || '';
|
||
const carModelFilter = params.carModel || '';
|
||
|
||
const { drivers, totalCount } = await getRankings(currentPage, pageSize, {
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
});
|
||
|
||
const filterOptions = await getFilterOptions();
|
||
|
||
const totalPages = Math.ceil(totalCount / pageSize);
|
||
const startRank = (currentPage - 1) * pageSize + 1;
|
||
|
||
const hasActiveFilters = searchQuery || classFilter || teamFilter || carModelFilter;
|
||
|
||
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()} {hasActiveFilters ? 'filtered' : '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>
|
||
|
||
{/* Filters & Controls */}
|
||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||
<div className="border border-white/10 bg-black p-6 space-y-6">
|
||
{/* Single Filter Form */}
|
||
<form action="/rankings" method="get" className="space-y-6">
|
||
<input type="hidden" name="page" value="1" />
|
||
<input type="hidden" name="pageSize" value={pageSize} />
|
||
|
||
{/* Search Bar */}
|
||
<div>
|
||
<label className="block text-xs font-bold tracking-wider text-white/60 mb-2">
|
||
SEARCH DRIVER
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
name="search"
|
||
defaultValue={searchQuery}
|
||
placeholder="Enter driver name..."
|
||
className="flex-1 px-4 py-3 bg-black border border-white/20 text-white placeholder-white/40 focus:border-white focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter Grid */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{/* Class Filter */}
|
||
<div>
|
||
<label className="block text-xs font-bold tracking-wider text-white/60 mb-2">
|
||
CLASS
|
||
</label>
|
||
<select
|
||
name="class"
|
||
className="w-full px-4 py-3 bg-black border border-white/20 text-white focus:border-white focus:outline-none"
|
||
defaultValue={classFilter}
|
||
>
|
||
<option value="">All Classes</option>
|
||
<option value="S">S CLASS (5000+)</option>
|
||
<option value="A">A CLASS (2500-4999)</option>
|
||
<option value="B">B CLASS (1750-2499)</option>
|
||
<option value="C">C CLASS (1250-1749)</option>
|
||
<option value="D">D CLASS (0-1249)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Team Filter */}
|
||
<div>
|
||
<label className="block text-xs font-bold tracking-wider text-white/60 mb-2">
|
||
TEAM
|
||
</label>
|
||
<select
|
||
name="team"
|
||
className="w-full px-4 py-3 bg-black border border-white/20 text-white focus:border-white focus:outline-none"
|
||
defaultValue={teamFilter}
|
||
>
|
||
<option value="">All Teams</option>
|
||
{filterOptions.teams.map(team => (
|
||
<option key={team} value={team}>{team}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Car Model Filter */}
|
||
<div>
|
||
<label className="block text-xs font-bold tracking-wider text-white/60 mb-2">
|
||
CAR MODEL
|
||
</label>
|
||
<select
|
||
name="carModel"
|
||
className="w-full px-4 py-3 bg-black border border-white/20 text-white focus:border-white focus:outline-none"
|
||
defaultValue={carModelFilter}
|
||
>
|
||
<option value="">All Cars</option>
|
||
{filterOptions.carModels.map(car => (
|
||
<option key={car} value={car}>{car}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Submit Button */}
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
className="px-8 py-3 border border-white/20 hover:border-white bg-black text-white transition-colors"
|
||
>
|
||
APPLY FILTERS
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Active Filters & Clear */}
|
||
{hasActiveFilters && (
|
||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-white/40">ACTIVE FILTERS:</span>
|
||
{searchQuery && (
|
||
<span className="px-2 py-1 bg-white/10 text-xs border border-white/20">
|
||
Search: {searchQuery}
|
||
</span>
|
||
)}
|
||
{classFilter && (
|
||
<span className="px-2 py-1 bg-white/10 text-xs border border-white/20">
|
||
Class: {classFilter}
|
||
</span>
|
||
)}
|
||
{teamFilter && (
|
||
<span className="px-2 py-1 bg-white/10 text-xs border border-white/20">
|
||
Team: {teamFilter}
|
||
</span>
|
||
)}
|
||
{carModelFilter && (
|
||
<span className="px-2 py-1 bg-white/10 text-xs border border-white/20">
|
||
Car: {carModelFilter}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Link
|
||
href={`/rankings?pageSize=${pageSize}`}
|
||
className="px-4 py-2 border border-white/20 hover:border-white text-xs transition-colors"
|
||
>
|
||
CLEAR ALL
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Page Size Selector */}
|
||
<div className="mt-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) => {
|
||
const newParams = buildQueryString({
|
||
page: '1',
|
||
pageSize: size.toString(),
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
});
|
||
return (
|
||
<Link
|
||
key={size}
|
||
href={`/rankings${newParams}`}
|
||
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>
|
||
|
||
{/* Rankings Table */}
|
||
<div className="mt-6 border border-white/10 bg-black overflow-x-auto">
|
||
<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">TEAM</th>
|
||
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">CAR</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">CUTS</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{drivers.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-6 py-12 text-center text-white/40">
|
||
No drivers found matching your filters
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
drivers.map((driver, index) => {
|
||
const driverClass = getDriverClass(driver.user_rank);
|
||
const globalRank = startRank + index;
|
||
const isPodium = globalRank <= 3 && !hasActiveFilters;
|
||
|
||
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 && !hasActiveFilters ? 'text-white' : ''}
|
||
${globalRank === 2 && !hasActiveFilters ? 'text-white/90' : ''}
|
||
${globalRank === 3 && !hasActiveFilters ? 'text-white/80' : ''}
|
||
${(globalRank > 3 || hasActiveFilters) ? '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="text-white/60 text-sm">
|
||
{driver.driver_team || '—'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className="text-white/60 text-sm">
|
||
{driver.car_model || '—'}
|
||
</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/50 font-mono text-sm">
|
||
{driver.cuts_alltime.toLocaleString()}
|
||
</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${buildQueryString({
|
||
page: '1',
|
||
pageSize: pageSize.toString(),
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
})}`}
|
||
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${buildQueryString({
|
||
page: (currentPage - 1).toString(),
|
||
pageSize: pageSize.toString(),
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
})}`}
|
||
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${buildQueryString({
|
||
page: pageNum.toString(),
|
||
pageSize: pageSize.toString(),
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
})}`}
|
||
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${buildQueryString({
|
||
page: (currentPage + 1).toString(),
|
||
pageSize: pageSize.toString(),
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
})}`}
|
||
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${buildQueryString({
|
||
page: totalPages.toString(),
|
||
pageSize: pageSize.toString(),
|
||
search: searchQuery,
|
||
class: classFilter,
|
||
team: teamFilter,
|
||
carModel: carModelFilter
|
||
})}`}
|
||
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>
|
||
);
|
||
}
|