migrate to vite
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m15s

This commit is contained in:
2026-03-06 20:46:40 +01:00
parent 8df9c8eaee
commit 49026fbab2
30 changed files with 3110 additions and 1064 deletions

6
.env.local.example Normal file → Executable file
View File

@@ -1,3 +1,3 @@
NEXT_PUBLIC_API_BASE=https://acc-api.jurmanovic.com/v1/api
SERVER_ID=2ea19460-f3b6-47ae-a667-c26876425268
ACCESS_TOKEN=EB41048A021F7A7707ED4E5EF37052C401F9ACD5DCDBC94BF16D0456FAAF111A
VITE_API_BASE=https://acc-api.jurmanovic.com/v1/api
VITE_SERVER_ID=your-server-id-here
VITE_ACCESS_TOKEN=your-access-token-here

9
.gitea/workflows/deploy.yaml Normal file → Executable file
View File

@@ -12,6 +12,7 @@ jobs:
uses: https://github.com/actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -19,13 +20,13 @@ jobs:
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_BASE: ${{ secrets.API_BASE }}
NEXT_PUBLIC_SERVER_ID: ${{ secrets.SERVER_ID }}
NEXT_PUBLIC_ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
VITE_API_BASE: ${{ secrets.API_BASE }}
VITE_SERVER_ID: ${{ secrets.SERVER_ID }}
VITE_ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Publish to Cloudflare
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./out --project-name=grid-gladiators-leaderboard
command: pages deploy ./dist --project-name=grid-gladiators-leaderboard

3
.gitignore vendored Normal file → Executable file
View File

@@ -3,7 +3,8 @@ node_modules/
npm-debug.log
yarn-error.log
# Next.js
# Build output
dist/
.next/
out/

24
eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ACC Championship Leaderboard</title>
<meta name="description" content="Grid Gladiators League Leaderboard - Race results and driver standings" />
<meta property="og:title" content="ACC Championship Leaderboard" />
<meta property="og:description" content="Grid Gladiators League Leaderboard - Race results and driver standings" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
next-env.d.ts vendored
View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,11 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;

3274
package-lock.json generated Normal file → Executable file

File diff suppressed because it is too large Load Diff

20
package.json Normal file → Executable file
View File

@@ -2,26 +2,32 @@
"name": "grid-gladiators-results",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src/"
},
"dependencies": {
"next": "^15",
"react": "^19",
"react-dom": "^19",
"d3": "^7"
},
"devDependencies": {
"@eslint/js": "^9",
"@types/d3": "^7",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^4",
"autoprefixer": "^10",
"eslint": "^9",
"eslint-plugin-react-hooks": "^5",
"eslint-plugin-react-refresh": "^0.4",
"postcss": "^8",
"tailwindcss": "^3",
"typescript": "^5"
"typescript": "^5",
"typescript-eslint": "^8",
"vite": "^5"
}
}

0
postcss.config.mjs Normal file → Executable file
View File

93
src/App.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useEffect, useState, useMemo } from 'react';
import { fetchLeaderboard } from '@/lib/api';
import { AppData } from '@/lib/types';
import { calculateStandingsData } from '@/lib/leaderboard';
import RaceResultsTable from '@/components/RaceResultsTable';
import DriverStandings from '@/components/DriverStandings';
import CumulativeChart from '@/components/CumulativeChart';
import PointsKey from '@/components/PointsKey';
export default function App() {
const [appData, setAppData] = useState<AppData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
setError(null);
const data = await fetchLeaderboard();
if (data) {
setAppData(data);
} else {
setError('Failed to load leaderboard data');
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, []);
const standingsData = useMemo(
() => (appData ? calculateStandingsData(appData) : []),
[appData]
);
return (
<div className="bg-gray-900 text-gray-200 p-4 md:p-8 min-h-screen">
<div className="container mx-auto max-w-7xl space-y-12">
<header className="text-center">
<h1 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-4">
Grid Gladiators League Leaderboard
</h1>
</header>
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div>
<p className="mt-4 text-lg text-gray-400">Loading leaderboard...</p>
</div>
) : error ? (
<div className="bg-red-900/20 border border-red-500 rounded-lg p-6 text-center">
<p className="text-red-300 mb-4">{error}</p>
<button
onClick={fetchData}
className="bg-purple-600 hover:bg-purple-700 text-white font-semibold py-2 px-6 rounded-lg transition"
>
Retry
</button>
</div>
) : appData ? (
<main className="space-y-12">
<section>
<h2 className="text-2xl font-bold text-white mb-4">Race Results</h2>
<RaceResultsTable appData={appData} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<section className="lg:col-span-2">
<h2 className="text-2xl font-bold text-white mb-4">Driver Standings</h2>
<DriverStandings standingsData={standingsData} />
</section>
<section className="lg:col-span-1">
<h2 className="text-2xl font-bold text-white mb-4">Points Table:</h2>
<PointsKey
pointsTable={appData.pointsTable}
flPoints={appData.flPoints}
flColor={appData.flColor}
flTextColor={appData.flTextColor}
/>
</section>
</div>
<section>
<h2 className="text-2xl font-bold text-white mb-6 text-center">Cumulative Points per Race</h2>
<CumulativeChart standingsData={standingsData} tracks={appData.tracks} />
</section>
</main>
) : null}
</div>
</div>
);
}

View File

@@ -1,60 +0,0 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { AppData } from '@/lib/types';
import { calculateStandingsData } from '@/lib/leaderboard';
import RaceResultsTable from './components/RaceResultsTable';
import DriverStandings from './components/DriverStandings';
import PointsKey from './components/PointsKey';
const CumulativeChart = dynamic(() => import('./components/CumulativeChart'), { ssr: false });
interface ClientPageProps {
initialAppData: AppData;
}
export default function ClientPage({ initialAppData }: ClientPageProps) {
const appData = initialAppData;
const standingsData = useMemo(() => calculateStandingsData(appData), [appData]);
return (
<div className="container mx-auto max-w-7xl space-y-12">
<header className="text-center">
<h1 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-4">
Grid Gladiators League Leaderboard
</h1>
</header>
<main className="space-y-12">
<section>
<h2 className="text-2xl font-bold text-white mb-4">Race Results</h2>
<RaceResultsTable appData={appData} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<section className="lg:col-span-2">
<h2 className="text-2xl font-bold text-white mb-4">Driver Standings</h2>
<DriverStandings standingsData={standingsData} />
</section>
<section className="lg:col-span-1">
<h2 className="text-2xl font-bold text-white mb-4">Points Table:</h2>
<PointsKey
pointsTable={appData.pointsTable}
flPoints={appData.flPoints}
flColor={appData.flColor}
flTextColor={appData.flTextColor}
/>
</section>
</div>
</main>
<section>
<h2 className="text-2xl font-bold text-white mb-6 text-center">Cumulative Points per Race</h2>
<CumulativeChart standingsData={standingsData} tracks={appData.tracks} />
</section>
</div>
);
}

View File

@@ -1,150 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import { DriverStanding, Track } from '@/lib/types';
interface CumulativeChartProps {
standingsData: DriverStanding[];
tracks: Track[];
}
export default function CumulativeChart({ standingsData, tracks }: CumulativeChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const renderChart = async () => {
if (!containerRef.current) return;
const d3 = await import('d3');
const container = containerRef.current;
container.innerHTML = '';
const margin = { top: 40, right: 20, bottom: 200, left: 40 };
const containerWidth = container.getBoundingClientRect().width;
if (containerWidth < 50) return;
const width = containerWidth - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
const trackNames = tracks.map(t => t.name);
const svg = d3.select(container)
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
const colorScale = d3.scaleOrdinal()
.domain(standingsData.map(d => d.name))
.range(standingsData.map(d => d.color));
const xScale = d3.scalePoint()
.domain(trackNames.length > 0 ? trackNames : ["No Tracks"])
.range([0, width])
.padding(0.5);
const maxY = d3.max(standingsData, driver =>
d3.max(driver.raceHistory, race => race.cumulativePoints)
);
const yScale = d3.scaleLinear()
.domain([0, (maxY !== undefined && maxY > 0) ? maxY : 10])
.range([height, 0])
.nice();
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
svg.append("g")
.attr("class", "chart-grid")
.call(d3.axisLeft(yScale).ticks(5).tickSize(-width).tickFormat(() => ""));
svg.append("g")
.attr("class", "chart-axis")
.attr("transform", `translate(0, ${height})`)
.call(xAxis)
.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-45)");
svg.append("g")
.attr("class", "chart-axis")
.call(yAxis);
const line = d3.line<any>()
.x(d => xScale(d.trackName) ?? 0)
.y(d => yScale(d.cumulativePoints))
.curve(d3.curveMonotoneX);
const driverGroups = svg.selectAll('.driver-group')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'driver-group');
driverGroups.append("path")
.attr("class", "chart-line")
.style("stroke", d => colorScale(d.name) as string)
.attr("d", d => line(d.raceHistory));
driverGroups.selectAll(".chart-dot")
.data(d => d.raceHistory)
.enter().append("circle")
.attr("class", "chart-dot")
.attr("cx", d => xScale(d.trackName) ?? 0)
.attr("cy", d => yScale(d.cumulativePoints))
.attr("r", 3)
.style("fill", (_d, i, nodes) => {
const parentNode = nodes[i].parentNode as any;
return colorScale((d3.select(parentNode).datum() as any).name) as string;
});
svg.append("text")
.attr("class", "chart-title")
.attr("x", width / 2)
.attr("y", 0 - (margin.top / 2))
.text("Cumulative Points per Race");
const legend = svg.append('g')
.attr('class', 'chart-legend')
.attr('transform', `translate(0, ${height + 120})`);
const legendItemWidth = 150;
const numCols = Math.floor(width / legendItemWidth);
const colWidth = (numCols > 0) ? (width / numCols) : width;
const maxCols = Math.max(1, numCols);
const legendItem = legend.selectAll('.legend-item')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (_d, i) => {
const xPos = (i % maxCols) * colWidth;
const yPos = Math.floor(i / maxCols) * 20;
return `translate(${xPos}, ${yPos})`;
});
legendItem.append('rect')
.attr('width', 12)
.attr('height', 12)
.style('fill', d => colorScale(d.name) as string);
legendItem.append('text')
.attr('x', 16)
.attr('y', 10)
.style('fill', '#d1d5db')
.style('font-size', '12px')
.text(d => d.name);
};
renderChart();
}, [standingsData, tracks]);
return <div ref={containerRef} className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50" />;
}

View File

@@ -1,23 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "ACC Championship Leaderboard",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-gray-900 text-gray-200 p-4 md:p-8`}>
{children}
</body>
</html>
);
}

View File

@@ -1,11 +0,0 @@
export default function NotFound() {
return (
<div className="container mx-auto max-w-7xl space-y-12">
<header className="text-center">
<h1 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-4">
404 - Page Not Found
</h1>
</header>
</div>
);
}

View File

@@ -1,22 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { fetchLeaderboard } from "@/lib/api";
import ClientPage from "./ClientPage";
import { AppData } from "@/lib/types";
export default function Page() {
const [appData, setAppData] = useState<AppData | null>(null);
useEffect(() => {
fetchLeaderboard().then((data) => {
setAppData(data);
});
}, []);
if (!appData) {
return null;
}
return <ClientPage initialAppData={appData} />;
}

View File

@@ -0,0 +1,193 @@
import { useEffect, useRef } from 'react';
import { DriverStanding, Track } from '@/lib/types';
interface CumulativeChartProps {
standingsData: DriverStanding[];
tracks: Track[];
}
export default function CumulativeChart({ standingsData, tracks }: CumulativeChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const selectionRef = useRef<any>(null);
useEffect(() => {
const renderChart = async () => {
if (!containerRef.current) return;
const d3 = await import('d3');
const container = containerRef.current;
if (selectionRef.current) {
selectionRef.current.remove();
}
container.innerHTML = '';
const margin = { top: 40, right: 20, bottom: 200, left: 40 };
const containerWidth = container.getBoundingClientRect().width;
if (containerWidth < 50) return;
const width = containerWidth - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
const trackNames = tracks.map((t) => t.name);
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr(
'viewBox',
`0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`
)
.attr('role', 'img')
.attr('aria-label', 'Cumulative points per race chart showing driver progression across all races')
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
selectionRef.current = d3.select(container).select('svg');
const colorScale = d3
.scaleOrdinal()
.domain(standingsData.map((d) => d.name))
.range(standingsData.map((d) => d.color));
const xScale = d3
.scalePoint()
.domain(trackNames.length > 0 ? trackNames : ['No Tracks'])
.range([0, width])
.padding(0.5);
const maxY = d3.max(standingsData, (driver: DriverStanding) =>
d3.max(driver.raceHistory, (race) => race.cumulativePoints)
);
const yScale = d3
.scaleLinear()
.domain([0, maxY !== undefined && maxY > 0 ? maxY : 10])
.range([height, 0])
.nice();
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
svg
.append('g')
.attr('class', 'chart-grid')
.call(
d3
.axisLeft(yScale)
.ticks(5)
.tickSize(-width)
.tickFormat(() => '')
);
svg
.append('g')
.attr('class', 'chart-axis')
.attr('transform', `translate(0, ${height})`)
.call(xAxis)
.selectAll('text')
.style('text-anchor', 'end')
.attr('dx', '-.8em')
.attr('dy', '.15em')
.attr('transform', 'rotate(-45)');
svg.append('g').attr('class', 'chart-axis').call(yAxis);
const line = d3
.line<(typeof standingsData)[0]['raceHistory'][0]>()
.x((d) => xScale(d.trackName) ?? 0)
.y((d) => yScale(d.cumulativePoints))
.curve(d3.curveMonotoneX);
const driverGroups = svg
.selectAll('.driver-group')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'driver-group');
driverGroups
.append('path')
.attr('class', 'chart-line')
.style('stroke', (d) => colorScale(d.name) as string)
.attr('d', (d) => line(d.raceHistory));
driverGroups
.selectAll('.chart-dot')
.data((d: DriverStanding) => d.raceHistory)
.enter()
.append('circle')
.attr('class', 'chart-dot')
.attr('cx', (d) => xScale(d.trackName) ?? 0)
.attr('cy', (d) => yScale(d.cumulativePoints))
.attr('r', 3)
.style('fill', (_d, i: number, nodes: NodeListOf<Element>) => {
const parentNode = nodes[i].parentNode;
return colorScale((d3.select(parentNode).datum() as DriverStanding).name) as string;
});
svg
.append('text')
.attr('class', 'chart-title')
.attr('x', width / 2)
.attr('y', 0 - margin.top / 2)
.text('Cumulative Points per Race');
const legend = svg
.append('g')
.attr('class', 'chart-legend')
.attr('transform', `translate(0, ${height + 120})`);
const legendItemWidth = 150;
const numCols = Math.floor(width / legendItemWidth);
const colWidth = numCols > 0 ? width / numCols : width;
const maxCols = Math.max(1, numCols);
const legendItem = legend
.selectAll('.legend-item')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (_d, i: number) => {
const xPos = (i % maxCols) * colWidth;
const yPos = Math.floor(i / maxCols) * 20;
return `translate(${xPos}, ${yPos})`;
});
legendItem
.append('rect')
.attr('width', 12)
.attr('height', 12)
.style('fill', (d) => colorScale(d.name) as string);
legendItem
.append('text')
.attr('x', 16)
.attr('y', 10)
.style('fill', '#d1d5db')
.style('font-size', '12px')
.text((d) => d.name);
};
renderChart();
return () => {
if (selectionRef.current) {
selectionRef.current.remove();
}
};
}, [standingsData, tracks]);
return (
<div
ref={containerRef}
className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50"
/>
);
}

View File

@@ -10,11 +10,11 @@ export default function DriverStandings({ standingsData }: DriverStandingsProps)
<table className="w-full">
<thead className="border-b border-gray-600">
<tr>
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Pos</th>
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Color</th>
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Driver</th>
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Initials</th>
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Pos</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Color</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Driver</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Initials</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
</tr>
</thead>
<tbody>
@@ -29,7 +29,12 @@ export default function DriverStandings({ standingsData }: DriverStandingsProps)
<tr key={driver.originalIndex} className={`border-b border-gray-700 ${podiumClass}`}>
<td className="px-3 py-3 font-semibold">{pos}</td>
<td className="px-3 py-3">
<div className="w-10 h-6 rounded" style={{ backgroundColor: driver.color }}></div>
<div
className="w-10 h-6 rounded"
style={{ backgroundColor: driver.color }}
role="presentation"
aria-label={`${driver.name}'s color`}
></div>
</td>
<td className="px-3 py-3">{driver.name}</td>
<td className="px-3 py-3 font-mono">{driver.initials}</td>

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { PointStyle } from '@/lib/types';
interface PointsKeyProps {
@@ -8,23 +9,27 @@ interface PointsKeyProps {
}
export default function PointsKey({ pointsTable, flPoints, flColor, flTextColor }: PointsKeyProps) {
const sortedPoints = [...pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99));
const sortedPoints = useMemo(
() => [...pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99)),
[pointsTable]
);
return (
<div className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
<table className="w-full text-center font-bold">
<thead>
<tr>
<th scope="col" className="px-3 py-2">Points</th>
</tr>
</thead>
<tbody>
{sortedPoints.map((style) => (
<tr key={style.id || `pt-${style.points}`}>
<td
className="px-3 py-2"
className="px-3 py-2 flex justify-center items-center"
style={{
backgroundColor: style.color,
color: style.textColor,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
}}
>
<div>
@@ -36,13 +41,13 @@ export default function PointsKey({ pointsTable, flPoints, flColor, flTextColor
))}
<tr>
<td
className="px-3 py-2 points-fl"
className="px-3 py-2 points-fl flex justify-center items-center"
style={{
backgroundColor: flColor,
color: flTextColor,
}}
>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'row' }}>
<div className="flex justify-center items-center">
FL <span className="fl-bg-text" style={{ color: flTextColor }}>(+{flPoints})</span>
</div>
</td>

View File

@@ -1,11 +1,32 @@
import { AppData } from "@/lib/types";
import { parsePoints, findStyleByPoints } from "@/lib/leaderboard";
import { useMemo } from 'react';
import { AppData } from '@/lib/types';
import { parsePoints, findStyleByPoints } from '@/lib/leaderboard';
interface RaceResultsTableProps {
appData: AppData;
}
export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
const driverById = useMemo(() => {
const map = new Map<string, (typeof appData.drivers)[0]>();
appData.drivers.forEach((driver) => {
map.set(driver.id, driver);
});
return map;
}, [appData]);
const resultsByTrackAndDriver = useMemo(() => {
const map = new Map<string, Map<string, number | null>>();
appData.tracks.forEach((track) => {
const driverMap = new Map<string, number | null>();
track.results.forEach((result) => {
driverMap.set(result.driverId, result.score);
});
map.set(track.id, driverMap);
});
return map;
}, [appData]);
return (
<div
id="table-container"
@@ -28,7 +49,7 @@ export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
key={driver.initials}
scope="col"
className="py-3 px-4 min-w-[170px]"
style={{ textAlign: "center" }}
style={{ textAlign: 'center' }}
>
<span>{driver.name}</span>
</th>
@@ -36,28 +57,20 @@ export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
</tr>
</thead>
<tbody id="race-table-body" className="divide-y divide-gray-700">
{appData.tracks.map((track, trackIndex) => {
// Find driver name for fastest lap
const flDriver = appData.drivers.find(
(d) => d.id === track.fastestLapDriverId
);
{appData.tracks.map((track) => {
const flDriver = driverById.get(track.fastestLapDriverId);
return (
<tr key={trackIndex}>
<tr key={track.id}>
<td className="font-semibold py-3 px-4 whitespace-nowrap min-w-[200px]">
<span>{track.name}</span>
</td>
<td className="py-3 px-4 font-mono min-w-[100px]">
{flDriver?.initials || ""}
{flDriver?.initials || ''}
</td>
{appData.drivers.map((driver) => {
const raceResult = track.results.find(
(r) => r.driverId === driver.id
);
let result = raceResult?.score;
// DNS fallback
if (result === undefined || result === null || result === "") {
result = "DNS";
}
const driverMap = resultsByTrackAndDriver.get(track.id);
const score = driverMap?.get(driver.id);
const result = score === null || score === undefined ? 'DNS' : score;
const pointsNum = parsePoints(result);
const style = findStyleByPoints(appData, pointsNum);
@@ -66,9 +79,10 @@ export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
color: style.textColor,
};
let hasFL = false;
if (track.fastestLapDriverId === driver.id && appData.flPoints) {
hasFL = true;
const hasFL =
track.fastestLapDriverId === driver.id && appData.flPoints;
if (hasFL) {
const flColor = appData.flColor;
if (pointsNum === 0) {
cellStyles = {
@@ -86,17 +100,17 @@ export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
if (hasFL) {
cellStyles = {
...cellStyles,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexDirection: "row",
paddingLeft: "0.75rem",
paddingRight: "0.75rem",
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row',
paddingLeft: '0.75rem',
paddingRight: '0.75rem',
};
} else {
cellStyles = {
...cellStyles,
textAlign: "center",
textAlign: 'center',
};
}
@@ -105,10 +119,11 @@ export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
key={driver.id}
className="py-3 px-4 font-mono"
style={cellStyles}
title={`${result} - ${style.label}`}
>
{hasFL ? (
<>
<span style={{ flexGrow: 1, textAlign: "left" }}>
<span style={{ flexGrow: 1, textAlign: 'left' }}>
{result}
</span>
<span

29
src/lib/api.ts Normal file → Executable file
View File

@@ -1,28 +1,35 @@
import { AppData } from './types';
import { defaultAppData } from './defaultData';
export async function fetchLeaderboard(): Promise<AppData | null> {
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || '';
const SERVER_ID = process.env.NEXT_PUBLIC_SERVER_ID || '';
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || '';
const API_BASE = import.meta.env.VITE_API_BASE || '';
const SERVER_ID = import.meta.env.VITE_SERVER_ID || '';
const ACCESS_TOKEN = import.meta.env.VITE_ACCESS_TOKEN || '';
if (!API_BASE || !SERVER_ID || !ACCESS_TOKEN) {
console.warn('Missing API configuration');
return null;
console.warn('Missing API configuration, using default data');
return defaultAppData;
}
try {
const res = await fetch(`${API_BASE}/server/${SERVER_ID}/leaderboard`, {
headers: {
"Access-Key": ACCESS_TOKEN
}
'Access-Key': ACCESS_TOKEN,
},
});
if (!res.ok) return null;
if (!res.ok) {
console.warn('API request failed, using default data');
return defaultAppData;
}
const data = await res.json();
// Treat empty leaderboard (no drivers) as null to fall back to defaults
if (!data || !data.drivers || data.drivers.length === 0) return null;
if (!data || !data.drivers || data.drivers.length === 0) {
console.warn('Empty API response, using default data');
return defaultAppData;
}
return data;
} catch (e) {
console.error("Failed to load leaderboard from API:", e);
return null;
console.error('Failed to load leaderboard from API:', e);
return defaultAppData;
}
}

0
src/lib/defaultData.ts Normal file → Executable file
View File

50
src/lib/leaderboard.ts Normal file → Executable file
View File

@@ -1,8 +1,12 @@
import { AppData, PointStyle, DriverStanding, RaceHistoryEntry } from "./types";
import { AppData, PointStyle, DriverStanding, RaceHistoryEntry } from './types';
export function parsePoints(value: any): number {
const num = parseInt(value, 10);
return isNaN(num) ? 0 : num;
export function parsePoints(value: number | string | null): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const num = parseInt(value, 10);
return isNaN(num) ? 0 : num;
}
return 0;
}
export function findStyleByPoints(
@@ -11,34 +15,44 @@ export function findStyleByPoints(
): PointStyle {
return (
appData.pointsTable.find((p) => p.points === points) || {
id: "unknown",
id: 'unknown',
points: 0,
label: "???",
color: "#374151",
textColor: "#e5e7eb",
label: '???',
color: '#374151',
textColor: '#e5e7eb',
priority: 99,
}
);
}
export function calculateStandingsData(appData: AppData): DriverStanding[] {
const resultsByTrackAndDriver = new Map<
string,
Map<string, { score: number | null }>
>();
appData.tracks.forEach((track) => {
const driverMap = new Map<string, { score: number | null }>();
track.results.forEach((result) => {
driverMap.set(result.driverId, { score: parsePoints(result.score) });
});
resultsByTrackAndDriver.set(track.id, driverMap);
});
const standings = appData.drivers.map((driver, driverIndex) => {
let totalPoints = 0;
let cumulativePoints = 0;
const raceHistory: RaceHistoryEntry[] = [];
appData.tracks.forEach((track) => {
// Find result for this driver by driverId
const raceResult = track.results.find((r) => r.driverId === driver.id);
let result = raceResult?.score;
// DNS fallback: if result is undefined, null, or empty, treat as DNS
if (result === undefined || result === null || result === "") {
result = "DNS";
}
let racePoints = parsePoints(result);
const driverMap = resultsByTrackAndDriver.get(track.id);
const resultObj = driverMap?.get(driver.id);
let racePoints = resultObj?.score ?? 0;
if (track.fastestLapDriverId === driver.id) {
racePoints += appData.flPoints ?? 1;
}
totalPoints += racePoints;
cumulativePoints += racePoints;
raceHistory.push({
@@ -52,8 +66,8 @@ export function calculateStandingsData(appData: AppData): DriverStanding[] {
name: driver.name,
color: driver.color,
initials: driver.initials,
totalPoints: totalPoints,
raceHistory: raceHistory,
totalPoints,
raceHistory,
originalIndex: driverIndex,
};
});

2
src/lib/types.ts Normal file → Executable file
View File

@@ -16,7 +16,7 @@ export interface PointStyle {
export interface RaceResult {
driverId: string;
score: number | string | null | undefined;
score: number | null;
}
export interface Track {

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './App.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

12
tailwind.config.ts Normal file → Executable file
View File

@@ -1,14 +1,14 @@
import type { Config } from 'tailwindcss'
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
}
export default config
};
export default config;

13
tsconfig.json Normal file → Executable file
View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
@@ -15,7 +15,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -31,15 +31,12 @@
},
"allowJs": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
"types": [
"vite/client"
]
},
"include": [
"src",
".next/types/**/*.ts"
"src"
],
"references": [
{

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});