feat: filters in Ranking page

This commit is contained in:
Afonso Clerigo Mendes de Sousa 2026-01-07 01:32:07 +00:00
parent 81b497517e
commit 8e8d59c335

View File

@ -1,5 +1,5 @@
// app/rankings/page.tsx
// Rankings with pagination and adjustable page size
// Rankings with pagination, adjustable page size, and filters
import { query } from '@/lib/db';
import { TrophyIcon } from '@/components/ui/icons';
@ -9,18 +9,97 @@ 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 getRankings(page: number = 1, pageSize: number = 50): Promise<{ drivers: RankingDriver[]; totalCount: number }> {
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`);
const countResult = await query(`SELECT COUNT(*) as count FROM users ${whereClause}`);
const totalCount = countResult[0]?.count || 0;
// Get paginated results
@ -28,10 +107,15 @@ async function getRankings(page: number = 1, pageSize: number = 50): Promise<{ d
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}
`;
@ -43,27 +127,55 @@ async function getRankings(page: number = 1, pageSize: number = 50): Promise<{ d
};
}
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' };
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 }>;
searchParams: Promise<{
page?: string;
pageSize?: string;
search?: string;
class?: string;
team?: string;
carModel?: string;
}>;
}) {
// Await searchParams in Next.js 15+
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 { drivers, totalCount } = await getRankings(currentPage, pageSize);
const totalPages = Math.ceil(totalCount / pageSize);
const startRank = (currentPage - 1) * pageSize + 1;
const hasActiveFilters = searchQuery || classFilter || teamFilter || carModelFilter;
return (
<>
@ -82,7 +194,7 @@ export default async function RankingsPage({
Global driver rankings based on performance, consistency, and clean racing
</p>
<p className="text-white/40 text-sm">
{totalCount.toLocaleString()} total drivers
{totalCount.toLocaleString()} {hasActiveFilters ? 'filtered' : 'total'} drivers
</p>
</div>
@ -112,100 +224,256 @@ export default async function RankingsPage({
</div>
</div>
{/* Rankings Table */}
<div className="max-w-7xl mx-auto px-6 py-12">
{/* 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="mb-6 flex items-center justify-between border border-white/10 p-4 bg-black">
<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) => (
<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>
))}
{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>
<div className="border border-white/10 bg-black">
{/* 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">STEAM ID</th>
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">CUTS</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 ? '▓' : '▒'}
{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>
)}
</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>
);
})}
{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>
@ -220,7 +488,14 @@ export default async function RankingsPage({
{/* First Page */}
{currentPage > 1 && (
<Link
href={`/rankings?page=1&pageSize=${pageSize}`}
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"
>
««
@ -230,7 +505,14 @@ export default async function RankingsPage({
{/* Previous Page */}
{currentPage > 1 && (
<Link
href={`/rankings?page=${currentPage - 1}&pageSize=${pageSize}`}
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
@ -253,7 +535,14 @@ export default async function RankingsPage({
return (
<Link
key={pageNum}
href={`/rankings?page=${pageNum}&pageSize=${pageSize}`}
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'
@ -268,7 +557,14 @@ export default async function RankingsPage({
{/* Next Page */}
{currentPage < totalPages && (
<Link
href={`/rankings?page=${currentPage + 1}&pageSize=${pageSize}`}
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
@ -278,7 +574,14 @@ export default async function RankingsPage({
{/* Last Page */}
{currentPage < totalPages && (
<Link
href={`/rankings?page=${totalPages}&pageSize=${pageSize}`}
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"
>
»»