631 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}