FODASSE QUE BORRAÇO
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user