FODASSE QUE BORRAÇO

This commit is contained in:
2025-10-25 23:16:38 +01:00
parent 2f641217ce
commit ede5003530
21 changed files with 2745 additions and 143 deletions
+322
View File
@@ -0,0 +1,322 @@
// app/api/drivers/route.ts
// API Route: GET /api/drivers
// Returns all connected drivers with their server info
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { DriverWithServer } from '@/types/racing';
export async function GET(request: Request) {
try {
// Parse query params (e.g., ?connected=true)
const { searchParams } = new URL(request.url);
const connectedOnly = searchParams.get('connected') === 'true';
// SQL query - joins drivers with servers
const sql = `
SELECT
u.driver_guid,
u.driver_name,
u.driver_team,
u.car_model,
u.car_skin,
u.cuts_alltime,
u.contacts_alltime,
u.laps_completed,
u.user_rank,
u.is_connect,
u.is_loading,
u.current_server,
u.created_at,
s.server_id,
s.server_name,
s.server_track,
s.session_type,
s.session_flag,
s.connected_players
FROM users u
LEFT JOIN servers s ON u.current_server = s.server_id
${connectedOnly ? 'WHERE u.is_connect = true' : ''}
ORDER BY u.user_rank ASC
`;
const rows = await query(sql);
// Transform to proper structure
const drivers: DriverWithServer[] = rows.map((row: any) => ({
driver_guid: row.driver_guid,
driver_name: row.driver_name,
driver_team: row.driver_team,
car_model: row.car_model,
car_skin: row.car_skin,
cuts_alltime: row.cuts_alltime,
contacts_alltime: row.contacts_alltime,
laps_completed: row.laps_completed,
user_rank: row.user_rank,
is_connect: row.is_connect,
is_loading: row.is_loading,
current_server: row.current_server,
created_at: row.created_at,
// Include server info if they're connected
server: row.server_id ? {
server_id: row.server_id,
server_name: row.server_name,
server_track: row.server_track,
session_type: row.session_type,
session_flag: row.session_flag,
connected_players: row.connected_players,
} : undefined,
}));
return NextResponse.json({
success: true,
data: drivers,
count: drivers.length,
});
} catch (error) {
console.error('Error fetching drivers:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch drivers'
},
{ status: 500 }
);
}
}
// POST endpoint for updating driver data (optional, for future use)
export async function POST(request: Request) {
try {
const body = await request.json();
// Add your update logic here
// Example: Update driver rank, stats, etc.
return NextResponse.json({
success: true,
message: 'Driver updated',
});
} catch (error) {
console.error('Error updating driver:', error);
return NextResponse.json(
{ success: false, error: 'Failed to update driver' },
{ status: 500 }
);
}
}// app/api/drivers/route.ts
// API Route: GET /api/drivers
// Returns all connected drivers with their server info
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { DriverWithServer } from '@/types/racing';
export async function GET(request: Request) {
try {
// Parse query params (e.g., ?connected=true)
const { searchParams } = new URL(request.url);
const connectedOnly = searchParams.get('connected') === 'true';
// SQL query - joins drivers with servers
const sql = `
SELECT
u.driver_guid,
u.driver_name,
u.driver_team,
u.car_model,
u.car_skin,
u.cuts_alltime,
u.contacts_alltime,
u.laps_completed,
u.user_rank,
u.is_connect,
u.is_loading,
u.current_server,
u.created_at,
s.server_id,
s.server_name,
s.server_track,
s.session_type,
s.session_flag,
s.connected_players
FROM users u
LEFT JOIN servers s ON u.current_server = s.server_id
${connectedOnly ? 'WHERE u.is_connect = true' : ''}
ORDER BY u.user_rank ASC
`;
const rows = await query(sql);
// Transform to proper structure
const drivers: DriverWithServer[] = rows.map((row: any) => ({
driver_guid: row.driver_guid,
driver_name: row.driver_name,
driver_team: row.driver_team,
car_model: row.car_model,
car_skin: row.car_skin,
cuts_alltime: row.cuts_alltime,
contacts_alltime: row.contacts_alltime,
laps_completed: row.laps_completed,
user_rank: row.user_rank,
is_connect: row.is_connect,
is_loading: row.is_loading,
current_server: row.current_server,
created_at: row.created_at,
// Include server info if they're connected
server: row.server_id ? {
server_id: row.server_id,
server_name: row.server_name,
server_track: row.server_track,
session_type: row.session_type,
session_flag: row.session_flag,
connected_players: row.connected_players,
} : undefined,
}));
return NextResponse.json({
success: true,
data: drivers,
count: drivers.length,
});
} catch (error) {
console.error('Error fetching drivers:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch drivers'
},
{ status: 500 }
);
}
}
// POST endpoint for updating driver data (optional, for future use)
export async function POST(request: Request) {
try {
const body = await request.json();
// Add your update logic here
// Example: Update driver rank, stats, etc.
return NextResponse.json({
success: true,
message: 'Driver updated',
});
} catch (error) {
console.error('Error updating driver:', error);
return NextResponse.json(
{ success: false, error: 'Failed to update driver' },
{ status: 500 }
);
}
}// app/api/drivers/route.ts
// API Route: GET /api/drivers
// Returns all connected drivers with their server info
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { DriverWithServer } from '@/types/racing';
export async function GET(request: Request) {
try {
// Parse query params (e.g., ?connected=true)
const { searchParams } = new URL(request.url);
const connectedOnly = searchParams.get('connected') === 'true';
// SQL query - joins drivers with servers
const sql = `
SELECT
u.driver_guid,
u.driver_name,
u.driver_team,
u.car_model,
u.car_skin,
u.cuts_alltime,
u.contacts_alltime,
u.laps_completed,
u.user_rank,
u.is_connect,
u.is_loading,
u.current_server,
u.created_at,
s.server_id,
s.server_name,
s.server_track,
s.session_type,
s.session_flag,
s.connected_players
FROM users u
LEFT JOIN servers s ON u.current_server = s.server_id
${connectedOnly ? 'WHERE u.is_connect = true' : ''}
ORDER BY u.user_rank ASC
`;
const rows = await query(sql);
// Transform to proper structure
const drivers: DriverWithServer[] = rows.map((row: any) => ({
driver_guid: row.driver_guid,
driver_name: row.driver_name,
driver_team: row.driver_team,
car_model: row.car_model,
car_skin: row.car_skin,
cuts_alltime: row.cuts_alltime,
contacts_alltime: row.contacts_alltime,
laps_completed: row.laps_completed,
user_rank: row.user_rank,
is_connect: row.is_connect,
is_loading: row.is_loading,
current_server: row.current_server,
created_at: row.created_at,
// Include server info if they're connected
server: row.server_id ? {
server_id: row.server_id,
server_name: row.server_name,
server_track: row.server_track,
session_type: row.session_type,
session_flag: row.session_flag,
connected_players: row.connected_players,
} : undefined,
}));
return NextResponse.json({
success: true,
data: drivers,
count: drivers.length,
});
} catch (error) {
console.error('Error fetching drivers:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch drivers'
},
{ status: 500 }
);
}
}
// POST endpoint for updating driver data (optional, for future use)
export async function POST(request: Request) {
try {
const body = await request.json();
// Add your update logic here
// Example: Update driver rank, stats, etc.
return NextResponse.json({
success: true,
message: 'Driver updated',
});
} catch (error) {
console.error('Error updating driver:', error);
return NextResponse.json(
{ success: false, error: 'Failed to update driver' },
{ status: 500 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
// app/api/events/register/route.ts
// API endpoint for event registration
import { NextResponse } from 'next/server';
import { query, queryOne } from '@/lib/db';
export async function POST(request: Request) {
try {
const body = await request.json();
const { eventId, steamId, carModel, carSkin, teamName } = body;
// Validate required fields
if (!eventId || !steamId || !carModel) {
return NextResponse.json(
{ success: false, error: 'Missing required fields' },
{ status: 400 }
);
}
// Convert steamId to number (BIGINT)
const driverGuid = parseInt(steamId);
if (isNaN(driverGuid)) {
return NextResponse.json(
{ success: false, error: 'Invalid Steam ID format' },
{ status: 400 }
);
}
// Check if user exists in database
const userCheck = await queryOne(
'SELECT driver_guid FROM users WHERE driver_guid = $1',
[driverGuid]
);
if (!userCheck) {
return NextResponse.json(
{ success: false, error: 'Steam ID not found in database. Please join a server first.' },
{ status: 404 }
);
}
// Check if event exists and is open
const eventCheck: any = await queryOne(
`SELECT event_id, event_status, max_participants,
(SELECT COUNT(*) FROM event_registrations
WHERE event_id = $1 AND status = 'REGISTERED') as current_registrations
FROM events WHERE event_id = $1`,
[eventId]
);
if (!eventCheck) {
return NextResponse.json(
{ success: false, error: 'Event not found' },
{ status: 404 }
);
}
if (eventCheck.event_status !== 'OPEN') {
return NextResponse.json(
{ success: false, error: 'Event registration is closed' },
{ status: 400 }
);
}
if (eventCheck.current_registrations >= eventCheck.max_participants) {
return NextResponse.json(
{ success: false, error: 'Event is full' },
{ status: 400 }
);
}
// Check if already registered
const existingReg = await queryOne(
'SELECT registration_id FROM event_registrations WHERE event_id = $1 AND driver_guid = $2',
[eventId, driverGuid]
);
if (existingReg) {
return NextResponse.json(
{ success: false, error: 'You are already registered for this event' },
{ status: 400 }
);
}
// Insert registration
await query(
`INSERT INTO event_registrations
(event_id, driver_guid, car_model, car_skin, team_name, status)
VALUES ($1, $2, $3, $4, $5, 'REGISTERED')`,
[eventId, driverGuid, carModel, carSkin || null, teamName || null]
);
return NextResponse.json({
success: true,
message: 'Registration successful',
});
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ success: false, error: 'Registration failed. Please try again.' },
{ status: 500 }
);
}
}
+235
View File
@@ -0,0 +1,235 @@
// app/dashboard/page.tsx
// Dashboard - navbar/footer now in layout.tsx
import { query } from '@/lib/db';
import { DriverWithServer } from '@/types/racing';
import { UsersIcon, ServerIcon, ActivityIcon, MapPinIcon, FlagIcon, LiveDotIcon } from '@/components/ui/icons';
async function getConnectedDrivers(): Promise<DriverWithServer[]> {
const sql = `
SELECT
u.driver_guid,
u.driver_name,
u.driver_team,
u.car_model,
u.car_skin,
u.user_rank,
u.laps_completed,
s.server_id,
s.server_name,
s.server_track,
s.session_flag,
s.connected_players
FROM users u
INNER JOIN servers s ON u.current_server = s.server_id
WHERE u.is_connect = true
ORDER BY s.server_id, u.user_rank ASC
`;
const rows = await query(sql);
return rows.map((row: any) => ({
driver_guid: row.driver_guid,
driver_name: row.driver_name,
driver_team: row.driver_team,
car_model: row.car_model,
car_skin: row.car_skin,
cuts_alltime: 0,
contacts_alltime: 0,
laps_completed: row.laps_completed,
user_rank: row.user_rank,
is_connect: true,
is_loading: false,
current_server: row.server_id,
created_at: new Date(),
server: {
server_id: row.server_id,
server_name: row.server_name,
server_track: row.server_track,
session_type: 0,
session_flag: row.session_flag,
connected_players: row.connected_players,
},
}));
}
export default async function DashboardPage() {
const drivers = await getConnectedDrivers();
const serverGroups = drivers.reduce((acc, driver) => {
const serverId = driver.server?.server_id ?? 0;
if (!acc[serverId]) {
acc[serverId] = [];
}
acc[serverId].push(driver);
return acc;
}, {} as Record<number, DriverWithServer[]>);
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">
<LiveDotIcon className="w-2 h-2 text-white animate-pulse" />
<span className="text-xs font-medium tracking-wider">LIVE SYSTEM</span>
</div>
<h1 className="text-6xl font-bold tracking-tight">
LIVE DASHBOARD
</h1>
<p className="text-white/60 text-lg max-w-2xl">
Real-time telemetry and session monitoring across all OpenWheels Racing infrastructure
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-12">
<StatCard
title="DRIVERS ONLINE"
value={drivers.length}
icon={<UsersIcon className="w-8 h-8" />}
/>
<StatCard
title="ACTIVE SERVERS"
value={Object.keys(serverGroups).length}
icon={<ServerIcon className="w-8 h-8" />}
/>
<StatCard
title="TOTAL LAPS"
value={drivers.reduce((sum, d) => sum + d.laps_completed, 0)}
icon={<ActivityIcon className="w-8 h-8" />}
/>
</div>
</div>
</div>
{/* Server Listings */}
<div className="max-w-7xl mx-auto px-6 py-12 space-y-6">
{Object.keys(serverGroups).length === 0 ? (
<div className="border border-white/10 p-16 text-center bg-black">
<div className="w-16 h-16 border-2 border-white/20 mx-auto mb-6"></div>
<p className="text-white/40 text-base tracking-wider">NO ACTIVE SESSIONS</p>
<p className="text-white/20 text-sm mt-2">System idle waiting for connections</p>
</div>
) : (
Object.entries(serverGroups).map(([serverId, serverDrivers]) => {
const server = serverDrivers[0].server;
return (
<div key={serverId} className="border border-white/10 sharp-border bg-black">
{/* Server Header */}
<div className="border-b border-white/10 p-6 topo-lines-dense">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-3">
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2 px-2 py-1 border border-white/30 text-xs">
<LiveDotIcon className="w-2 h-2 animate-pulse" />
<span className="font-medium tracking-wider">LIVE</span>
</div>
<span className="text-xs text-white/40 tracking-wider">
ID: {server?.server_id}
</span>
</div>
<h2 className="text-2xl font-bold tracking-tight">
{server?.server_name}
</h2>
<div className="flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<MapPinIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">{server?.server_track}</span>
</div>
<div className="flex items-center space-x-2">
<FlagIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">{server?.session_flag}</span>
</div>
<div className="flex items-center space-x-2">
<UsersIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">{server?.connected_players} CONNECTED</span>
</div>
</div>
</div>
</div>
</div>
{/* Driver Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">POS</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">RANK</th>
<th className="px-6 py-4 text-left text-xs font-bold tracking-wider text-white/60">LAPS</th>
</tr>
</thead>
<tbody>
{serverDrivers.map((driver, index) => (
<tr
key={driver.driver_guid}
className="border-b border-white/5 hover:bg-white/5 transition-colors"
>
<td className="px-6 py-4">
<span className="text-base font-bold tracking-tight">
{String(index + 1).padStart(2, '0')}
</span>
</td>
<td className="px-6 py-4">
<span className="font-semibold tracking-tight text-base">{driver.driver_name}</span>
</td>
<td className="px-6 py-4">
<span className="text-white/50 text-sm">
{driver.driver_team || '—'}
</span>
</td>
<td className="px-6 py-4">
<span className="text-white/70 text-sm font-mono tracking-tight">
{driver.car_model}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-block px-3 py-1 border border-white/20 text-sm font-mono">
{driver.user_rank}
</span>
</td>
<td className="px-6 py-4">
<span className="font-mono text-sm">{driver.laps_completed}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})
)}
</div>
</>
);
}
function StatCard({
title,
value,
icon
}: {
title: string;
value: number;
icon: React.ReactNode;
}) {
return (
<div className="border border-white/10 p-6 sharp-border">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold tracking-wider text-white/60">{title}</span>
<div className="text-white/40">
{icon}
</div>
</div>
<div className="text-5xl font-bold tracking-tight">
{value.toLocaleString()}
</div>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
// app/events/[id]/page.tsx
// Event detail page with registration form
import { query } from '@/lib/db';
import { Event, EventRegistration } from '@/types/racing';
import { notFound } from 'next/navigation';
import { TrophyIcon, MapPinIcon, UsersIcon } from '@/components/ui/icons';
import EventRegistrationForm from '@/components/events/EventRegistrationForm';
async function getEvent(eventId: number): Promise<Event | null> {
const sql = `
SELECT e.*, COUNT(er.registration_id) as registrations_count
FROM events e
LEFT JOIN event_registrations er ON e.event_id = er.event_id AND er.status = 'REGISTERED'
WHERE e.event_id = $1
GROUP BY e.event_id
`;
const rows = await query(sql, [eventId]);
return rows.length > 0 ? rows[0] as Event : null;
}
async function getEventRegistrations(eventId: number): Promise<EventRegistration[]> {
const sql = `
SELECT
er.*,
u.driver_name
FROM event_registrations er
JOIN users u ON er.driver_guid = u.driver_guid
WHERE er.event_id = $1 AND er.status = 'REGISTERED'
ORDER BY er.registration_date ASC
`;
const rows = await query(sql, [eventId]);
return rows as EventRegistration[];
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
export default async function EventDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const eventId = parseInt(id);
const event: any = await getEvent(eventId);
if (!event) {
notFound();
}
const registrations = await getEventRegistrations(eventId);
const isOpen = event.event_status === 'OPEN';
const isFull = event.registrations_count >= event.max_participants;
const deadlinePassed = event.registration_deadline && new Date(event.registration_deadline) < new Date();
const canRegister = isOpen && !isFull && !deadlinePassed;
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">EVENT DETAILS</span>
</div>
<h1 className="text-5xl font-bold tracking-tight">
{event.event_name}
</h1>
<p className="text-white/70 text-lg max-w-3xl">
{event.event_description}
</p>
</div>
{/* Event Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-12">
<InfoCard label="TRACK" value={event.event_track} icon={<MapPinIcon className="w-6 h-6" />} />
<InfoCard label="DATE" value={formatDate(event.event_date)} icon="📅" />
<InfoCard label="DURATION" value={`${event.event_duration || 'TBD'} min`} icon="⏱️" />
<InfoCard
label="PARTICIPANTS"
value={`${event.registrations_count}/${event.max_participants}`}
icon={<UsersIcon className="w-6 h-6" />}
/>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Registration */}
<div className="lg:col-span-2 space-y-6">
{/* Registration Form */}
{canRegister ? (
<div className="border border-white/10 bg-black p-6">
<h2 className="text-2xl font-bold tracking-tight mb-6">REGISTER FOR EVENT</h2>
<EventRegistrationForm eventId={event.event_id} />
</div>
) : (
<div className="border border-white/10 bg-black p-6">
<h2 className="text-2xl font-bold tracking-tight mb-4">REGISTRATION CLOSED</h2>
<p className="text-white/60">
{isFull && 'This event is full.'}
{deadlinePassed && !isFull && 'Registration deadline has passed.'}
{event.event_status === 'CLOSED' && !isFull && !deadlinePassed && 'Registration is closed for this event.'}
</p>
</div>
)}
{/* Event Rules */}
{event.event_rules && (
<div className="border border-white/10 bg-black p-6 topo-lines-dense">
<h3 className="text-xl font-bold tracking-tight mb-4">EVENT RULES</h3>
<div className="text-white/70 text-sm space-y-2 whitespace-pre-line">
{event.event_rules}
</div>
</div>
)}
</div>
{/* Right Column - Registered Participants */}
<div className="lg:col-span-1">
<div className="border border-white/10 bg-black p-6 sticky top-20">
<h3 className="text-xl font-bold tracking-tight mb-4">
REGISTERED DRIVERS ({event.registrations_count})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{registrations.length === 0 ? (
<p className="text-white/40 text-sm">No registrations yet</p>
) : (
registrations.map((reg: any, index: number) => (
<div key={reg.registration_id} className="border border-white/10 p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2">
<span className="text-xs font-bold text-white/40">
#{String(index + 1).padStart(2, '0')}
</span>
<span className="font-semibold text-sm">{reg.driver_name}</span>
</div>
</div>
<div className="space-y-1 text-xs text-white/60">
<div>Car: <span className="text-white/80 font-mono">{reg.car_model}</span></div>
{reg.car_skin && <div>Skin: <span className="text-white/80">{reg.car_skin}</span></div>}
{reg.team_name && <div>Team: <span className="text-white/80">{reg.team_name}</span></div>}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
</>
);
}
function InfoCard({
label,
value,
icon
}: {
label: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="border border-white/10 p-4 sharp-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold tracking-wider text-white/60">{label}</span>
<div className="text-white/40 text-sm">
{icon}
</div>
</div>
<div className="text-lg font-bold tracking-tight">
{value}
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
// app/events/page.tsx
// Events listing page
import { query } from '@/lib/db';
import { Event } from '@/types/racing';
import Link from 'next/link';
import { TrophyIcon, UsersIcon, MapPinIcon, ClockIcon, CalendarIcon } from '@/components/ui/icons';
async function getEvents(): Promise<Event[]> {
const sql = `
SELECT
e.*,
COUNT(er.registration_id) as registrations_count
FROM events e
LEFT JOIN event_registrations er ON e.event_id = er.event_id AND er.status = 'REGISTERED'
WHERE e.event_status IN ('OPEN', 'CLOSED')
GROUP BY e.event_id
ORDER BY e.event_date ASC
`;
const rows = await query(sql);
return rows as Event[];
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
export default async function EventsPage() {
const events = await getEvents();
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">RACING EVENTS</span>
</div>
<h1 className="text-6xl font-bold tracking-tight">
EVENTS
</h1>
<p className="text-white/60 text-lg max-w-3xl">
Join competitive racing events, championships, and special races. Register now to secure your spot.
</p>
</div>
</div>
</div>
{/* Events List */}
<div className="max-w-7xl mx-auto px-6 py-12 space-y-6">
{events.length === 0 ? (
<div className="border border-white/10 p-16 text-center bg-black">
<TrophyIcon className="w-16 h-16 mx-auto mb-6 text-white/20" />
<p className="text-white/40 text-base tracking-wider">NO UPCOMING EVENTS</p>
<p className="text-white/20 text-sm mt-2">Check back soon for new racing events</p>
</div>
) : (
events.map((event: any) => {
const isOpen = event.event_status === 'OPEN';
const isFull = event.registrations_count >= event.max_participants;
const deadlinePassed = event.registration_deadline && new Date(event.registration_deadline) < new Date();
const canRegister = isOpen && !isFull && !deadlinePassed;
return (
<div key={event.event_id} className="border border-white/10 sharp-border bg-black">
<img
src="https://openwheels.racing/files/img/EnduranceEvents1-1.png"
alt={event.event_name}
className="w-full h-48 object-cover object-[50%_0%] border-b border-white/10 grayscale hover:grayscale-0 transition duration-300"
/>
{/* Event Header */}
<div className="border-b border-white/10 p-6 topo-lines-dense">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-3">
<div className="flex items-center space-x-3">
<div className={`flex items-center space-x-2 px-2 py-1 border text-xs ${
canRegister
? 'border-white/30 text-white'
: 'border-white/10 text-white/40'
}`}>
<span className="font-medium tracking-wider">{event.event_status}</span>
</div>
<span className="text-xs text-white/40 tracking-wider">
ID: {event.event_id}
</span>
</div>
<h2 className="text-2xl font-bold tracking-tight">
{event.event_name}
</h2>
<p className="text-white/70 text-sm max-w-3xl">
{event.event_description}
</p>
<div className="flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<MapPinIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">{event.event_track}</span>
</div>
<div className="flex items-center space-x-2">
<UsersIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">
{event.registrations_count}/{event.max_participants} registered
</span>
</div>
<div className="flex items-center space-x-2">
<CalendarIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">
{formatDate(event.event_date)}
</span>
</div>
{event.event_duration && (
<div className="flex items-center space-x-2">
<ClockIcon className="w-4 h-4 text-white/60" />
<span className="text-white/80 text-sm">
{event.event_duration} minutes
</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Event Actions */}
<div className="p-6 flex items-center justify-between">
<div className="text-sm text-white/60">
{deadlinePassed && <span>Registration deadline passed</span>}
{isFull && !deadlinePassed && <span>Event is full</span>}
{!canRegister && !deadlinePassed && !isFull && event.event_status === 'CLOSED' && <span>Registration closed</span>}
</div>
<Link
href={`/events/${event.event_id}`}
className={`px-6 py-3 border text-sm font-medium transition-all ${
canRegister
? 'border-white hover:bg-white hover:text-black'
: 'border-white/20 text-white/60'
}`}
>
{canRegister ? 'REGISTER NOW' : 'VIEW DETAILS'}
</Link>
</div>
</div>
);
})
)}
</div>
</>
);
}
+67 -22
View File
@@ -1,26 +1,71 @@
@import url('https://fonts.googleapis.com/css2?family=BBH+Sans+Bartle&family=BBH+Sans+Bogle&display=swap');
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
/* Base styles */
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background-color: #0a0a0a;
color: #fff;
font-family: 'BBH Sans Bogle', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'BBH Sans Bartle', sans-serif;
letter-spacing: -0.02em;
}
/* Topographic line pattern */
.topo-lines {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 50px,
rgba(255, 255, 255, 0.03) 50px,
rgba(255, 255, 255, 0.03) 51px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 50px,
rgba(255, 255, 255, 0.03) 50px,
rgba(255, 255, 255, 0.03) 51px
);
}
.topo-lines-dense {
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 20px,
rgba(255, 255, 255, 0.02) 20px,
rgba(255, 255, 255, 0.02) 21px
),
repeating-linear-gradient(
-45deg,
transparent,
transparent 20px,
rgba(255, 255, 255, 0.02) 20px,
rgba(255, 255, 255, 0.02) 21px
);
}
/* Sharp borders and hover effects */
.sharp-border {
border: 1px solid rgba(255, 255, 255, 0.1);
background: #000;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.sharp-border:hover {
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
/* Grid overlay effect */
.grid-overlay {
background-image:
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 30px 30px;
}
+60 -19
View File
@@ -1,34 +1,75 @@
// app/layout.tsx
// Root layout with navbar and footer (like Laravel layouts!)
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Navbar from "@/components/navbar";
import InteractiveTopo from "@/components/interactiveTopo";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "OpenWheels Racing",
description: "Free racing community - Live dashboard and rankings",
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<body>
<div className="min-h-screen bg-[#0a0a0a] flex flex-col relative">
{/* Interactive topology effect */}
<InteractiveTopo />
{/* Static topology background */}
<div className="fixed inset-0 topo-lines pointer-events-none z-0" />
{/* Content wrapper */}
<div className="relative z-10 flex flex-col min-h-screen">
{/* Navbar - appears on all pages */}
<Navbar />
{/* Page content */}
<main className="flex-1">
{children}
</main>
{/* Footer - appears on all pages */}
<Footer />
</div>
</div>
</body>
</html>
);
}
function Footer() {
return (
<footer className="border-t border-white/10 mt-20">
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-3">
<img
src="https://openwheels.racing/files/img/Openwheels_small.svg"
alt="OW"
className="h-6 w-6 brightness-0 invert opacity-50"
/>
<span className="text-white/40 tracking-wider">
© 2025 OPENWHEELS.RACING
</span>
</div>
<div className="flex items-center space-x-6 text-white/40">
<a href="https://discord.gg/nvuB8EvT9P" className="hover:text-white transition-colors">
DISCORD
</a>
<a href="https://openwheels.racing" className="hover:text-white transition-colors">
WEBSITE
</a>
</div>
</div>
</div>
</footer>
);
}
+206 -57
View File
@@ -1,65 +1,214 @@
import Image from "next/image";
// app/page.tsx
// Homepage - navbar/footer now in layout.tsx
import Link from 'next/link';
import { query } from '@/lib/db';
import { TrophyIcon, ChartIcon, CircleIcon, UsersIcon, ServerIcon, ActivityIcon } from '@/components/ui/icons';
async function getStats() {
const driversOnline = await query(`
SELECT COUNT(*) as count
FROM users
WHERE is_connect = true
`);
const totalDrivers = await query(`
SELECT COUNT(*) as count
FROM users
`);
const activeServers = await query(`
SELECT COUNT(DISTINCT server_id) as count
FROM servers
WHERE connected_players > 0
`);
return {
driversOnline: driversOnline[0]?.count || 0,
totalDrivers: totalDrivers[0]?.count || 0,
activeServers: activeServers[0]?.count || 0,
};
}
export default async function HomePage() {
const stats = await getStats();
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<>
{/* Hero */}
<div className="relative border-b border-white/10">
<div className="absolute inset-0 grid-overlay opacity-50"></div>
<div className="max-w-7xl mx-auto px-6 py-32 relative">
<div className="text-center space-y-8">
{/* Logo */}
<div className="flex justify-center mb-8">
<img
src="https://openwheels.racing/files/img/Openwheels_landscape.svg"
alt="OpenWheels"
className="h-24 brightness-0 invert"
/>
</div>
{/* Tagline */}
<div className="space-y-4">
<h1 className="text-7xl font-bold tracking-tight leading-none">
FREE RACING.<br/>
NO BARRIERS.
</h1>
<p className="text-xl text-white/60 max-w-2xl mx-auto leading-relaxed">
Join our community of passionate racers. Compete in multiple leagues,
track your progress, and race for free on dedicated infrastructure.
</p>
</div>
{/* CTA */}
<div className="flex items-center justify-center space-x-4 pt-8">
<Link
href="/dashboard"
className="px-8 py-4 border-2 border-white hover:bg-white hover:text-[#0a0a0a] transition-all text-base font-medium"
>
VIEW DASHBOARD
</Link>
<a
href="https://discord.gg/nvuB8EvT9P"
target="_blank"
rel="noopener noreferrer"
className="px-8 py-4 border border-white/30 hover:border-white transition-all text-base"
>
JOIN COMMUNITY
</a>
</div>
</div>
{/* Live Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-24">
<LiveStatCard
value={stats.driversOnline}
label="ONLINE NOW"
icon={<UsersIcon />}
/>
<LiveStatCard
value={stats.activeServers}
label="ACTIVE SERVERS"
icon={<ServerIcon />}
/>
<LiveStatCard
value={stats.totalDrivers}
label="TOTAL DRIVERS"
icon={<ActivityIcon />}
/>
</div>
</div>
</div>
{/* Features Grid */}
<div className="max-w-7xl mx-auto px-6 py-24">
<div className="text-center mb-20">
<h2 className="text-5xl font-bold tracking-tight mb-4">
INFRASTRUCTURE
</h2>
<p className="text-white/60 text-lg">
Everything required for competitive sim racing
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FeatureCard
icon={<TrophyIcon className="w-10 h-10" />}
title="MULTIPLE LEAGUES"
description="Progress through skill-based divisions. C-Class to Pro level competition across all disciplines."
/>
<FeatureCard
icon={<ChartIcon className="w-10 h-10" />}
title="LIVE RANKINGS"
description="Real-time performance tracking. Transparent ELO system with historical data and statistics."
/>
<FeatureCard
icon={<CircleIcon className="w-10 h-10" />}
title="FREE ACCESS"
description="No subscriptions. No hidden costs. Open infrastructure for the racing community."
/>
<FeatureCard
icon={<TrophyIcon className="w-10 h-10" />}
title="WEATHER SYSTEMS"
description="Dynamic conditions. Rain, dry, variable grip. Test your adaptability in all scenarios."
/>
<FeatureCard
icon={<ActivityIcon className="w-10 h-10" />}
title="MULTIPLE CLASSES"
description="MX5, GT4, GT3 categories. Different cars for different racing philosophies and styles."
/>
<FeatureCard
icon={<UsersIcon className="w-10 h-10" />}
title="ACTIVE COMMUNITY"
description="Discord integration. Event organization, race coordination, and community engagement tools."
/>
</div>
</main>
</div>
{/* System Status */}
<div className="border-t border-white/10">
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
<span className="text-xs tracking-wider text-white/60">SYSTEM OPERATIONAL</span>
</div>
<div className="text-xs text-white/40 font-mono">
v2.0.0 | UPTIME: 99.9%
</div>
</div>
</div>
</div>
</>
);
}
function LiveStatCard({
value,
label,
icon
}: {
value: number;
label: string;
icon: React.ReactNode;
}) {
return (
<div className="border border-white/10 p-8 sharp-border">
<div className="flex items-center justify-between mb-4">
<div className="text-white/40">
{icon}
</div>
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
</div>
<div className="text-5xl font-bold tracking-tight mb-2">
{value.toLocaleString()}
</div>
<div className="text-sm text-white/60 tracking-wider font-medium">
{label}
</div>
</div>
);
}
function FeatureCard({
icon,
title,
description
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="border border-white/10 p-8 sharp-border topo-lines-dense bg-black">
<div className="mb-6 text-white/80">
{icon}
</div>
<h3 className="text-xl font-bold tracking-tight mb-3">{title}</h3>
<p className="text-white/60 leading-relaxed">{description}</p>
</div>
);
}
+326
View File
@@ -0,0 +1,326 @@
// 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';
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="font-semibold tracking-tight text-base">
{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>
);
}