Compare commits

...

8 Commits

Author SHA1 Message Date
49026fbab2 migrate to vite
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m15s
2026-03-06 20:46:40 +01:00
8df9c8eaee remove defaultAppData showing
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m28s
2026-03-05 21:37:00 +01:00
df8ebbfd47 move fetch to useEffect
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 6m25s
2026-03-04 22:40:33 +01:00
b0b8968fb2 use client for fetch data
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 5m51s
2026-03-04 22:23:49 +01:00
5a116eabe7 update request model rewiring
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m46s
2026-03-01 16:45:30 +01:00
3004545400 fix FL points defaulting to 1
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m3s
2026-03-01 15:07:45 +01:00
8b3d8bc817 Configure env variables for Gitea Actions build step
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m26s
Add secrets to build step in deploy.yaml:
- NEXT_PUBLIC_API_BASE
- SERVER_ID
- ACCESS_TOKEN

These are baked into static export during build, not needed by Cloudflare.
2026-03-01 14:50:45 +01:00
fb339c57de Use non-public env variables for sensitive credentials
- NEXT_PUBLIC_API_BASE: Keep public (just the endpoint URL)
- SERVER_ID: Remove NEXT_PUBLIC_ prefix (private identifier)
- ACCESS_TOKEN: Remove NEXT_PUBLIC_ prefix (sensitive credential)

Since fetching is server-side, credentials never reach the browser.
Only API_BASE needs to be public if accessed from client in future.
2026-03-01 14:50:25 +01:00
30 changed files with 3298 additions and 1155 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
NEXT_PUBLIC_SERVER_ID=2ea19460-f3b6-47ae-a667-c26876425268
NEXT_PUBLIC_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

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

@@ -12,16 +12,21 @@ jobs:
uses: https://github.com/actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
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,55 +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} />
</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,98 +0,0 @@
import { AppData } from '@/lib/types';
import { parsePoints, findStyleByPoints } from '@/lib/leaderboard';
interface RaceResultsTableProps {
appData: AppData;
}
export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
return (
<div id="table-container" className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50 overflow-x-auto">
<table id="race-results-table" className="w-full min-w-[1200px] border-collapse text-center">
<thead id="race-table-head">
<tr>
<th scope="col" className="py-3 px-4 min-w-[200px]">Track</th>
<th scope="col" className="py-3 px-4 min-w-[100px]">FL</th>
{appData.drivers.map((driver) => (
<th key={driver.initials} scope="col" className="py-3 px-4 min-w-[170px]" style={{ textAlign: 'center' }}>
<span>{driver.name}</span>
</th>
))}
</tr>
</thead>
<tbody id="race-table-body" className="divide-y divide-gray-700">
{appData.tracks.map((track, trackIndex) => (
<tr key={trackIndex}>
<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]">{track.fastestLapInitials || ''}</td>
{appData.drivers.map((driver, driverIndex) => {
const driverInitials = driver.initials;
let result = track.results[driverIndex];
// DNS fallback
if (result === undefined || result === null || result === '') {
result = 'DNS';
}
const pointsNum = parsePoints(result);
const style = findStyleByPoints(appData, pointsNum);
let cellStyles: React.CSSProperties = {
backgroundColor: style.color,
color: style.textColor,
};
let hasFL = false;
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
hasFL = true;
const flColor = appData.flPoints.color;
if (pointsNum === 0) {
cellStyles = {
backgroundColor: flColor,
color: appData.flPoints.textColor,
};
} else {
cellStyles = {
background: `linear-gradient(135deg, ${style.color} 50%, ${flColor} 50%)`,
color: style.textColor,
};
}
}
if (hasFL) {
cellStyles = {
...cellStyles,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row',
paddingLeft: '0.75rem',
paddingRight: '0.75rem',
};
} else {
cellStyles = {
...cellStyles,
textAlign: 'center',
};
}
return (
<td key={driverIndex} className="py-3 px-4 font-mono" style={cellStyles}>
{hasFL ? (
<>
<span style={{ flexGrow: 1, textAlign: 'left' }}>{result}</span>
<span className="fl-text-only" style={{ color: appData.flPoints.textColor }}>(+1)</span>
</>
) : (
<span>{result}</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}

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,10 +0,0 @@
import { fetchLeaderboard } from '@/lib/api';
import { defaultAppData } from '@/lib/defaultData';
import ClientPage from './ClientPage';
export default async function Page() {
const leaderboardData = await fetchLeaderboard();
const initialAppData = leaderboardData || defaultAppData;
return <ClientPage initialAppData={initialAppData} />;
}

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,28 +1,35 @@
import { useMemo } from 'react';
import { PointStyle } from '@/lib/types';
interface PointsKeyProps {
pointsTable: PointStyle[];
flPoints: PointStyle;
flPoints: number;
flColor: string;
flTextColor: string;
}
export default function PointsKey({ pointsTable, flPoints }: PointsKeyProps) {
const sortedPoints = [...pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99));
export default function PointsKey({ pointsTable, flPoints, flColor, flTextColor }: PointsKeyProps) {
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.points}>
<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>
@@ -33,9 +40,15 @@ export default function PointsKey({ pointsTable, flPoints }: PointsKeyProps) {
</tr>
))}
<tr>
<td className="px-3 py-2 points-fl">
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'row' }}>
FL <span className="fl-bg-text" style={{ color: flPoints.textColor }}>(+{flPoints.points})</span>
<td
className="px-3 py-2 points-fl flex justify-center items-center"
style={{
backgroundColor: flColor,
color: flTextColor,
}}
>
<div className="flex justify-center items-center">
FL <span className="fl-bg-text" style={{ color: flTextColor }}>(+{flPoints})</span>
</div>
</td>
</tr>

View File

@@ -0,0 +1,149 @@
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"
className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50 overflow-x-auto"
>
<table
id="race-results-table"
className="w-full min-w-[1200px] border-collapse text-center"
>
<thead id="race-table-head">
<tr>
<th scope="col" className="py-3 px-4 min-w-[200px]">
Track
</th>
<th scope="col" className="py-3 px-4 min-w-[100px]">
FL
</th>
{appData.drivers.map((driver) => (
<th
key={driver.initials}
scope="col"
className="py-3 px-4 min-w-[170px]"
style={{ textAlign: 'center' }}
>
<span>{driver.name}</span>
</th>
))}
</tr>
</thead>
<tbody id="race-table-body" className="divide-y divide-gray-700">
{appData.tracks.map((track) => {
const flDriver = driverById.get(track.fastestLapDriverId);
return (
<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 || ''}
</td>
{appData.drivers.map((driver) => {
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);
let cellStyles: React.CSSProperties = {
backgroundColor: style.color,
color: style.textColor,
};
const hasFL =
track.fastestLapDriverId === driver.id && appData.flPoints;
if (hasFL) {
const flColor = appData.flColor;
if (pointsNum === 0) {
cellStyles = {
backgroundColor: flColor,
color: appData.flTextColor,
};
} else {
cellStyles = {
background: `linear-gradient(135deg, ${style.color} 50%, ${flColor} 50%)`,
color: style.textColor,
};
}
}
if (hasFL) {
cellStyles = {
...cellStyles,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row',
paddingLeft: '0.75rem',
paddingRight: '0.75rem',
};
} else {
cellStyles = {
...cellStyles,
textAlign: 'center',
};
}
return (
<td
key={driver.id}
className="py-3 px-4 font-mono"
style={cellStyles}
title={`${result} - ${style.label}`}
>
{hasFL ? (
<>
<span style={{ flexGrow: 1, textAlign: 'left' }}>
{result}
</span>
<span
className="fl-text-only"
style={{ color: appData.flTextColor }}
>
(+{appData.flPoints})
</span>
</>
) : (
<span>{result}</span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

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;
}
}

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

@@ -7,50 +7,52 @@ const defaultColors = [
export const defaultAppData: AppData = {
drivers: [
{ name: "Filip Makarijevski", color: defaultColors[0], initials: "F.M." },
{ name: "Karlo Steinbruckner", color: defaultColors[1], initials: "K.S." },
{ name: "Fran Jurmanović", color: defaultColors[2], initials: "F.J." },
{ name: "Borna Bevanda", color: defaultColors[3], initials: "B.B." },
{ name: "Matej Jugović", color: defaultColors[4], initials: "M.J." },
{ name: "Lovo Bravić", color: defaultColors[5], initials: "L.B." },
{ name: "Dominik Dejanović", color: defaultColors[6], initials: "D.D." },
{ name: "Josip Kompanović", color: defaultColors[7], initials: "J.K." },
{ name: "Tin Vidmar", color: defaultColors[8], initials: "T.V." },
{ name: "Ivan Ivezić", color: defaultColors[9], initials: "I.I." },
{ name: "Tomislav Glavaš", color: defaultColors[10], initials: "T.G." }
{ id: "550e8400-e29b-41d4-a716-446655440000", name: "Filip Makarijevski", color: defaultColors[0], initials: "F.M." },
{ id: "550e8400-e29b-41d4-a716-446655440001", name: "Karlo Steinbruckner", color: defaultColors[1], initials: "K.S." },
{ id: "550e8400-e29b-41d4-a716-446655440002", name: "Fran Jurmanović", color: defaultColors[2], initials: "F.J." },
{ id: "550e8400-e29b-41d4-a716-446655440003", name: "Borna Bevanda", color: defaultColors[3], initials: "B.B." },
{ id: "550e8400-e29b-41d4-a716-446655440004", name: "Matej Jugović", color: defaultColors[4], initials: "M.J." },
{ id: "550e8400-e29b-41d4-a716-446655440005", name: "Lovo Bravić", color: defaultColors[5], initials: "L.B." },
{ id: "550e8400-e29b-41d4-a716-446655440006", name: "Dominik Dejanović", color: defaultColors[6], initials: "D.D." },
{ id: "550e8400-e29b-41d4-a716-446655440007", name: "Josip Kompanović", color: defaultColors[7], initials: "J.K." },
{ id: "550e8400-e29b-41d4-a716-446655440008", name: "Tin Vidmar", color: defaultColors[8], initials: "T.V." },
{ id: "550e8400-e29b-41d4-a716-446655440009", name: "Ivan Ivezić", color: defaultColors[9], initials: "I.I." },
{ id: "550e8400-e29b-41d4-a716-44665544000a", name: "Tomislav Glavaš", color: defaultColors[10], initials: "T.G." }
],
pointsTable: [
{ points: 10, label: "1st", color: "#fde047", textColor: "#422006", priority: 1 },
{ points: 7, label: "2nd", color: "#9ca3af", textColor: "#1f2937", priority: 2 },
{ points: 5, label: "3rd", color: "#f0c0a0", textColor: "#431407", priority: 3 },
{ points: 2, label: "4th", color: "#81c784", textColor: "#1F2937", priority: 4 },
{ points: 1, label: "5th", color: "#a7d9ef", textColor: "#1F2937", priority: 5 },
{ id: "pt-1st", points: 10, label: "1st", color: "#fde047", textColor: "#422006", priority: 1 },
{ id: "pt-2nd", points: 7, label: "2nd", color: "#9ca3af", textColor: "#1f2937", priority: 2 },
{ id: "pt-3rd", points: 5, label: "3rd", color: "#f0c0a0", textColor: "#431407", priority: 3 },
{ id: "pt-4th", points: 2, label: "4th", color: "#81c784", textColor: "#1F2937", priority: 4 },
{ id: "pt-5th", points: 1, label: "5th", color: "#a7d9ef", textColor: "#1F2937", priority: 5 },
],
flPoints: { points: 1, label: "FL +1", color: "#8b5cf6", textColor: "#000000", priority: 6 },
flPoints: 1,
flColor: "#8b5cf6",
flTextColor: "#000000",
tracks: [
{ name: "Hungaroring", results: [], fastestLapInitials: "" },
{ name: "Watkins Glen", results: [], fastestLapInitials: "" },
{ name: "Paul Ricard", results: [], fastestLapInitials: "" },
{ name: "Silverstone", results: [], fastestLapInitials: "" },
{ name: "Zandvoort", results: [], fastestLapInitials: "" },
{ name: "Brands Hatch", results: [], fastestLapInitials: "" },
{ name: "Circuit Ricardo Tormo (Valencia)", results: [], fastestLapInitials: "" },
{ name: "Mount Panorama Circuit", results: [], fastestLapInitials: "" },
{ name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [], fastestLapInitials: "" },
{ name: "Barcelona", results: [null, 0, 0], fastestLapInitials: "" },
{ name: "Indianapolis", results: [], fastestLapInitials: "" },
{ name: "WeatherTech Raceway Laguna Seca", results: [], fastestLapInitials: "" },
{ name: "Zolder", results: [], fastestLapInitials: "" },
{ name: "Suzuka Circuit", results: [], fastestLapInitials: "" },
{ name: "Snetterton", results: [], fastestLapInitials: "" },
{ name: "Spa-Francochamps", results: [], fastestLapInitials: "" },
{ name: "Circuit of the Americas (COTA)", results: [], fastestLapInitials: "" },
{ name: "Donington Park", results: [], fastestLapInitials: "" },
{ name: "Monza", results: [], fastestLapInitials: "" },
{ name: "Misano", results: [], fastestLapInitials: "" },
{ name: "Kyalami Grand Prix Circuit", results: [], fastestLapInitials: "" },
{ name: "Oulton Park", results: [], fastestLapInitials: "" },
{ name: "Red Bull Ring", results: [], fastestLapInitials: "" },
{ name: "Nürburgring", results: [], fastestLapInitials: "" }
{ id: "track-hungaroring", name: "Hungaroring", results: [], fastestLapDriverId: "" },
{ id: "track-watkins", name: "Watkins Glen", results: [], fastestLapDriverId: "" },
{ id: "track-paulricard", name: "Paul Ricard", results: [], fastestLapDriverId: "" },
{ id: "track-silverstone", name: "Silverstone", results: [], fastestLapDriverId: "" },
{ id: "track-zandvoort", name: "Zandvoort", results: [], fastestLapDriverId: "" },
{ id: "track-brands", name: "Brands Hatch", results: [], fastestLapDriverId: "" },
{ id: "track-valencia", name: "Circuit Ricardo Tormo (Valencia)", results: [], fastestLapDriverId: "" },
{ id: "track-bathurst", name: "Mount Panorama Circuit", results: [], fastestLapDriverId: "" },
{ id: "track-imola", name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [], fastestLapDriverId: "" },
{ id: "track-barcelona", name: "Barcelona", results: [], fastestLapDriverId: "" },
{ id: "track-indianapolis", name: "Indianapolis", results: [], fastestLapDriverId: "" },
{ id: "track-laguna", name: "WeatherTech Raceway Laguna Seca", results: [], fastestLapDriverId: "" },
{ id: "track-zolder", name: "Zolder", results: [], fastestLapDriverId: "" },
{ id: "track-suzuka", name: "Suzuka Circuit", results: [], fastestLapDriverId: "" },
{ id: "track-snetterton", name: "Snetterton", results: [], fastestLapDriverId: "" },
{ id: "track-spa", name: "Spa-Francochamps", results: [], fastestLapDriverId: "" },
{ id: "track-cota", name: "Circuit of the Americas (COTA)", results: [], fastestLapDriverId: "" },
{ id: "track-donington", name: "Donington Park", results: [], fastestLapDriverId: "" },
{ id: "track-monza", name: "Monza", results: [], fastestLapDriverId: "" },
{ id: "track-misano", name: "Misano", results: [], fastestLapDriverId: "" },
{ id: "track-kyalami", name: "Kyalami Grand Prix Circuit", results: [], fastestLapDriverId: "" },
{ id: "track-oulton", name: "Oulton Park", results: [], fastestLapDriverId: "" },
{ id: "track-redbull", name: "Red Bull Ring", results: [], fastestLapDriverId: "" },
{ id: "track-nurburgring", name: "Nürburgring", results: [], fastestLapDriverId: "" }
]
};

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

@@ -1,43 +1,64 @@
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(appData: AppData, points: number): PointStyle {
return appData.pointsTable.find(p => p.points === points) || {
points: 0,
label: '???',
color: '#374151',
textColor: '#e5e7eb',
priority: 99
};
export function findStyleByPoints(
appData: AppData,
points: number,
): PointStyle {
return (
appData.pointsTable.find((p) => p.points === points) || {
id: 'unknown',
points: 0,
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[] = [];
const driverInitials = driver.initials;
appData.tracks.forEach((track) => {
let result = track.results[driverIndex];
// DNS fallback: if result is undefined, null, or empty, treat as DNS
if (result === undefined || result === null || result === '') {
result = 'DNS';
}
let racePoints = parsePoints(result);
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
racePoints += appData.flPoints.points || 1;
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({
trackName: track.name,
points: racePoints,
cumulativePoints: cumulativePoints
cumulativePoints: cumulativePoints,
});
});
@@ -45,9 +66,9 @@ export function calculateStandingsData(appData: AppData): DriverStanding[] {
name: driver.name,
color: driver.color,
initials: driver.initials,
totalPoints: totalPoints,
raceHistory: raceHistory,
originalIndex: driverIndex
totalPoints,
raceHistory,
originalIndex: driverIndex,
};
});

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

@@ -1,10 +1,12 @@
export interface Driver {
id: string;
name: string;
color: string;
initials: string;
}
export interface PointStyle {
id?: string;
points: number;
label: string;
color: string;
@@ -12,10 +14,16 @@ export interface PointStyle {
priority: number;
}
export interface RaceResult {
driverId: string;
score: number | null;
}
export interface Track {
id: string;
name: string;
results: (number | string | null | undefined)[];
fastestLapInitials: string;
results: RaceResult[];
fastestLapDriverId: string;
}
export interface RaceHistoryEntry {
@@ -34,8 +42,12 @@ export interface DriverStanding {
}
export interface AppData {
id?: string;
serverId?: string;
drivers: Driver[];
pointsTable: PointStyle[];
flPoints: PointStyle;
flPoints: number;
flColor: string;
flTextColor: string;
tracks: 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'),
},
},
});