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