migrate to vite
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m15s
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m15s
This commit is contained in:
6
.env.local.example
Normal file → Executable file
6
.env.local.example
Normal file → Executable 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
9
.gitea/workflows/deploy.yaml
Normal file → Executable 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
3
.gitignore
vendored
Normal file → Executable 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
24
eslint.config.js
Normal 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
17
index.html
Normal 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
6
next-env.d.ts
vendored
@@ -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.
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
3276
package-lock.json
generated
Normal file → Executable file
3276
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
20
package.json
Normal file → Executable 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
0
postcss.config.mjs
Normal file → Executable file
93
src/App.tsx
Normal file
93
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
193
src/components/CumulativeChart.tsx
Normal file
193
src/components/CumulativeChart.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
29
src/lib/api.ts
Normal file → Executable 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
0
src/lib/defaultData.ts
Normal file → Executable file
46
src/lib/leaderboard.ts
Normal file → Executable file
46
src/lib/leaderboard.ts
Normal file → Executable file
@@ -1,9 +1,13 @@
|
||||
import { AppData, PointStyle, DriverStanding, RaceHistoryEntry } from "./types";
|
||||
import { AppData, PointStyle, DriverStanding, RaceHistoryEntry } from './types';
|
||||
|
||||
export function parsePoints(value: any): number {
|
||||
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,
|
||||
@@ -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
2
src/lib/types.ts
Normal file → Executable 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
10
src/main.tsx
Normal 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
12
tailwind.config.ts
Normal file → Executable 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
13
tsconfig.json
Normal file → Executable 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
12
tsconfig.node.json
Normal 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
13
vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user