Phase 0: scaffold cup26 — Vite+React+Fastify TS PWA, broadcast theme, app shell
Mirrors the TTRPG stack (strict TS, Tailwind 4, vite-plugin-pwa, esbuild server bundle, multi-stage Dockerfile, Traefik compose). Floodlit-pitch design tokens, UI primitives (Button/Card/Badge), TanStack Router shell with Live/Groups/ Bracket/Predict/Story routes (placeholders), dependency-free PWA icon generator. Raw data staged: openfootball 2026 fixtures + martj42 international results CSV. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "cup26-web",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
dev-dist
|
||||
server/dist
|
||||
server/node_modules
|
||||
.git
|
||||
**/*.test.ts
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
dist
|
||||
dev-dist
|
||||
server/dist
|
||||
server/node_modules
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
# Generated build artifacts (reproduced by `npm run data:build`)
|
||||
public/data
|
||||
public/pwa-192x192.png
|
||||
public/pwa-512x512.png
|
||||
public/favicon.svg
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# --- build stage: generate data, bundle the PWA + the live server ---
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* bun.lockb* ./
|
||||
RUN bun install
|
||||
COPY . .
|
||||
RUN bun run data:build # icons + fixtures.json + ratings.json → public/
|
||||
RUN bun run build # tsc -b && vite build → /app/dist
|
||||
RUN bun run build:server # esbuild → /app/server/dist/index.js
|
||||
|
||||
# --- runtime stage: small node image, non-root, serves dist + /ws + /api ---
|
||||
FROM node:20-slim AS runtime
|
||||
ENV NODE_ENV=production STATIC_DIR=/app/dist PORT=8787
|
||||
WORKDIR /app/server
|
||||
COPY server/package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY --from=build /app/server/dist ./dist
|
||||
COPY --from=build /app/dist /app/dist
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 8787
|
||||
CMD ["node", "dist/index.js"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,728 @@
|
||||
{"name": "World Cup 2026",
|
||||
"matches":
|
||||
[{"round": "Matchday 1",
|
||||
"date": "2026-06-11",
|
||||
"time": "13:00 UTC-6",
|
||||
"team1": "Mexico",
|
||||
"team2": "South Africa",
|
||||
"group": "Group A",
|
||||
"ground": "Mexico City"},
|
||||
{"round": "Matchday 1",
|
||||
"date": "2026-06-11",
|
||||
"time": "20:00 UTC-6",
|
||||
"team1": "South Korea",
|
||||
"team2": "Czech Republic",
|
||||
"group": "Group A",
|
||||
"ground": "Guadalajara (Zapopan)"},
|
||||
{"round": "Matchday 8",
|
||||
"date": "2026-06-18",
|
||||
"time": "12:00 UTC-4",
|
||||
"team1": "Czech Republic",
|
||||
"team2": "South Africa",
|
||||
"group": "Group A",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Matchday 8",
|
||||
"date": "2026-06-18",
|
||||
"time": "19:00 UTC-6",
|
||||
"team1": "Mexico",
|
||||
"team2": "South Korea",
|
||||
"group": "Group A",
|
||||
"ground": "Guadalajara (Zapopan)"},
|
||||
{"round": "Matchday 14",
|
||||
"date": "2026-06-24",
|
||||
"time": "19:00 UTC-6",
|
||||
"team1": "Czech Republic",
|
||||
"team2": "Mexico",
|
||||
"group": "Group A",
|
||||
"ground": "Mexico City"},
|
||||
{"round": "Matchday 14",
|
||||
"date": "2026-06-24",
|
||||
"time": "19:00 UTC-6",
|
||||
"team1": "South Africa",
|
||||
"team2": "South Korea",
|
||||
"group": "Group A",
|
||||
"ground": "Monterrey (Guadalupe)"},
|
||||
{"round": "Matchday 2",
|
||||
"date": "2026-06-12",
|
||||
"time": "15:00 UTC-4",
|
||||
"team1": "Canada",
|
||||
"team2": "Bosnia & Herzegovina",
|
||||
"group": "Group B",
|
||||
"ground": "Toronto"},
|
||||
{"round": "Matchday 3",
|
||||
"date": "2026-06-13",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "Qatar",
|
||||
"team2": "Switzerland",
|
||||
"group": "Group B",
|
||||
"ground": "San Francisco Bay Area (Santa Clara)"},
|
||||
{"round": "Matchday 8",
|
||||
"date": "2026-06-18",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "Switzerland",
|
||||
"team2": "Bosnia & Herzegovina",
|
||||
"group": "Group B",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Matchday 8",
|
||||
"date": "2026-06-18",
|
||||
"time": "15:00 UTC-7",
|
||||
"team1": "Canada",
|
||||
"team2": "Qatar",
|
||||
"group": "Group B",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Matchday 14",
|
||||
"date": "2026-06-24",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "Switzerland",
|
||||
"team2": "Canada",
|
||||
"group": "Group B",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Matchday 14",
|
||||
"date": "2026-06-24",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "Bosnia & Herzegovina",
|
||||
"team2": "Qatar",
|
||||
"group": "Group B",
|
||||
"ground": "Seattle"},
|
||||
{"round": "Matchday 3",
|
||||
"date": "2026-06-13",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Brazil",
|
||||
"team2": "Morocco",
|
||||
"group": "Group C",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Matchday 3",
|
||||
"date": "2026-06-13",
|
||||
"time": "21:00 UTC-4",
|
||||
"team1": "Haiti",
|
||||
"team2": "Scotland",
|
||||
"group": "Group C",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Matchday 9",
|
||||
"date": "2026-06-19",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Scotland",
|
||||
"team2": "Morocco",
|
||||
"group": "Group C",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Matchday 9",
|
||||
"date": "2026-06-19",
|
||||
"time": "20:30 UTC-4",
|
||||
"team1": "Brazil",
|
||||
"team2": "Haiti",
|
||||
"group": "Group C",
|
||||
"ground": "Philadelphia"},
|
||||
{"round": "Matchday 14",
|
||||
"date": "2026-06-24",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Scotland",
|
||||
"team2": "Brazil",
|
||||
"group": "Group C",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Matchday 14",
|
||||
"date": "2026-06-24",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Morocco",
|
||||
"team2": "Haiti",
|
||||
"group": "Group C",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Matchday 2",
|
||||
"date": "2026-06-12",
|
||||
"time": "18:00 UTC-7",
|
||||
"team1": "USA",
|
||||
"team2": "Paraguay",
|
||||
"group": "Group D",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Matchday 3",
|
||||
"date": "2026-06-13",
|
||||
"time": "21:00 UTC-7",
|
||||
"team1": "Australia",
|
||||
"team2": "Turkey",
|
||||
"group": "Group D",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Matchday 9",
|
||||
"date": "2026-06-19",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "USA",
|
||||
"team2": "Australia",
|
||||
"group": "Group D",
|
||||
"ground": "Seattle"},
|
||||
{"round": "Matchday 9",
|
||||
"date": "2026-06-19",
|
||||
"time": "20:00 UTC-7",
|
||||
"team1": "Turkey",
|
||||
"team2": "Paraguay",
|
||||
"group": "Group D",
|
||||
"ground": "San Francisco Bay Area (Santa Clara)"},
|
||||
{"round": "Matchday 15",
|
||||
"date": "2026-06-25",
|
||||
"time": "19:00 UTC-7",
|
||||
"team1": "Turkey",
|
||||
"team2": "USA",
|
||||
"group": "Group D",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Matchday 15",
|
||||
"date": "2026-06-25",
|
||||
"time": "19:00 UTC-7",
|
||||
"team1": "Paraguay",
|
||||
"team2": "Australia",
|
||||
"group": "Group D",
|
||||
"ground": "San Francisco Bay Area (Santa Clara)"},
|
||||
{"round": "Matchday 4",
|
||||
"date": "2026-06-14",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "Germany",
|
||||
"team2": "Curaçao",
|
||||
"group": "Group E",
|
||||
"ground": "Houston"},
|
||||
{"round": "Matchday 4",
|
||||
"date": "2026-06-14",
|
||||
"time": "19:00 UTC-4",
|
||||
"team1": "Ivory Coast",
|
||||
"team2": "Ecuador",
|
||||
"group": "Group E",
|
||||
"ground": "Philadelphia"},
|
||||
{"round": "Matchday 10",
|
||||
"date": "2026-06-20",
|
||||
"time": "16:00 UTC-4",
|
||||
"team1": "Germany",
|
||||
"team2": "Ivory Coast",
|
||||
"group": "Group E",
|
||||
"ground": "Toronto"},
|
||||
{"round": "Matchday 10",
|
||||
"date": "2026-06-20",
|
||||
"time": "19:00 UTC-5",
|
||||
"team1": "Ecuador",
|
||||
"team2": "Curaçao",
|
||||
"group": "Group E",
|
||||
"ground": "Kansas City"},
|
||||
{"round": "Matchday 15",
|
||||
"date": "2026-06-25",
|
||||
"time": "16:00 UTC-4",
|
||||
"team1": "Curaçao",
|
||||
"team2": "Ivory Coast",
|
||||
"group": "Group E",
|
||||
"ground": "Philadelphia"},
|
||||
{"round": "Matchday 15",
|
||||
"date": "2026-06-25",
|
||||
"time": "16:00 UTC-4",
|
||||
"team1": "Ecuador",
|
||||
"team2": "Germany",
|
||||
"group": "Group E",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Matchday 4",
|
||||
"date": "2026-06-14",
|
||||
"time": "15:00 UTC-5",
|
||||
"team1": "Netherlands",
|
||||
"team2": "Japan",
|
||||
"group": "Group F",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Matchday 4",
|
||||
"date": "2026-06-14",
|
||||
"time": "20:00 UTC-6",
|
||||
"team1": "Sweden",
|
||||
"team2": "Tunisia",
|
||||
"group": "Group F",
|
||||
"ground": "Monterrey (Guadalupe)"},
|
||||
{"round": "Matchday 10",
|
||||
"date": "2026-06-20",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "Netherlands",
|
||||
"team2": "Sweden",
|
||||
"group": "Group F",
|
||||
"ground": "Houston"},
|
||||
{"round": "Matchday 10",
|
||||
"date": "2026-06-20",
|
||||
"time": "22:00 UTC-6",
|
||||
"team1": "Tunisia",
|
||||
"team2": "Japan",
|
||||
"group": "Group F",
|
||||
"ground": "Monterrey (Guadalupe)"},
|
||||
{"round": "Matchday 15",
|
||||
"date": "2026-06-25",
|
||||
"time": "18:00 UTC-5",
|
||||
"team1": "Japan",
|
||||
"team2": "Sweden",
|
||||
"group": "Group F",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Matchday 15",
|
||||
"date": "2026-06-25",
|
||||
"time": "18:00 UTC-5",
|
||||
"team1": "Tunisia",
|
||||
"team2": "Netherlands",
|
||||
"group": "Group F",
|
||||
"ground": "Kansas City"},
|
||||
{"round": "Matchday 5",
|
||||
"date": "2026-06-15",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "Belgium",
|
||||
"team2": "Egypt",
|
||||
"group": "Group G",
|
||||
"ground": "Seattle"},
|
||||
{"round": "Matchday 5",
|
||||
"date": "2026-06-15",
|
||||
"time": "18:00 UTC-7",
|
||||
"team1": "Iran",
|
||||
"team2": "New Zealand",
|
||||
"group": "Group G",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Matchday 11",
|
||||
"date": "2026-06-21",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "Belgium",
|
||||
"team2": "Iran",
|
||||
"group": "Group G",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Matchday 11",
|
||||
"date": "2026-06-21",
|
||||
"time": "18:00 UTC-7",
|
||||
"team1": "New Zealand",
|
||||
"team2": "Egypt",
|
||||
"group": "Group G",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Matchday 16",
|
||||
"date": "2026-06-26",
|
||||
"time": "20:00 UTC-7",
|
||||
"team1": "Egypt",
|
||||
"team2": "Iran",
|
||||
"group": "Group G",
|
||||
"ground": "Seattle"},
|
||||
{"round": "Matchday 16",
|
||||
"date": "2026-06-26",
|
||||
"time": "20:00 UTC-7",
|
||||
"team1": "New Zealand",
|
||||
"team2": "Belgium",
|
||||
"group": "Group G",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Matchday 5",
|
||||
"date": "2026-06-15",
|
||||
"time": "12:00 UTC-4",
|
||||
"team1": "Spain",
|
||||
"team2": "Cape Verde",
|
||||
"group": "Group H",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Matchday 5",
|
||||
"date": "2026-06-15",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Saudi Arabia",
|
||||
"team2": "Uruguay",
|
||||
"group": "Group H",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Matchday 11",
|
||||
"date": "2026-06-21",
|
||||
"time": "12:00 UTC-4",
|
||||
"team1": "Spain",
|
||||
"team2": "Saudi Arabia",
|
||||
"group": "Group H",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Matchday 11",
|
||||
"date": "2026-06-21",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Uruguay",
|
||||
"team2": "Cape Verde",
|
||||
"group": "Group H",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Matchday 16",
|
||||
"date": "2026-06-26",
|
||||
"time": "19:00 UTC-5",
|
||||
"team1": "Cape Verde",
|
||||
"team2": "Saudi Arabia",
|
||||
"group": "Group H",
|
||||
"ground": "Houston"},
|
||||
{"round": "Matchday 16",
|
||||
"date": "2026-06-26",
|
||||
"time": "18:00 UTC-6",
|
||||
"team1": "Uruguay",
|
||||
"team2": "Spain",
|
||||
"group": "Group H",
|
||||
"ground": "Guadalajara (Zapopan)"},
|
||||
{"round": "Matchday 6",
|
||||
"date": "2026-06-16",
|
||||
"time": "15:00 UTC-4",
|
||||
"team1": "France",
|
||||
"team2": "Senegal",
|
||||
"group": "Group I",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Matchday 6",
|
||||
"date": "2026-06-16",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "Iraq",
|
||||
"team2": "Norway",
|
||||
"group": "Group I",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Matchday 12",
|
||||
"date": "2026-06-22",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "France",
|
||||
"team2": "Iraq",
|
||||
"group": "Group I",
|
||||
"ground": "Philadelphia"},
|
||||
{"round": "Matchday 12",
|
||||
"date": "2026-06-22",
|
||||
"time": "20:00 UTC-4",
|
||||
"team1": "Norway",
|
||||
"team2": "Senegal",
|
||||
"group": "Group I",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Matchday 16",
|
||||
"date": "2026-06-26",
|
||||
"time": "15:00 UTC-4",
|
||||
"team1": "Norway",
|
||||
"team2": "France",
|
||||
"group": "Group I",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Matchday 16",
|
||||
"date": "2026-06-26",
|
||||
"time": "15:00 UTC-4",
|
||||
"team1": "Senegal",
|
||||
"team2": "Iraq",
|
||||
"group": "Group I",
|
||||
"ground": "Toronto"},
|
||||
{"round": "Matchday 6",
|
||||
"date": "2026-06-16",
|
||||
"time": "20:00 UTC-5",
|
||||
"team1": "Argentina",
|
||||
"team2": "Algeria",
|
||||
"group": "Group J",
|
||||
"ground": "Kansas City"},
|
||||
{"round": "Matchday 6",
|
||||
"date": "2026-06-16",
|
||||
"time": "21:00 UTC-7",
|
||||
"team1": "Austria",
|
||||
"team2": "Jordan",
|
||||
"group": "Group J",
|
||||
"ground": "San Francisco Bay Area (Santa Clara)"},
|
||||
{"round": "Matchday 12",
|
||||
"date": "2026-06-22",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "Argentina",
|
||||
"team2": "Austria",
|
||||
"group": "Group J",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Matchday 12",
|
||||
"date": "2026-06-22",
|
||||
"time": "20:00 UTC-7",
|
||||
"team1": "Jordan",
|
||||
"team2": "Algeria",
|
||||
"group": "Group J",
|
||||
"ground": "San Francisco Bay Area (Santa Clara)"},
|
||||
{"round": "Matchday 17",
|
||||
"date": "2026-06-27",
|
||||
"time": "21:00 UTC-5",
|
||||
"team1": "Algeria",
|
||||
"team2": "Austria",
|
||||
"group": "Group J",
|
||||
"ground": "Kansas City"},
|
||||
{"round": "Matchday 17",
|
||||
"date": "2026-06-27",
|
||||
"time": "21:00 UTC-5",
|
||||
"team1": "Jordan",
|
||||
"team2": "Argentina",
|
||||
"group": "Group J",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Matchday 7",
|
||||
"date": "2026-06-17",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "Portugal",
|
||||
"team2": "DR Congo",
|
||||
"group": "Group K",
|
||||
"ground": "Houston"},
|
||||
{"round": "Matchday 7",
|
||||
"date": "2026-06-17",
|
||||
"time": "20:00 UTC-6",
|
||||
"team1": "Uzbekistan",
|
||||
"team2": "Colombia",
|
||||
"group": "Group K",
|
||||
"ground": "Mexico City"},
|
||||
{"round": "Matchday 13",
|
||||
"date": "2026-06-23",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "Portugal",
|
||||
"team2": "Uzbekistan",
|
||||
"group": "Group K",
|
||||
"ground": "Houston"},
|
||||
{"round": "Matchday 13",
|
||||
"date": "2026-06-23",
|
||||
"time": "20:00 UTC-6",
|
||||
"team1": "Colombia",
|
||||
"team2": "DR Congo",
|
||||
"group": "Group K",
|
||||
"ground": "Guadalajara (Zapopan)"},
|
||||
{"round": "Matchday 17",
|
||||
"date": "2026-06-27",
|
||||
"time": "19:30 UTC-4",
|
||||
"team1": "Colombia",
|
||||
"team2": "Portugal",
|
||||
"group": "Group K",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Matchday 17",
|
||||
"date": "2026-06-27",
|
||||
"time": "19:30 UTC-4",
|
||||
"team1": "DR Congo",
|
||||
"team2": "Uzbekistan",
|
||||
"group": "Group K",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Matchday 7",
|
||||
"date": "2026-06-17",
|
||||
"time": "15:00 UTC-5",
|
||||
"team1": "England",
|
||||
"team2": "Croatia",
|
||||
"group": "Group L",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Matchday 7",
|
||||
"date": "2026-06-17",
|
||||
"time": "19:00 UTC-4",
|
||||
"team1": "Ghana",
|
||||
"team2": "Panama",
|
||||
"group": "Group L",
|
||||
"ground": "Toronto"},
|
||||
{"round": "Matchday 13",
|
||||
"date": "2026-06-23",
|
||||
"time": "16:00 UTC-4",
|
||||
"team1": "England",
|
||||
"team2": "Ghana",
|
||||
"group": "Group L",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Matchday 13",
|
||||
"date": "2026-06-23",
|
||||
"time": "19:00 UTC-4",
|
||||
"team1": "Panama",
|
||||
"team2": "Croatia",
|
||||
"group": "Group L",
|
||||
"ground": "Toronto"},
|
||||
{"round": "Matchday 17",
|
||||
"date": "2026-06-27",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "Panama",
|
||||
"team2": "England",
|
||||
"group": "Group L",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Matchday 17",
|
||||
"date": "2026-06-27",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "Croatia",
|
||||
"team2": "Ghana",
|
||||
"group": "Group L",
|
||||
"ground": "Philadelphia"},
|
||||
{"round": "Round of 32",
|
||||
"num": 73,
|
||||
"date": "2026-06-28",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "2A",
|
||||
"team2": "2B",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 74,
|
||||
"date": "2026-06-29",
|
||||
"time": "16:30 UTC-4",
|
||||
"team1": "1E",
|
||||
"team2": "3A/B/C/D/F",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 75,
|
||||
"date": "2026-06-29",
|
||||
"time": "19:00 UTC-6",
|
||||
"team1": "1F",
|
||||
"team2": "2C",
|
||||
"ground": "Monterrey (Guadalupe)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 76,
|
||||
"date": "2026-06-29",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "1C",
|
||||
"team2": "2F",
|
||||
"ground": "Houston"},
|
||||
{"round": "Round of 32",
|
||||
"num": 77,
|
||||
"date": "2026-06-30",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "1I",
|
||||
"team2": "3C/D/F/G/H",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 78,
|
||||
"date": "2026-06-30",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "2E",
|
||||
"team2": "2I",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 79,
|
||||
"date": "2026-06-30",
|
||||
"time": "19:00 UTC-6",
|
||||
"team1": "1A",
|
||||
"team2": "3C/E/F/H/I",
|
||||
"ground": "Mexico City"},
|
||||
{"round": "Round of 32",
|
||||
"num": 80,
|
||||
"date": "2026-07-01",
|
||||
"time": "12:00 UTC-4",
|
||||
"team1": "1L",
|
||||
"team2": "3E/H/I/J/K",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Round of 32",
|
||||
"num": 81,
|
||||
"date": "2026-07-01",
|
||||
"time": "17:00 UTC-7",
|
||||
"team1": "1D",
|
||||
"team2": "3B/E/F/I/J",
|
||||
"ground": "San Francisco Bay Area (Santa Clara)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 82,
|
||||
"date": "2026-07-01",
|
||||
"time": "13:00 UTC-7",
|
||||
"team1": "1G",
|
||||
"team2": "3A/E/H/I/J",
|
||||
"ground": "Seattle"},
|
||||
{"round": "Round of 32",
|
||||
"num": 83,
|
||||
"date": "2026-07-02",
|
||||
"time": "19:00 UTC-4",
|
||||
"team1": "2K",
|
||||
"team2": "2L",
|
||||
"ground": "Toronto"},
|
||||
{"round": "Round of 32",
|
||||
"num": 84,
|
||||
"date": "2026-07-02",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "1H",
|
||||
"team2": "2J",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 85,
|
||||
"date": "2026-07-02",
|
||||
"time": "20:00 UTC-7",
|
||||
"team1": "1B",
|
||||
"team2": "3E/F/G/I/J",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Round of 32",
|
||||
"num": 86,
|
||||
"date": "2026-07-03",
|
||||
"time": "18:00 UTC-4",
|
||||
"team1": "1J",
|
||||
"team2": "2H",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Round of 32",
|
||||
"num": 87,
|
||||
"date": "2026-07-03",
|
||||
"time": "20:30 UTC-5",
|
||||
"team1": "1K",
|
||||
"team2": "3D/E/I/J/L",
|
||||
"ground": "Kansas City"},
|
||||
{"round": "Round of 32",
|
||||
"num": 88,
|
||||
"date": "2026-07-03",
|
||||
"time": "13:00 UTC-5",
|
||||
"team1": "2D",
|
||||
"team2": "2G",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Round of 16",
|
||||
"num": 89,
|
||||
"date": "2026-07-04",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "W74",
|
||||
"team2": "W77",
|
||||
"ground": "Philadelphia"},
|
||||
{"round": "Round of 16",
|
||||
"num": 90,
|
||||
"date": "2026-07-04",
|
||||
"time": "12:00 UTC-5",
|
||||
"team1": "W73",
|
||||
"team2": "W75",
|
||||
"ground": "Houston"},
|
||||
{"round": "Round of 16",
|
||||
"num": 91,
|
||||
"date": "2026-07-05",
|
||||
"time": "16:00 UTC-4",
|
||||
"team1": "W76",
|
||||
"team2": "W78",
|
||||
"ground": "New York/New Jersey (East Rutherford)"},
|
||||
{"round": "Round of 16",
|
||||
"num": 92,
|
||||
"date": "2026-07-05",
|
||||
"time": "18:00 UTC-6",
|
||||
"team1": "W79",
|
||||
"team2": "W80",
|
||||
"ground": "Mexico City"},
|
||||
{"round": "Round of 16",
|
||||
"num": 93,
|
||||
"date": "2026-07-06",
|
||||
"time": "14:00 UTC-5",
|
||||
"team1": "W83",
|
||||
"team2": "W84",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Round of 16",
|
||||
"num": 94,
|
||||
"date": "2026-07-06",
|
||||
"time": "17:00 UTC-7",
|
||||
"team1": "W81",
|
||||
"team2": "W82",
|
||||
"ground": "Seattle"},
|
||||
{"round": "Round of 16",
|
||||
"num": 95,
|
||||
"date": "2026-07-07",
|
||||
"time": "12:00 UTC-4",
|
||||
"team1": "W86",
|
||||
"team2": "W88",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Round of 16",
|
||||
"num": 96,
|
||||
"date": "2026-07-07",
|
||||
"time": "13:00 UTC-7",
|
||||
"team1": "W85",
|
||||
"team2": "W87",
|
||||
"ground": "Vancouver"},
|
||||
{"round": "Quarter-final",
|
||||
"num": 97,
|
||||
"date": "2026-07-09",
|
||||
"time": "16:00 UTC-4",
|
||||
"team1": "W89",
|
||||
"team2": "W90",
|
||||
"ground": "Boston (Foxborough)"},
|
||||
{"round": "Quarter-final",
|
||||
"num": 98,
|
||||
"date": "2026-07-10",
|
||||
"time": "12:00 UTC-7",
|
||||
"team1": "W93",
|
||||
"team2": "W94",
|
||||
"ground": "Los Angeles (Inglewood)"},
|
||||
{"round": "Quarter-final",
|
||||
"num": 99,
|
||||
"date": "2026-07-11",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "W91",
|
||||
"team2": "W92",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Quarter-final",
|
||||
"num": 100,
|
||||
"date": "2026-07-11",
|
||||
"time": "20:00 UTC-5",
|
||||
"team1": "W95",
|
||||
"team2": "W96",
|
||||
"ground": "Kansas City"},
|
||||
{"round": "Semi-final",
|
||||
"num": 101,
|
||||
"date": "2026-07-14",
|
||||
"time": "14:00 UTC-5",
|
||||
"team1": "W97",
|
||||
"team2": "W98",
|
||||
"ground": "Dallas (Arlington)"},
|
||||
{"round": "Semi-final",
|
||||
"num": 102,
|
||||
"date": "2026-07-15",
|
||||
"time": "15:00 UTC-4",
|
||||
"team1": "W99",
|
||||
"team2": "W100",
|
||||
"ground": "Atlanta"},
|
||||
{"round": "Match for third place",
|
||||
"date": "2026-07-18",
|
||||
"time": "17:00 UTC-4",
|
||||
"team1": "L101",
|
||||
"team2": "L102",
|
||||
"ground": "Miami (Miami Gardens)"},
|
||||
{"round": "Final",
|
||||
"date": "2026-07-19",
|
||||
"time": "15:00 UTC-4",
|
||||
"team1": "W101",
|
||||
"team2": "W102",
|
||||
"ground": "New York/New Jersey (East Rutherford)"}]}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Self-contained compose project for cup26.briggen.dev.
|
||||
# Deploy: rsync the repo to /root/cup26/, place this file there as
|
||||
# docker-compose.yml, then `docker compose up -d --build`.
|
||||
# Joins the EXISTING external `proxy` network — the shared Traefik stack is untouched.
|
||||
name: cup26
|
||||
|
||||
services:
|
||||
cup26:
|
||||
build: .
|
||||
container_name: cup26
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=8787
|
||||
- STATIC_DIR=/app/dist
|
||||
- NODE_ENV=production
|
||||
- ALLOWED_ORIGINS=https://cup26.briggen.dev
|
||||
# football-data.org free API token (https://www.football-data.org/client/register)
|
||||
- FOOTBALL_DATA_TOKEN=${FOOTBALL_DATA_TOKEN:-}
|
||||
# opt-in: layer the unofficial real-time source on top of the free API
|
||||
- ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 384m
|
||||
memswap_limit: 384m
|
||||
pids_limit: 200
|
||||
cpus: 1.0
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.cup26.rule=Host(`cup26.briggen.dev`)"
|
||||
- "traefik.http.routers.cup26.entrypoints=websecure"
|
||||
- "traefik.http.routers.cup26.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.cup26.loadbalancer.server.port=8787"
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,27 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'dev-dist', 'server/dist', 'public/data'] },
|
||||
{ files: ['server/**/*.ts', 'server/build.mjs', 'scripts/**/*'], languageOptions: { globals: globals.node } },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a1410" />
|
||||
<!--CSP-PLACEHOLDER-->
|
||||
<title>Cup26 — World Cup 2026</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "cup26",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"build:server": "node server/build.mjs",
|
||||
"dev:server": "tsx watch server/src/index.ts",
|
||||
"data:fixtures": "tsx scripts/buildFixtures.ts",
|
||||
"data:ratings": "tsx scripts/buildRatings.ts",
|
||||
"data:viz": "tsx scripts/buildStatsbombViz.ts",
|
||||
"data:icons": "node scripts/make-icons.mjs",
|
||||
"data:build": "npm run data:icons && npm run data:fixtures && npm run data:ratings"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/d3-scale": "^4.0.8",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"fastify": "^5.8.5",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.19.0",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Dependency-free icon generator: draws a "floodlit pitch" crest and writes
|
||||
// favicon.svg + pwa-192x192.png + pwa-512x512.png into public/.
|
||||
// Pure JS (zlib only) so it runs in the minimal Docker build image too — no
|
||||
// ImageMagick / librsvg required.
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// ---- palette (matches the dark theme) ----
|
||||
const BG = [10, 20, 16]; // surface
|
||||
const FIELD = [20, 83, 56]; // pitch green
|
||||
const FIELD_DK = [15, 64, 44];
|
||||
const LINE = [220, 240, 230];
|
||||
const BALL = [224, 184, 92]; // gold
|
||||
|
||||
// ---- pure-JS PNG encoder (RGBA, filter 0) ----
|
||||
const CRC = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
function encodePNG(width, height, rgba) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (width * 4 + 1)] = 0; // filter: none
|
||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||||
}
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- drawing helpers ----
|
||||
function makeIcon(size) {
|
||||
const buf = Buffer.alloc(size * size * 4);
|
||||
const set = (x, y, [r, g, b], a = 255) => {
|
||||
if (x < 0 || y < 0 || x >= size || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
const ia = a / 255;
|
||||
buf[i] = Math.round(buf[i] * (1 - ia) + r * ia);
|
||||
buf[i + 1] = Math.round(buf[i + 1] * (1 - ia) + g * ia);
|
||||
buf[i + 2] = Math.round(buf[i + 2] * (1 - ia) + b * ia);
|
||||
buf[i + 3] = Math.max(buf[i + 3], a);
|
||||
};
|
||||
|
||||
// full-bleed background (good for maskable)
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) set(x, y, BG);
|
||||
|
||||
// inset rounded field
|
||||
const pad = Math.round(size * 0.14);
|
||||
const r = Math.round(size * 0.18);
|
||||
const x0 = pad, y0 = pad, x1 = size - pad, y1 = size - pad;
|
||||
const inField = (x, y) => {
|
||||
if (x < x0 || x > x1 || y < y0 || y > y1) return false;
|
||||
const cx = x < x0 + r ? x0 + r : x > x1 - r ? x1 - r : x;
|
||||
const cy = y < y0 + r ? y0 + r : y > y1 - r ? y1 - r : y;
|
||||
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
|
||||
};
|
||||
const cx = size / 2;
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
if (!inField(x, y)) continue;
|
||||
// subtle vertical mowing stripes
|
||||
const stripe = Math.floor((x - x0) / (size * 0.09)) % 2 === 0;
|
||||
set(x, y, stripe ? FIELD : FIELD_DK);
|
||||
}
|
||||
}
|
||||
|
||||
// markings (white): halfway line, center circle, center spot as gold ball
|
||||
const lw = Math.max(2, Math.round(size * 0.012));
|
||||
const ring = Math.round(size * 0.17);
|
||||
const ringW = Math.max(2, Math.round(size * 0.016));
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
if (!inField(x, y)) continue;
|
||||
// halfway line
|
||||
if (Math.abs(x - cx) <= lw / 2) set(x, y, LINE, 220);
|
||||
// center circle ring
|
||||
const d = Math.hypot(x - cx, y - size / 2);
|
||||
if (Math.abs(d - ring) <= ringW / 2) set(x, y, LINE, 220);
|
||||
}
|
||||
}
|
||||
// center gold ball
|
||||
const ballR = Math.round(size * 0.055);
|
||||
for (let y = -ballR; y <= ballR; y++)
|
||||
for (let x = -ballR; x <= ballR; x++)
|
||||
if (x * x + y * y <= ballR * ballR) set(Math.round(cx + x), Math.round(size / 2 + y), BALL);
|
||||
|
||||
return encodePNG(size, size, buf);
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="0" fill="#0a1410"/>
|
||||
<rect x="72" y="72" width="368" height="368" rx="92" fill="#14533a"/>
|
||||
<g stroke="#dcf0e6" stroke-width="7" fill="none" opacity="0.85">
|
||||
<line x1="256" y1="72" x2="256" y2="440"/>
|
||||
<circle cx="256" cy="256" r="86"/>
|
||||
</g>
|
||||
<circle cx="256" cy="256" r="28" fill="#e0b85c"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
writeFileSync(join(OUT, 'favicon.svg'), svg);
|
||||
writeFileSync(join(OUT, 'pwa-192x192.png'), makeIcon(192));
|
||||
writeFileSync(join(OUT, 'pwa-512x512.png'), makeIcon(512));
|
||||
console.log('icons → public/favicon.svg, pwa-192x192.png, pwa-512x512.png');
|
||||
@@ -0,0 +1,17 @@
|
||||
import { build } from 'esbuild';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Bundle the server + its shared `@/lib/*` imports (model, sync types) into one
|
||||
// ESM file. npm deps (fastify, zod, …) stay external and install at runtime.
|
||||
await build({
|
||||
entryPoints: ['server/src/index.ts'],
|
||||
outfile: 'server/dist/index.js',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
packages: 'external',
|
||||
banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" },
|
||||
alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) },
|
||||
});
|
||||
console.log('server bundled → server/dist/index.js');
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "cup26-server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"fastify": "^5.8.5",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="grid place-items-center py-24 text-center">
|
||||
<div>
|
||||
<p className="font-display text-5xl font-extrabold text-accent">404</p>
|
||||
<p className="mt-2 text-muted">Off the pitch — that page doesn't exist.</p>
|
||||
<Link to="/" className="mt-4 inline-block rounded-md bg-elevated px-4 py-2 text-sm font-medium text-ink hover:bg-line">
|
||||
Back to Live
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Link, Outlet } from '@tanstack/react-router';
|
||||
import { Moon, Sun, Trophy } from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Live', exact: true },
|
||||
{ to: '/groups', label: 'Groups', exact: false },
|
||||
{ to: '/bracket', label: 'Bracket', exact: false },
|
||||
{ to: '/predict', label: 'Predict', exact: false },
|
||||
{ to: '/story', label: 'Story', exact: false },
|
||||
] as const;
|
||||
|
||||
function ThemeToggle() {
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const toggle = useUiStore((s) => s.toggleTheme);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
className="grid h-9 w-9 place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
|
||||
<Link to="/" className="flex items-center gap-2 font-display text-lg font-extrabold tracking-tight text-ink">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-b from-accent-soft to-accent text-accent-ink">
|
||||
<Trophy size={16} />
|
||||
</span>
|
||||
Cup<span className="text-accent">26</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
||||
{NAV.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
{...(item.exact ? { activeOptions: { exact: true } } : {})}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-elevated hover:text-ink',
|
||||
'[&.active]:bg-accent-glow [&.active]:text-accent',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-line py-6 text-center text-xs text-faint">
|
||||
<p>
|
||||
Cup26 · World Cup 2026 · data from openfootball, football-data.org & StatsBomb open data ·
|
||||
model-driven odds, not betting advice
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Tone = 'neutral' | 'accent' | 'live' | 'gold' | 'muted';
|
||||
|
||||
const tones: Record<Tone, string> = {
|
||||
neutral: 'bg-elevated text-ink-soft border-line',
|
||||
accent: 'bg-accent-glow text-accent border-accent-deep/40',
|
||||
live: 'bg-live/15 text-live border-live/40',
|
||||
gold: 'bg-gold/15 text-gold border-gold/40',
|
||||
muted: 'bg-transparent text-muted border-line',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
tone = 'neutral',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement> & { tone?: Tone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'subtle' | 'ghost' | 'danger';
|
||||
type Size = 'sm' | 'md' | 'icon';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: 'bg-gradient-to-b from-accent-soft to-accent text-accent-ink border border-accent-deep font-semibold shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] hover:brightness-105 active:brightness-95',
|
||||
secondary: 'bg-elevated text-ink hover:bg-line border border-line',
|
||||
subtle: 'bg-elevated text-ink border border-transparent hover:border-line',
|
||||
ghost: 'text-muted hover:text-ink hover:bg-elevated',
|
||||
danger: 'bg-danger text-white hover:brightness-110 font-medium border border-danger',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-8 px-3 text-sm rounded-md',
|
||||
md: 'h-10 px-4 text-sm rounded-md',
|
||||
icon: 'h-9 w-9 rounded-md grid place-items-center',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = 'secondary', size = 'md', type, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? 'button'}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 transition-[filter,background-color,border-color,box-shadow]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** A panel surface with a hairline border. The app's basic content container. */
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-xl border border-line bg-panel', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('px-4 py-3 border-b border-line', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-4', className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Consistent page title + subtitle + optional right-aligned actions. */
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-extrabold tracking-tight text-ink">{title}</h1>
|
||||
{subtitle ? <p className="mt-0.5 text-sm text-muted">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardBody } from './Card';
|
||||
|
||||
/** Temporary "built in a later phase" panel so routes render cleanly meanwhile. */
|
||||
export function Placeholder({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="grid place-items-center gap-2 py-16 text-center text-muted">
|
||||
{icon ? <div className="text-accent">{icon}</div> : null}
|
||||
<p className="max-w-md text-sm">{children}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GitMerge } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Placeholder } from '@/components/ui/Placeholder';
|
||||
|
||||
export function BracketPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Bracket" subtitle="Round of 32 through the Final." />
|
||||
<Placeholder icon={<GitMerge size={28} />}>
|
||||
The knockout bracket (32 → 16 → QF → SF → Final) arrives in Phase 1, with model
|
||||
win-probabilities overlaid in Phase 2.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Table } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Placeholder } from '@/components/ui/Placeholder';
|
||||
|
||||
export function GroupsPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Groups" subtitle="All 12 groups (A–L), 48 teams." />
|
||||
<Placeholder icon={<Table size={28} />}>
|
||||
Standings tables for the 12 groups arrive in Phase 1.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Radio } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Placeholder } from '@/components/ui/Placeholder';
|
||||
|
||||
export function LivePage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Live" subtitle="Today's World Cup 2026 matches, scores and what's on now." />
|
||||
<Placeholder icon={<Radio size={28} />}>
|
||||
Live match cards land in Phase 1 — fixtures from openfootball, scores polled from
|
||||
football-data.org, pushed over WebSocket.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Placeholder } from '@/components/ui/Placeholder';
|
||||
|
||||
export function PredictionsPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Predict" subtitle="Elo + Dixon-Coles + Monte Carlo — who lifts the trophy?" />
|
||||
<Placeholder icon={<TrendingUp size={28} />}>
|
||||
Match win/draw/loss bars, simulated championship odds and an odds-over-time chart arrive
|
||||
in Phase 2.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Placeholder } from '@/components/ui/Placeholder';
|
||||
|
||||
export function StoryPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Story" subtitle="A data-driven look back, powered by StatsBomb open data." />
|
||||
<Placeholder icon={<Sparkles size={28} />}>
|
||||
Shot maps, pass networks and xG races arrive in Phase 3.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merge conditional class names, resolving Tailwind conflicts. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { router } from './router';
|
||||
import { useUiStore, applyTheme } from './stores/uiStore';
|
||||
import './styles/globals.css';
|
||||
|
||||
// Apply persisted theme before first paint.
|
||||
applyTheme(useUiStore.getState().theme);
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) throw new Error('Root element #root not found');
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
|
||||
import { RootLayout } from './app/RootLayout';
|
||||
import { NotFound } from './app/NotFound';
|
||||
import { LivePage } from './features/live/LivePage';
|
||||
import { GroupsPage } from './features/groups/GroupsPage';
|
||||
import { BracketPage } from './features/bracket/BracketPage';
|
||||
import { PredictionsPage } from './features/predictions/PredictionsPage';
|
||||
import { StoryPage } from './features/story/StoryPage';
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: LivePage });
|
||||
const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/groups', component: GroupsPage });
|
||||
const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage });
|
||||
const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/predict', component: PredictionsPage });
|
||||
const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: StoryPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'cup26:theme';
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof localStorage === 'undefined') return 'dark';
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v === 'light' || v === 'dark' ? v : 'dark';
|
||||
}
|
||||
|
||||
/** Toggle the `.dark` class on <html> and persist the choice. */
|
||||
export function applyTheme(theme: Theme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.classList.toggle('light', theme === 'light');
|
||||
}
|
||||
|
||||
interface UiState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set, get) => ({
|
||||
theme: readTheme(),
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
try { localStorage.setItem(STORAGE_KEY, theme); } catch { /* private mode */ }
|
||||
set({ theme });
|
||||
},
|
||||
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
|
||||
}));
|
||||
@@ -0,0 +1,171 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Class-based dark mode: toggling `.dark` on <html> swaps the token values below. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ============================================================================
|
||||
CUP26 — broadcast palette. Floodlit pitch (dark) · clean telecast (light).
|
||||
Accent is pitch-green; gold is the highlight. Outcome colors (win/draw/loss)
|
||||
drive the model bars. Components consume --app-* tokens, never raw hex.
|
||||
============================================================================ */
|
||||
|
||||
/* ---- Light: clean telecast ---- */
|
||||
:root {
|
||||
--app-surface: oklch(0.975 0.005 160);
|
||||
--app-surface-2: oklch(0.945 0.008 160);
|
||||
--app-panel: oklch(1 0 0);
|
||||
--app-panel-2: oklch(0.975 0.006 160);
|
||||
--app-elevated: oklch(0.930 0.010 160);
|
||||
--app-line: oklch(0.890 0.012 160);
|
||||
--app-line-strong:oklch(0.800 0.018 158);
|
||||
--app-ink: oklch(0.235 0.022 158);
|
||||
--app-ink-soft: oklch(0.360 0.020 158);
|
||||
--app-muted: oklch(0.520 0.018 160);
|
||||
--app-faint: oklch(0.640 0.014 162);
|
||||
|
||||
--app-accent: oklch(0.585 0.150 150);
|
||||
--app-accent-deep: oklch(0.500 0.140 150);
|
||||
--app-accent-soft: oklch(0.700 0.160 150);
|
||||
--app-accent-ink: oklch(0.985 0.010 150);
|
||||
--app-accent-glow: oklch(0.585 0.150 150 / 0.16);
|
||||
|
||||
--app-gold: oklch(0.760 0.130 85);
|
||||
--app-pitch: oklch(0.560 0.110 150);
|
||||
|
||||
--app-win: oklch(0.610 0.140 150);
|
||||
--app-draw: oklch(0.730 0.120 85);
|
||||
--app-loss: oklch(0.580 0.160 25);
|
||||
|
||||
--app-danger: oklch(0.560 0.170 28);
|
||||
--app-danger-glow: oklch(0.560 0.170 28 / 0.16);
|
||||
--app-success: oklch(0.600 0.130 150);
|
||||
--app-warning: oklch(0.720 0.130 80);
|
||||
--app-info: oklch(0.560 0.120 250);
|
||||
|
||||
--app-live: oklch(0.600 0.200 25);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Dark: floodlit pitch at night ---- */
|
||||
.dark {
|
||||
--app-surface: oklch(0.165 0.022 158);
|
||||
--app-surface-2: oklch(0.135 0.020 158);
|
||||
--app-panel: oklch(0.205 0.024 159);
|
||||
--app-panel-2: oklch(0.235 0.026 159);
|
||||
--app-elevated: oklch(0.275 0.028 160);
|
||||
--app-line: oklch(0.320 0.026 160);
|
||||
--app-line-strong:oklch(0.420 0.030 158);
|
||||
--app-ink: oklch(0.955 0.012 150);
|
||||
--app-ink-soft: oklch(0.860 0.014 152);
|
||||
--app-muted: oklch(0.680 0.020 156);
|
||||
--app-faint: oklch(0.540 0.022 158);
|
||||
|
||||
--app-accent: oklch(0.800 0.175 148);
|
||||
--app-accent-deep: oklch(0.700 0.160 148);
|
||||
--app-accent-soft: oklch(0.870 0.165 146);
|
||||
--app-accent-ink: oklch(0.165 0.030 158);
|
||||
--app-accent-glow: oklch(0.800 0.175 148 / 0.20);
|
||||
|
||||
--app-gold: oklch(0.840 0.140 88);
|
||||
--app-pitch: oklch(0.620 0.130 150);
|
||||
|
||||
--app-win: oklch(0.800 0.165 148);
|
||||
--app-draw: oklch(0.820 0.130 88);
|
||||
--app-loss: oklch(0.680 0.180 25);
|
||||
|
||||
--app-danger: oklch(0.680 0.185 30);
|
||||
--app-danger-glow: oklch(0.680 0.185 30 / 0.22);
|
||||
--app-success: oklch(0.790 0.150 150);
|
||||
--app-warning: oklch(0.820 0.140 82);
|
||||
--app-info: oklch(0.720 0.130 252);
|
||||
|
||||
--app-live: oklch(0.700 0.220 25);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Expose tokens as Tailwind utilities: bg-surface, text-faint, border-line, text-win… */
|
||||
@theme inline {
|
||||
--color-surface: var(--app-surface);
|
||||
--color-surface-2: var(--app-surface-2);
|
||||
--color-panel: var(--app-panel);
|
||||
--color-panel-2: var(--app-panel-2);
|
||||
--color-elevated: var(--app-elevated);
|
||||
--color-line: var(--app-line);
|
||||
--color-line-strong: var(--app-line-strong);
|
||||
--color-ink: var(--app-ink);
|
||||
--color-ink-soft: var(--app-ink-soft);
|
||||
--color-muted: var(--app-muted);
|
||||
--color-faint: var(--app-faint);
|
||||
--color-accent: var(--app-accent);
|
||||
--color-accent-deep: var(--app-accent-deep);
|
||||
--color-accent-soft: var(--app-accent-soft);
|
||||
--color-accent-ink: var(--app-accent-ink);
|
||||
--color-accent-glow: var(--app-accent-glow);
|
||||
--color-gold: var(--app-gold);
|
||||
--color-pitch: var(--app-pitch);
|
||||
--color-win: var(--app-win);
|
||||
--color-draw: var(--app-draw);
|
||||
--color-loss: var(--app-loss);
|
||||
--color-danger: var(--app-danger);
|
||||
--color-danger-glow: var(--app-danger-glow);
|
||||
--color-success: var(--app-success);
|
||||
--color-warning: var(--app-warning);
|
||||
--color-info: var(--app-info);
|
||||
--color-live: var(--app-live);
|
||||
|
||||
--font-display: 'Hanken Grotesk', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'Spline Sans Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--app-surface);
|
||||
color: var(--app-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Small-caps section label */
|
||||
.smallcaps {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
/* Tabular figures for scores/odds so digits don't jitter as they tick. */
|
||||
.tnum { font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
|
||||
|
||||
/* Live pulse dot */
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.45; transform: scale(0.8); }
|
||||
}
|
||||
.live-dot { animation: live-pulse 1.4s ease-in-out infinite; }
|
||||
|
||||
/* Reduced-motion friendliness */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin themed scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--app-line) transparent;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vitest.setup.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["server/src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
// Strict CSP injected at build time only (dev needs inline scripts for react-refresh).
|
||||
// All external football APIs are called from the server, so the browser only ever
|
||||
// talks to its own origin + WebSocket — connect-src can stay locked to 'self'.
|
||||
const PROD_CSP =
|
||||
"default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self'; connect-src 'self' ws: wss:; " +
|
||||
"font-src 'self' data:; worker-src 'self' blob:; " +
|
||||
"manifest-src 'self'; object-src 'none'; base-uri 'self'";
|
||||
|
||||
function cspPlugin() {
|
||||
return {
|
||||
name: 'inject-csp',
|
||||
transformIndexHtml(html: string) {
|
||||
const tag = `<meta http-equiv="Content-Security-Policy" content="${PROD_CSP}" />`;
|
||||
return html.replace('<!--CSP-PLACEHOLDER-->', tag);
|
||||
},
|
||||
apply: 'build' as const,
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
cspPlugin(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg'],
|
||||
manifest: {
|
||||
name: 'Cup26 — World Cup 2026 Dashboard',
|
||||
short_name: 'Cup26',
|
||||
description: 'Live World Cup 2026 scores, model-driven odds, and data-story visualizations.',
|
||||
theme_color: '#0a1410',
|
||||
background_color: '#0a1410',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'],
|
||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
// Live data is fetched fresh from /api; never serve it from the precache.
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev: proxy API + WS to the Fastify server so the browser only talks to :5173.
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8787', changeOrigin: true },
|
||||
'/ws': { target: 'ws://localhost:8787', ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
css: false,
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user