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 // 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 { query } from '@/lib/db';
import { TrophyIcon } from '@/components/ui/icons'; import { TrophyIcon } from '@/components/ui/icons';
@ -9,18 +9,97 @@ export const dynamic = "force-dynamic";
interface RankingDriver { interface RankingDriver {
driver_guid: string; driver_guid: string;
driver_name: string; driver_name: string;
driver_team: string | null;
car_model: string | null;
user_rank: number; user_rank: number;
laps_completed: number; laps_completed: number;
cuts_alltime: number;
contacts_alltime: number;
created_at: Date; created_at: Date;
} }
interface FilterOptions {
teams: string[];
carModels: string[];
}
const PAGE_SIZE_OPTIONS = [25, 50, 100]; 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; 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 // 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; const totalCount = countResult[0]?.count || 0;
// Get paginated results // Get paginated results
@ -28,10 +107,15 @@ async function getRankings(page: number = 1, pageSize: number = 50): Promise<{ d
SELECT SELECT
driver_guid, driver_guid,
driver_name, driver_name,
driver_team,
car_model,
user_rank, user_rank,
laps_completed, laps_completed,
cuts_alltime,
contacts_alltime,
created_at created_at
FROM users FROM users
${whereClause}
ORDER BY user_rank DESC ORDER BY user_rank DESC
LIMIT ${pageSize} OFFSET ${offset} LIMIT ${pageSize} OFFSET ${offset}
`; `;
@ -43,28 +127,56 @@ async function getRankings(page: number = 1, pageSize: number = 50): Promise<{ d
}; };
} }
function getDriverClass(rank: number): { name: string; color: string } { function getDriverClass(rank: number): { name: string; color: string; code: string } {
if (rank >= 5000) return { name: 'S CLASS', color: 'text-white' }; if (rank >= 5000) return { name: 'S CLASS', color: 'text-white', code: 'S' };
if (rank >= 2500) return { name: 'A CLASS', color: 'text-white' }; if (rank >= 2500) return { name: 'A CLASS', color: 'text-white', code: 'A' };
if (rank >= 1750) return { name: 'B CLASS', color: 'text-white/80' }; if (rank >= 1750) return { name: 'B CLASS', color: 'text-white/80', code: 'B' };
if (rank >= 1250) return { name: 'C CLASS', color: 'text-white/60' }; if (rank >= 1250) return { name: 'C CLASS', color: 'text-white/60', code: 'C' };
return { name: 'D CLASS', color: 'text-white/40' }; 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({ export default async function RankingsPage({
searchParams, 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 params = await searchParams;
const currentPage = parseInt(params.page || '1'); const currentPage = parseInt(params.page || '1');
const pageSize = parseInt(params.pageSize || '50'); 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 totalPages = Math.ceil(totalCount / pageSize);
const startRank = (currentPage - 1) * pageSize + 1; const startRank = (currentPage - 1) * pageSize + 1;
const hasActiveFilters = searchQuery || classFilter || teamFilter || carModelFilter;
return ( return (
<> <>
{/* Hero */} {/* Hero */}
@ -82,7 +194,7 @@ export default async function RankingsPage({
Global driver rankings based on performance, consistency, and clean racing Global driver rankings based on performance, consistency, and clean racing
</p> </p>
<p className="text-white/40 text-sm"> <p className="text-white/40 text-sm">
{totalCount.toLocaleString()} total drivers {totalCount.toLocaleString()} {hasActiveFilters ? 'filtered' : 'total'} drivers
</p> </p>
</div> </div>
@ -112,18 +224,152 @@ export default async function RankingsPage({
</div> </div>
</div> </div>
{/* Rankings Table */} {/* Filters & Controls */}
<div className="max-w-7xl mx-auto px-6 py-12"> <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 */} {/* 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"> <div className="text-sm text-white/60">
Items per page: Items per page:
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{PAGE_SIZE_OPTIONS.map((size) => ( {PAGE_SIZE_OPTIONS.map((size) => {
const newParams = buildQueryString({
page: '1',
pageSize: size.toString(),
search: searchQuery,
class: classFilter,
team: teamFilter,
carModel: carModelFilter
});
return (
<Link <Link
key={size} key={size}
href={`/rankings?page=1&pageSize=${size}`} href={`/rankings${newParams}`}
className={`px-4 py-2 border text-sm transition-colors bg-black ${ className={`px-4 py-2 border text-sm transition-colors bg-black ${
pageSize === size pageSize === size
? 'border-white text-white' ? 'border-white text-white'
@ -132,27 +378,38 @@ export default async function RankingsPage({
> >
{size} {size}
</Link> </Link>
))} );
})}
</div> </div>
</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"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-white/10 bg-white/5"> <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">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">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">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">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">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> </tr>
</thead> </thead>
<tbody> <tbody>
{drivers.map((driver, index) => { {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 driverClass = getDriverClass(driver.user_rank);
const globalRank = startRank + index; const globalRank = startRank + index;
const isPodium = globalRank <= 3; const isPodium = globalRank <= 3 && !hasActiveFilters;
return ( return (
<tr <tr
@ -165,10 +422,10 @@ export default async function RankingsPage({
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={` <span className={`
text-base font-bold tracking-tight text-base font-bold tracking-tight
${globalRank === 1 ? 'text-white' : ''} ${globalRank === 1 && !hasActiveFilters ? 'text-white' : ''}
${globalRank === 2 ? 'text-white/90' : ''} ${globalRank === 2 && !hasActiveFilters ? 'text-white/90' : ''}
${globalRank === 3 ? 'text-white/80' : ''} ${globalRank === 3 && !hasActiveFilters ? 'text-white/80' : ''}
${globalRank > 3 ? 'text-white/60' : ''} ${(globalRank > 3 || hasActiveFilters) ? 'text-white/60' : ''}
`}> `}>
{String(globalRank).padStart(2, '0')} {String(globalRank).padStart(2, '0')}
</span> </span>
@ -183,6 +440,16 @@ export default async function RankingsPage({
{driver.driver_name} {driver.driver_name}
</span> </span>
</td> </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"> <td className="px-6 py-4">
<span className="font-mono text-base"> <span className="font-mono text-base">
{driver.user_rank.toLocaleString()} {driver.user_rank.toLocaleString()}
@ -199,13 +466,14 @@ export default async function RankingsPage({
</span> </span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="text-white/40 font-mono text-xs"> <span className="text-white/50 font-mono text-sm">
{driver.driver_guid} {driver.cuts_alltime.toLocaleString()}
</span> </span>
</td> </td>
</tr> </tr>
); );
})} })
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -220,7 +488,14 @@ export default async function RankingsPage({
{/* First Page */} {/* First Page */}
{currentPage > 1 && ( {currentPage > 1 && (
<Link <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" 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 */} {/* Previous Page */}
{currentPage > 1 && ( {currentPage > 1 && (
<Link <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" className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
> >
PREV PREV
@ -253,7 +535,14 @@ export default async function RankingsPage({
return ( return (
<Link <Link
key={pageNum} 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 ${ className={`px-4 py-2 border text-sm transition-colors bg-black ${
currentPage === pageNum currentPage === pageNum
? 'border-white text-white' ? 'border-white text-white'
@ -268,7 +557,14 @@ export default async function RankingsPage({
{/* Next Page */} {/* Next Page */}
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link <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" className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
> >
NEXT NEXT
@ -278,7 +574,14 @@ export default async function RankingsPage({
{/* Last Page */} {/* Last Page */}
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link <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" className="px-3 py-2 border border-white/20 hover:border-white/40 transition-colors text-sm bg-black"
> >
»» »»