init
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.local
|
||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# football-data.org API-Token (kostenlos: https://www.football-data.org/client/register)
|
||||
# Ohne Token liefert die API nur eingeschränkte Daten.
|
||||
FOOTBALL_DATA_TOKEN=dein_token_hier
|
||||
|
||||
# Polymarket-Slug des WM-Events (Standard: world-cup-2026).
|
||||
# Den genauen Slug findest du in der Polymarket-URL nach /event/.
|
||||
POLYMARKET_WC_SLUG=world-cup-2026
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
7
Caddyfile.snippet
Normal file
7
Caddyfile.snippet
Normal file
@@ -0,0 +1,7 @@
|
||||
# In deinen bestehenden Caddyfile einfügen (eigene Domain/Subdomain wählen).
|
||||
# Caddy und der wm2026-Container müssen im selben Docker-Netz "caddy_net" sein.
|
||||
|
||||
wm.deine-domain.de {
|
||||
encode zstd gzip
|
||||
reverse_proxy wm2026:3000
|
||||
}
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# --- Stufe 1: Dependencies ---
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# --- Stufe 2: Build ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# --- Stufe 3: Runtime (standalone) ---
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Standalone-Output enthält nur das Nötigste
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||
CMD ["node", "server.js"]
|
||||
120
README.md
Normal file
120
README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# WM 2026 Board
|
||||
|
||||
Live-Gruppentabellen, Drittplatzierten-Wertung und vollständiger K.o.-Baum der
|
||||
FIFA WM 2026 — mit korrekter **Annex-C-Zuordnung** der acht besten Gruppendritten
|
||||
und optionalen **Polymarket-Wahrscheinlichkeiten** pro Spiel.
|
||||
|
||||
Gebaut als Next.js 15 (App Router) mit serverseitigem Feed-Proxy und Caching,
|
||||
Docker-Standalone-Image, ausgelegt für Caddy als Reverse Proxy.
|
||||
|
||||
## Warum ein Backend-Proxy?
|
||||
|
||||
Das Frontend kann die Feeds **nicht direkt** aufrufen:
|
||||
|
||||
- **football-data.org** erlaubt im Free-Tier nur 10 Anfragen/Minute und verlangt
|
||||
einen `X-Auth-Token` (im Browser nicht sicher unterzubringen).
|
||||
- **Polymarket Gamma** liefert `outcomePrices` als JSON-String und hat Rate Limits.
|
||||
- Beide setzen kein CORS für beliebige Browser-Origins.
|
||||
|
||||
Deshalb holt die Route `app/api/matches` beide Feeds **serverseitig**, cached sie
|
||||
in-memory (Spiele 60 s, Odds 120 s) und liefert ein fertiges JSON. Das Frontend
|
||||
pollt nur diese eine eigene Route (alle 30 s).
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Browser ──poll 30s──> /api/matches (Next.js, gecacht)
|
||||
├── football-data.org/v4/competitions/WC/matches
|
||||
└── gamma-api.polymarket.com/events?slug=...
|
||||
│
|
||||
├── computeGroupTables() (FIFA-Tiebreaker)
|
||||
├── computeThirdPlaceTable() (beste 8 Dritte)
|
||||
└── resolveAnnexC() (495 Szenarien, lib/annexc-data.ts)
|
||||
```
|
||||
|
||||
Die Annex-C-Tabelle (`lib/annexc-data.ts`) enthält alle 495 möglichen
|
||||
Kombinationen aus den FIFA-Wettbewerbsregeln. Sobald genau 8 Gruppendritte
|
||||
feststehen, ordnet `resolveAnnexC()` jedem der acht betroffenen Gruppensieger
|
||||
(A, B, D, E, G, I, K, L) seinen Drittplatzierten zu, und der Bracket „verbindet"
|
||||
sich automatisch.
|
||||
|
||||
## Schnellstart (lokal)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # FOOTBALL_DATA_TOKEN eintragen
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
Token kostenlos registrieren: https://www.football-data.org/client/register
|
||||
|
||||
## Deployment auf dem VPS (Docker + Caddy)
|
||||
|
||||
Das Projekt geht davon aus, dass Caddy bereits als Reverse Proxy in einem
|
||||
externen Docker-Netz läuft. Heißt dein Netz anders, in `docker-compose.yml`
|
||||
und im Caddy-Snippet anpassen.
|
||||
|
||||
```bash
|
||||
# 1. Netz prüfen/anlegen (falls noch nicht vorhanden)
|
||||
docker network ls | grep caddy_net || docker network create caddy_net
|
||||
|
||||
# 2. .env anlegen
|
||||
cp .env.example .env && nano .env
|
||||
|
||||
# 3. Bauen und starten
|
||||
docker compose up -d --build
|
||||
|
||||
# 4. Caddy-Route einbinden
|
||||
# Inhalt von Caddyfile.snippet in deinen Caddyfile übernehmen,
|
||||
# Domain anpassen, dann Caddy neu laden:
|
||||
docker exec -w /etc/caddy caddy caddy reload
|
||||
```
|
||||
|
||||
Danach ist die Seite unter `https://wm.deine-domain.de` erreichbar. Caddy
|
||||
besorgt TLS automatisch.
|
||||
|
||||
## Konfiguration (.env)
|
||||
|
||||
| Variable | Pflicht | Beschreibung |
|
||||
|-----------------------|---------|--------------|
|
||||
| `FOOTBALL_DATA_TOKEN` | ja | Token von football-data.org. Ohne ihn liefert die API nur die Wettbewerbsliste. |
|
||||
| `POLYMARKET_WC_SLUG` | nein | Slug des WM-Events auf Polymarket (Standard `world-cup-2026`). Den exakten Slug aus der URL nach `/event/` ablesen. |
|
||||
|
||||
## Feeds verifizieren
|
||||
|
||||
Beide Upstream-Strukturen vor dem Turnierstart kurz gegenchecken (Felder können
|
||||
sich ändern):
|
||||
|
||||
```bash
|
||||
# football-data: Gruppenfeld "GROUP_X", score.fullTime, stage
|
||||
curl -H "X-Auth-Token: $FOOTBALL_DATA_TOKEN" \
|
||||
"https://api.football-data.org/v4/competitions/WC/matches" | jq '.matches[0]'
|
||||
|
||||
# Polymarket: markets[].outcomes / outcomePrices (JSON-Strings!)
|
||||
curl "https://gamma-api.polymarket.com/events?slug=world-cup-2026" | jq '.[0].markets[0]'
|
||||
```
|
||||
|
||||
Stimmen Feldnamen nicht, nur die Adapter in `lib/feeds.ts` anpassen — der Rest
|
||||
(Tabellen, Annex C, Bracket) ist davon entkoppelt.
|
||||
|
||||
## Was anpassbar ist
|
||||
|
||||
- **Anderer Ergebnis-Feed** (API-Football, TheStatsAPI): nur `fetchMatchesAndTeams`
|
||||
in `lib/feeds.ts` neu schreiben, sodass es `Match[]` + `Team[]` liefert.
|
||||
- **Odds-Matching**: `attachOdds` verknüpft per Teamname im Markttitel. Bei
|
||||
abweichenden Namen (z. B. „USA" vs. „United States") dort eine Alias-Tabelle ergänzen.
|
||||
- **Poll-Intervall**: in `app/page.tsx` (`setInterval`, Standard 30 s) und den
|
||||
Cache-TTLs in `lib/feeds.ts`.
|
||||
|
||||
## Korrektheit der Annex-C-Logik
|
||||
|
||||
`lib/annexc-data.ts` wurde gegen die veröffentlichte FIFA-/Wikipedia-Tabelle
|
||||
geprüft. Beispiel Szenario 415 (Dritte aus A,B,C,E,F,H,I,J) ergibt die Zuordnung
|
||||
`H J B C A F E I` für die Gruppensieger A,B,D,E,G,I,K,L — exakt wie in den
|
||||
offiziellen Regeln.
|
||||
|
||||
## Lizenz / Hinweise
|
||||
|
||||
Kein offizielles FIFA-Produkt. football-data.org ist für nicht-kommerzielle
|
||||
Nutzung kostenlos; bei kommerzieller Nutzung dort anfragen. Polymarket-Zugang
|
||||
und -Daten unterliegen den jeweiligen lokalen Bestimmungen.
|
||||
44
app/api/matches/route.ts
Normal file
44
app/api/matches/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMatchesAndTeams, fetchOdds, attachOdds } from "@/lib/feeds";
|
||||
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
|
||||
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
|
||||
|
||||
// Diese Route wird vom Frontend gepollt. Sie ist der einzige Ort, der die
|
||||
// Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { matches: rawMatches, teams } = await fetchMatchesAndTeams();
|
||||
|
||||
// Odds sind optional: fällt der Polymarket-Call aus, liefern wir trotzdem.
|
||||
let matches = rawMatches;
|
||||
try {
|
||||
const odds = await fetchOdds();
|
||||
matches = attachOdds(rawMatches, teams, odds);
|
||||
} catch {
|
||||
// still ohne Wahrscheinlichkeiten
|
||||
}
|
||||
|
||||
const groupTables = computeGroupTables(teams, matches);
|
||||
const thirdTable = computeThirdPlaceTable(groupTables);
|
||||
const qGroups = qualifiedThirdGroups(thirdTable);
|
||||
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
updatedAt: new Date().toISOString(),
|
||||
teams,
|
||||
matches,
|
||||
groupTables,
|
||||
thirdTable,
|
||||
annexAssignment: annex,
|
||||
annexResolved: annex != null,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unbekannter Fehler";
|
||||
return NextResponse.json(
|
||||
{ error: "Feed nicht erreichbar", detail: message },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
113
app/components/Bracket.tsx
Normal file
113
app/components/Bracket.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
|
||||
import { ThirdAssignment } from "@/lib/bracket";
|
||||
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
|
||||
|
||||
function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
|
||||
return (
|
||||
<div className={`side ${s.isWinner ? "win" : ""}`}>
|
||||
<span className="nm">
|
||||
{s.teamId ? (
|
||||
<>
|
||||
<span>{s.label}</span>
|
||||
{s.code && <span className="c">{s.code}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="lbl">{s.label}</span>
|
||||
)}
|
||||
{prob != null && prob > 0 && (
|
||||
<span className="prob">{Math.round(prob * 100)}%</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sc">{s.score != null ? s.score : "–"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tie({ tie, isFinal }: { tie: ResolvedTie; isFinal?: boolean }) {
|
||||
return (
|
||||
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
|
||||
<span className="tie-num">#{tie.matchNumber}</span>
|
||||
<Side s={tie.home} prob={tie.prob?.home} />
|
||||
<Side s={tie.away} prob={tie.prob?.away} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Bracket({
|
||||
matches, teams, tables, thirds, assignment, annexResolved,
|
||||
}: {
|
||||
matches: Match[]; teams: Team[]; tables: GroupTable[];
|
||||
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
|
||||
}) {
|
||||
const { r32, later } = resolveBracket(matches, teams, tables, thirds, assignment);
|
||||
|
||||
const pick = (nums: number[]) => nums.map((n) => later[n]).filter(Boolean);
|
||||
const r16 = pick([89, 90, 91, 92, 93, 94, 95, 96]);
|
||||
const qf = pick([97, 98, 99, 100]);
|
||||
const sf = pick([101, 102]);
|
||||
const fin = later[104];
|
||||
const third = later[103];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bracket-banner">
|
||||
<span className="k">Annex-C-Zuordnung der Drittplatzierten:</span>
|
||||
<span className="v">
|
||||
{annexResolved
|
||||
? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt"
|
||||
: "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bracket-scroll">
|
||||
<div className="bracket">
|
||||
<div className="round">
|
||||
<div className="round-label">Letzte 32</div>
|
||||
<div className="round-matches">
|
||||
{r32.map((t) => <Tie key={t.matchNumber} tie={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="round">
|
||||
<div className="round-label">Achtelfinale</div>
|
||||
<div className="round-matches">
|
||||
{r16.map((t) => <Tie key={t.matchNumber} tie={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="round">
|
||||
<div className="round-label">Viertelfinale</div>
|
||||
<div className="round-matches">
|
||||
{qf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="round">
|
||||
<div className="round-label">Halbfinale</div>
|
||||
<div className="round-matches">
|
||||
{sf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="round">
|
||||
<div className="round-label">Finale</div>
|
||||
<div className="round-matches">
|
||||
{fin && <Tie tie={fin} isFinal />}
|
||||
{third && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
|
||||
<Tie tie={third} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="legend">
|
||||
<span><i style={{ background: "var(--turf)" }} />Sieger / weiter</span>
|
||||
<span><i style={{ background: "var(--gold)" }} />Finale</span>
|
||||
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
|
||||
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/components/Groups.tsx
Normal file
66
app/components/Groups.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { GroupTable, Match, Team } from "@/lib/types";
|
||||
|
||||
function teamById(teams: Team[], id: string) {
|
||||
return teams.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
function liveMatchFor(group: string, matches: Match[]) {
|
||||
return matches.find(
|
||||
(m) => m.group === group && (m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED"),
|
||||
);
|
||||
}
|
||||
|
||||
export default function Groups({
|
||||
tables, teams, matches,
|
||||
}: { tables: GroupTable[]; teams: Team[]; matches: Match[] }) {
|
||||
return (
|
||||
<div className="group-grid">
|
||||
{tables.map((t) => {
|
||||
const live = liveMatchFor(t.group, matches);
|
||||
return (
|
||||
<div className="group-card" key={t.group}>
|
||||
<div className="group-head">
|
||||
<span className="group-name">Gruppe {t.group}</span>
|
||||
<span className="group-tag">
|
||||
{live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`}
|
||||
</span>
|
||||
</div>
|
||||
<table className="standings">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="team">Team</th>
|
||||
<th>Sp</th><th>S</th><th>U</th><th>N</th>
|
||||
<th>Tore</th><th>±</th><th>Pkt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.rows.map((r) => {
|
||||
const team = teamById(teams, r.teamId);
|
||||
const cls = r.rank <= 2 ? `q${r.rank}` : r.rank === 3 ? "q3" : "";
|
||||
return (
|
||||
<tr key={r.teamId}>
|
||||
<td className="team">
|
||||
<span className={`rankdot ${cls}`}>{r.rank}</span>
|
||||
<span className="team-name">{team?.name ?? r.teamId}</span>
|
||||
{team?.code && <span className="team-code">{team.code}</span>}
|
||||
</td>
|
||||
<td>{r.played}</td>
|
||||
<td>{r.won}</td>
|
||||
<td>{r.drawn}</td>
|
||||
<td>{r.lost}</td>
|
||||
<td>{r.goalsFor}:{r.goalsAgainst}</td>
|
||||
<td>{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}</td>
|
||||
<td className="pts">{r.points}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
app/components/ThirdPlace.tsx
Normal file
52
app/components/ThirdPlace.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Team, ThirdPlaceRow } from "@/lib/types";
|
||||
|
||||
export default function ThirdPlace({
|
||||
rows, teams,
|
||||
}: { rows: ThirdPlaceRow[]; teams: Team[] }) {
|
||||
const name = (id: string) => teams.find((t) => t.id === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
<div className="third-wrap">
|
||||
<p className="notice" style={{ marginBottom: 16 }}>
|
||||
Acht der zwölf Gruppendritten erreichen die Runde der letzten 32. Gewertet wird
|
||||
gruppenübergreifend nach Punkten, Tordifferenz und Toren — der Direktvergleich
|
||||
entfällt, weil diese Teams nie gegeneinander gespielt haben. Die Trennlinie markiert
|
||||
den Schnitt zwischen Platz 8 und 9.
|
||||
</p>
|
||||
<table className="third-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Gruppe</th><th>Team</th>
|
||||
<th>Sp</th><th>Pkt</th><th>±</th><th>Tore</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const isCut = i === 8; // erste nicht-qualifizierte Zeile
|
||||
return (
|
||||
<tr
|
||||
key={r.teamId}
|
||||
className={`third-row ${r.qualifies ? "qual" : ""} ${isCut ? "cut" : ""}`}
|
||||
>
|
||||
<td>{r.overallRank}</td>
|
||||
<td>{r.group}</td>
|
||||
<td style={{ fontWeight: 600 }}>{name(r.teamId)}</td>
|
||||
<td>{r.played}</td>
|
||||
<td className="pts">{r.points}</td>
|
||||
<td>{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}</td>
|
||||
<td>{r.goalsFor}:{r.goalsAgainst}</td>
|
||||
<td>
|
||||
<span className={`qual-badge ${r.qualifies ? "yes" : "no"}`}>
|
||||
{r.qualifies ? "weiter" : "raus"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
app/globals.css
Normal file
247
app/globals.css
Normal file
@@ -0,0 +1,247 @@
|
||||
:root {
|
||||
/* Palette: Stadion bei Nacht über drei Zeitzonen.
|
||||
Tiefes Mitternachtsblau, kühles Flutlicht-Weiß, warmer Rasen-Akzent,
|
||||
ein Signal-Magenta für "live". Bewusst nicht die üblichen AI-Defaults. */
|
||||
--bg: #0b1020;
|
||||
--bg-raised: #121a32;
|
||||
--bg-card: #16203c;
|
||||
--line: #243152;
|
||||
--line-soft: #1b2540;
|
||||
--ink: #eef2fb;
|
||||
--ink-dim: #9aa6c4;
|
||||
--ink-faint: #5f6d92;
|
||||
--turf: #4ade80; /* Rasen / qualifiziert */
|
||||
--turf-deep: #1f7a45;
|
||||
--floodlight: #cfe0ff;
|
||||
--live: #ff3d7f; /* Live-Signal */
|
||||
--gold: #ffd24a; /* Sieger / Finale */
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--font-display: "Archivo Expanded", "Archivo", system-ui, sans-serif;
|
||||
--font-body: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "Geist Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 70% -10%, #16224a 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at 10% 0%, #102046 0%, transparent 50%),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 20px; }
|
||||
|
||||
/* ---------- Header / Hero ---------- */
|
||||
.masthead {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(18,26,50,0.7), transparent);
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.masthead-inner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 0; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.brand { display: flex; align-items: baseline; gap: 12px; }
|
||||
.brand-mark {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800; letter-spacing: -0.02em;
|
||||
font-size: clamp(20px, 3vw, 28px);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.brand-mark .accent { color: var(--turf); }
|
||||
.brand-sub {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--ink-faint); letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--ink-dim);
|
||||
border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 6px 12px; background: var(--bg-card);
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); }
|
||||
.dot.on { background: var(--turf); box-shadow: 0 0 0 3px rgba(74,222,128,0.18); }
|
||||
.dot.live { background: var(--live); box-shadow: 0 0 0 3px rgba(255,61,127,0.2); animation: pulse 1.6s infinite; }
|
||||
@keyframes pulse { 50% { box-shadow: 0 0 0 6px rgba(255,61,127,0); } }
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 0 0 4px; }
|
||||
.tab {
|
||||
font-family: var(--font-display); font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.01em;
|
||||
font-size: 14px; color: var(--ink-faint);
|
||||
padding: 16px 18px; cursor: pointer; border: none; background: none;
|
||||
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
|
||||
}
|
||||
.tab:hover { color: var(--ink-dim); }
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--turf); }
|
||||
|
||||
.section { padding: 28px 0 64px; }
|
||||
|
||||
/* ---------- Gruppen ---------- */
|
||||
.group-grid {
|
||||
display: grid; gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||
}
|
||||
.group-card {
|
||||
background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.group-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.group-name {
|
||||
font-family: var(--font-display); font-weight: 800; font-size: 16px;
|
||||
text-transform: uppercase; letter-spacing: 0.02em;
|
||||
}
|
||||
.group-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
|
||||
|
||||
table.standings { width: 100%; border-collapse: collapse; }
|
||||
.standings th {
|
||||
font-family: var(--font-mono); font-size: 10px; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--ink-faint); text-align: right; padding: 8px 6px;
|
||||
}
|
||||
.standings th.team { text-align: left; padding-left: 14px; }
|
||||
.standings td {
|
||||
padding: 9px 6px; text-align: right; font-variant-numeric: tabular-nums;
|
||||
border-top: 1px solid var(--line-soft); font-size: 13px;
|
||||
}
|
||||
.standings td.team {
|
||||
text-align: left; padding-left: 14px; display: flex; align-items: center; gap: 9px;
|
||||
}
|
||||
.rankdot {
|
||||
width: 18px; height: 18px; border-radius: 5px; flex: none;
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--font-mono); font-size: 10px; font-weight: 600;
|
||||
color: var(--bg); background: var(--ink-faint);
|
||||
}
|
||||
.rankdot.q1, .rankdot.q2 { background: var(--turf); }
|
||||
.rankdot.q3 { background: var(--gold); color: #2a2200; }
|
||||
.rankdot.q3.out { background: var(--ink-faint); color: var(--bg); }
|
||||
.team-name { font-weight: 600; }
|
||||
.team-code { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
|
||||
.pts { font-weight: 700; color: var(--floodlight); }
|
||||
|
||||
/* Live-Zeile */
|
||||
.score-live { color: var(--live); font-weight: 700; }
|
||||
|
||||
/* ---------- Drittplatzierte ---------- */
|
||||
.third-wrap { margin-top: 8px; }
|
||||
.third-table { width: 100%; border-collapse: collapse; background: var(--bg-card);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||||
.third-table th {
|
||||
font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--ink-faint); padding: 11px 12px; text-align: right;
|
||||
background: var(--bg-raised); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.third-table th:first-child, .third-table td:first-child { text-align: left; }
|
||||
.third-table td {
|
||||
padding: 10px 12px; text-align: right; font-variant-numeric: tabular-nums;
|
||||
border-top: 1px solid var(--line-soft); font-size: 13px;
|
||||
}
|
||||
.third-row.qual { background: linear-gradient(90deg, rgba(74,222,128,0.07), transparent); }
|
||||
.third-row.cut td { border-top: 2px solid var(--turf-deep); }
|
||||
.qual-badge {
|
||||
font-family: var(--font-mono); font-size: 10px; padding: 2px 7px; border-radius: 999px;
|
||||
}
|
||||
.qual-badge.yes { background: rgba(74,222,128,0.16); color: var(--turf); }
|
||||
.qual-badge.no { background: rgba(95,109,146,0.16); color: var(--ink-faint); }
|
||||
|
||||
/* ---------- Bracket ---------- */
|
||||
.bracket-banner {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px;
|
||||
}
|
||||
.bracket-banner .k {
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
|
||||
}
|
||||
.bracket-banner .v { font-family: var(--font-mono); font-size: 12px; color: var(--turf); }
|
||||
.bracket-scroll { overflow-x: auto; padding-bottom: 16px; }
|
||||
.bracket {
|
||||
display: flex; gap: 26px; min-width: max-content; align-items: stretch;
|
||||
}
|
||||
.round { display: flex; flex-direction: column; min-width: 220px; }
|
||||
.round-label {
|
||||
font-family: var(--font-display); font-weight: 800; text-transform: uppercase;
|
||||
font-size: 12px; letter-spacing: 0.06em; color: var(--ink-faint);
|
||||
margin-bottom: 10px; padding-left: 2px;
|
||||
}
|
||||
.round-matches { display: flex; flex-direction: column; justify-content: space-around; flex: 1; gap: 12px; }
|
||||
|
||||
.tie {
|
||||
background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm); overflow: hidden; position: relative;
|
||||
}
|
||||
.tie.final-tie { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(255,210,74,0.2); }
|
||||
.tie-num {
|
||||
position: absolute; top: -7px; left: 8px;
|
||||
font-family: var(--font-mono); font-size: 9px; color: var(--ink-faint);
|
||||
background: var(--bg); padding: 0 5px;
|
||||
}
|
||||
.side {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 10px; font-size: 13px;
|
||||
}
|
||||
.side + .side { border-top: 1px solid var(--line-soft); }
|
||||
.side .nm { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.side .nm .c { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
|
||||
.side .lbl { color: var(--ink-dim); font-style: italic; }
|
||||
.side .sc { font-family: var(--font-mono); font-weight: 700; color: var(--floodlight); }
|
||||
.side.win .nm { color: var(--turf); font-weight: 700; }
|
||||
.side.win .sc { color: var(--turf); }
|
||||
.side .prob {
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.legend { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 16px;
|
||||
font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
||||
|
||||
/* ---------- Zustände ---------- */
|
||||
.notice {
|
||||
background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 18px 20px; color: var(--ink-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
.notice.err { border-color: #5a2230; color: #ffb0c0; }
|
||||
.skel { background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); height: 220px; animation: shimmer 1.4s infinite; }
|
||||
@keyframes shimmer { 50% { opacity: .55; } }
|
||||
|
||||
.foot {
|
||||
border-top: 1px solid var(--line); padding: 24px 0 48px;
|
||||
color: var(--ink-faint); font-size: 12px; font-family: var(--font-mono);
|
||||
}
|
||||
.foot a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tab { padding: 14px 12px; font-size: 12px; }
|
||||
.group-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
24
app/layout.tsx
Normal file
24
app/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WM 26 — Gruppen & K.o.-Baum",
|
||||
description:
|
||||
"Live-Gruppentabellen, Drittplatzierten-Wertung und der vollständige K.o.-Baum der FIFA WM 2026 mit Annex-C-Zuordnung und Polymarket-Wahrscheinlichkeiten.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
122
app/page.tsx
Normal file
122
app/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
|
||||
import { ThirdAssignment } from "@/lib/bracket";
|
||||
import Groups from "./components/Groups";
|
||||
import ThirdPlace from "./components/ThirdPlace";
|
||||
import Bracket from "./components/Bracket";
|
||||
|
||||
interface ApiData {
|
||||
updatedAt: string;
|
||||
teams: Team[];
|
||||
matches: Match[];
|
||||
groupTables: GroupTable[];
|
||||
thirdTable: ThirdPlaceRow[];
|
||||
annexAssignment: ThirdAssignment | null;
|
||||
annexResolved: boolean;
|
||||
}
|
||||
|
||||
type Tab = "groups" | "thirds" | "bracket";
|
||||
|
||||
export default function Home() {
|
||||
const [data, setData] = useState<ApiData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("groups");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/matches", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `Fehler ${res.status}`);
|
||||
}
|
||||
setData(await res.json());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Verbindung fehlgeschlagen");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000); // alle 30 s aktualisieren
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const anyLive = data?.matches.some(
|
||||
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="masthead">
|
||||
<div className="wrap masthead-inner">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">WM <span className="accent">26</span></span>
|
||||
<span className="brand-sub">USA · Kanada · Mexiko</span>
|
||||
</div>
|
||||
<span className="status-pill">
|
||||
<span className={`dot ${anyLive ? "live" : data ? "on" : ""}`} />
|
||||
{error
|
||||
? "Feed offline"
|
||||
: data
|
||||
? anyLive ? "Live" : `Aktualisiert ${new Date(data.updatedAt).toLocaleTimeString("de-DE")}`
|
||||
: "Lade Daten…"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="wrap">
|
||||
<nav className="tabs">
|
||||
<button className={`tab ${tab === "groups" ? "active" : ""}`} onClick={() => setTab("groups")}>
|
||||
Gruppen
|
||||
</button>
|
||||
<button className={`tab ${tab === "thirds" ? "active" : ""}`} onClick={() => setTab("thirds")}>
|
||||
Drittplatzierte
|
||||
</button>
|
||||
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
|
||||
K.o.-Baum
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section className="section">
|
||||
{error && (
|
||||
<div className="notice err">
|
||||
Die Live-Feeds sind gerade nicht erreichbar: {error}.
|
||||
Prüfe den <code>FOOTBALL_DATA_TOKEN</code> und die Netzwerkfreigabe des Servers.
|
||||
Die Seite versucht es automatisch erneut.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data && !error && (
|
||||
<div className="group-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => <div className="skel" key={i} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && tab === "groups" && (
|
||||
<Groups tables={data.groupTables} teams={data.teams} matches={data.matches} />
|
||||
)}
|
||||
{data && tab === "thirds" && (
|
||||
<ThirdPlace rows={data.thirdTable} teams={data.teams} />
|
||||
)}
|
||||
{data && tab === "bracket" && (
|
||||
<Bracket
|
||||
matches={data.matches} teams={data.teams} tables={data.groupTables}
|
||||
thirds={data.thirdTable} assignment={data.annexAssignment}
|
||||
annexResolved={data.annexResolved}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="foot">
|
||||
<div className="wrap">
|
||||
Daten: football-data.org (Spiele & Tabellen) · Polymarket Gamma API (Wahrscheinlichkeiten) ·
|
||||
Annex-C-Zuordnung nach den FIFA-Wettbewerbsregeln WM 2026. Kein offizielles FIFA-Produkt.
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
docker-compose.local.yml
Normal file
15
docker-compose.local.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
wm2026:
|
||||
build: .
|
||||
image: wm2026-board:latest
|
||||
container_name: wm2026-local
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "3001:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/matches"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
# WM 2026 Board — passt in dein bestehendes Caddy-Setup auf dem Contabo-VPS.
|
||||
# Annahme: Caddy läuft als Reverse-Proxy in einem gemeinsamen externen Netz "caddy_net".
|
||||
# Falls dein Netz anders heißt, unten anpassen.
|
||||
|
||||
services:
|
||||
wm2026:
|
||||
build: .
|
||||
image: wm2026-board:latest
|
||||
container_name: wm2026
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
- caddy_net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/matches"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
500
lib/annexc-data.ts
Normal file
500
lib/annexc-data.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
// Annex C der FIFA WM 2026 Wettbewerbsregeln: 495 Szenarien.
|
||||
// Schluessel = 8 Gruppen (alphabetisch) der besten Drittplatzierten.
|
||||
// Wert = Zuordnung in Spaltenreihenfolge der Gruppensieger [1A,1B,1D,1E,1G,1I,1K,1L].
|
||||
export const ANNEX_C: Record<string,string> = {
|
||||
"EFGHIJKL": "EJIFHGLK",
|
||||
"DFGHIJKL": "HGIDJFLK",
|
||||
"DEGHIJKL": "EJIDHGLK",
|
||||
"DEFHIJKL": "EJIDHFLK",
|
||||
"DEFGIJKL": "EGIDJFLK",
|
||||
"DEFGHJKL": "EGJDHFLK",
|
||||
"DEFGHIKL": "EGIDHFLK",
|
||||
"DEFGHIJL": "EGJDHFLI",
|
||||
"DEFGHIJK": "EGJDHFIK",
|
||||
"CFGHIJKL": "HGICJFLK",
|
||||
"CEGHIJKL": "EJICHGLK",
|
||||
"CEFHIJKL": "EJICHFLK",
|
||||
"CEFGIJKL": "EGICJFLK",
|
||||
"CEFGHJKL": "EGJCHFLK",
|
||||
"CEFGHIKL": "EGICHFLK",
|
||||
"CEFGHIJL": "EGJCHFLI",
|
||||
"CEFGHIJK": "EGJCHFIK",
|
||||
"CDGHIJKL": "HGICJDLK",
|
||||
"CDFHIJKL": "CJIDHFLK",
|
||||
"CDFGIJKL": "CGIDJFLK",
|
||||
"CDFGHJKL": "CGJDHFLK",
|
||||
"CDFGHIKL": "CGIDHFLK",
|
||||
"CDFGHIJL": "CGJDHFLI",
|
||||
"CDFGHIJK": "CGJDHFIK",
|
||||
"CDEHIJKL": "EJICHDLK",
|
||||
"CDEGIJKL": "EGICJDLK",
|
||||
"CDEGHJKL": "EGJCHDLK",
|
||||
"CDEGHIKL": "EGICHDLK",
|
||||
"CDEGHIJL": "EGJCHDLI",
|
||||
"CDEGHIJK": "EGJCHDIK",
|
||||
"CDEFIJKL": "CJEDIFLK",
|
||||
"CDEFHJKL": "CJEDHFLK",
|
||||
"CDEFHIKL": "CEIDHFLK",
|
||||
"CDEFHIJL": "CJEDHFLI",
|
||||
"CDEFHIJK": "CJEDHFIK",
|
||||
"CDEFGJKL": "CGEDJFLK",
|
||||
"CDEFGIKL": "CGEDIFLK",
|
||||
"CDEFGIJL": "CGEDJFLI",
|
||||
"CDEFGIJK": "CGEDJFIK",
|
||||
"CDEFGHKL": "CGEDHFLK",
|
||||
"CDEFGHJL": "CGJDHFLE",
|
||||
"CDEFGHJK": "CGJDHFEK",
|
||||
"CDEFGHIL": "CGEDHFLI",
|
||||
"CDEFGHIK": "CGEDHFIK",
|
||||
"CDEFGHIJ": "CGJDHFEI",
|
||||
"BFGHIJKL": "HJBFIGLK",
|
||||
"BEGHIJKL": "EJIBHGLK",
|
||||
"BEFHIJKL": "EJBFIHLK",
|
||||
"BEFGIJKL": "EJBFIGLK",
|
||||
"BEFGHJKL": "EJBFHGLK",
|
||||
"BEFGHIKL": "EGBFIHLK",
|
||||
"BEFGHIJL": "EJBFHGLI",
|
||||
"BEFGHIJK": "EJBFHGIK",
|
||||
"BDGHIJKL": "HJBDIGLK",
|
||||
"BDFHIJKL": "HJBDIFLK",
|
||||
"BDFGIJKL": "IGBDJFLK",
|
||||
"BDFGHJKL": "HGBDJFLK",
|
||||
"BDFGHIKL": "HGBDIFLK",
|
||||
"BDFGHIJL": "HGBDJFLI",
|
||||
"BDFGHIJK": "HGBDJFIK",
|
||||
"BDEHIJKL": "EJBDIHLK",
|
||||
"BDEGIJKL": "EJBDIGLK",
|
||||
"BDEGHJKL": "EJBDHGLK",
|
||||
"BDEGHIKL": "EGBDIHLK",
|
||||
"BDEGHIJL": "EJBDHGLI",
|
||||
"BDEGHIJK": "EJBDHGIK",
|
||||
"BDEFIJKL": "EJBDIFLK",
|
||||
"BDEFHJKL": "EJBDHFLK",
|
||||
"BDEFHIKL": "EIBDHFLK",
|
||||
"BDEFHIJL": "EJBDHFLI",
|
||||
"BDEFHIJK": "EJBDHFIK",
|
||||
"BDEFGJKL": "EGBDJFLK",
|
||||
"BDEFGIKL": "EGBDIFLK",
|
||||
"BDEFGIJL": "EGBDJFLI",
|
||||
"BDEFGIJK": "EGBDJFIK",
|
||||
"BDEFGHKL": "EGBDHFLK",
|
||||
"BDEFGHJL": "HGBDJFLE",
|
||||
"BDEFGHJK": "HGBDJFEK",
|
||||
"BDEFGHIL": "EGBDHFLI",
|
||||
"BDEFGHIK": "EGBDHFIK",
|
||||
"BDEFGHIJ": "HGBDJFEI",
|
||||
"BCGHIJKL": "HJBCIGLK",
|
||||
"BCFHIJKL": "HJBCIFLK",
|
||||
"BCFGIJKL": "IGBCJFLK",
|
||||
"BCFGHJKL": "HGBCJFLK",
|
||||
"BCFGHIKL": "HGBCIFLK",
|
||||
"BCFGHIJL": "HGBCJFLI",
|
||||
"BCFGHIJK": "HGBCJFIK",
|
||||
"BCEHIJKL": "EJBCIHLK",
|
||||
"BCEGIJKL": "EJBCIGLK",
|
||||
"BCEGHJKL": "EJBCHGLK",
|
||||
"BCEGHIKL": "EGBCIHLK",
|
||||
"BCEGHIJL": "EJBCHGLI",
|
||||
"BCEGHIJK": "EJBCHGIK",
|
||||
"BCEFIJKL": "EJBCIFLK",
|
||||
"BCEFHJKL": "EJBCHFLK",
|
||||
"BCEFHIKL": "EIBCHFLK",
|
||||
"BCEFHIJL": "EJBCHFLI",
|
||||
"BCEFHIJK": "EJBCHFIK",
|
||||
"BCEFGJKL": "EGBCJFLK",
|
||||
"BCEFGIKL": "EGBCIFLK",
|
||||
"BCEFGIJL": "EGBCJFLI",
|
||||
"BCEFGIJK": "EGBCJFIK",
|
||||
"BCEFGHKL": "EGBCHFLK",
|
||||
"BCEFGHJL": "HGBCJFLE",
|
||||
"BCEFGHJK": "HGBCJFEK",
|
||||
"BCEFGHIL": "EGBCHFLI",
|
||||
"BCEFGHIK": "EGBCHFIK",
|
||||
"BCEFGHIJ": "HGBCJFEI",
|
||||
"BCDHIJKL": "HJBCIDLK",
|
||||
"BCDGIJKL": "IGBCJDLK",
|
||||
"BCDGHJKL": "HGBCJDLK",
|
||||
"BCDGHIKL": "HGBCIDLK",
|
||||
"BCDGHIJL": "HGBCJDLI",
|
||||
"BCDGHIJK": "HGBCJDIK",
|
||||
"BCDFIJKL": "CJBDIFLK",
|
||||
"BCDFHJKL": "CJBDHFLK",
|
||||
"BCDFHIKL": "CIBDHFLK",
|
||||
"BCDFHIJL": "CJBDHFLI",
|
||||
"BCDFHIJK": "CJBDHFIK",
|
||||
"BCDFGJKL": "CGBDJFLK",
|
||||
"BCDFGIKL": "CGBDIFLK",
|
||||
"BCDFGIJL": "CGBDJFLI",
|
||||
"BCDFGIJK": "CGBDJFIK",
|
||||
"BCDFGHKL": "CGBDHFLK",
|
||||
"BCDFGHJL": "CGBDHFLJ",
|
||||
"BCDFGHJK": "HGBCJFDK",
|
||||
"BCDFGHIL": "CGBDHFLI",
|
||||
"BCDFGHIK": "CGBDHFIK",
|
||||
"BCDFGHIJ": "HGBCJFDI",
|
||||
"BCDEIJKL": "EJBCIDLK",
|
||||
"BCDEHJKL": "EJBCHDLK",
|
||||
"BCDEHIKL": "EIBCHDLK",
|
||||
"BCDEHIJL": "EJBCHDLI",
|
||||
"BCDEHIJK": "EJBCHDIK",
|
||||
"BCDEGJKL": "EGBCJDLK",
|
||||
"BCDEGIKL": "EGBCIDLK",
|
||||
"BCDEGIJL": "EGBCJDLI",
|
||||
"BCDEGIJK": "EGBCJDIK",
|
||||
"BCDEGHKL": "EGBCHDLK",
|
||||
"BCDEGHJL": "HGBCJDLE",
|
||||
"BCDEGHJK": "HGBCJDEK",
|
||||
"BCDEGHIL": "EGBCHDLI",
|
||||
"BCDEGHIK": "EGBCHDIK",
|
||||
"BCDEGHIJ": "HGBCJDEI",
|
||||
"BCDEFJKL": "CJBDEFLK",
|
||||
"BCDEFIKL": "CEBDIFLK",
|
||||
"BCDEFIJL": "CJBDEFLI",
|
||||
"BCDEFIJK": "CJBDEFIK",
|
||||
"BCDEFHKL": "CEBDHFLK",
|
||||
"BCDEFHJL": "CJBDHFLE",
|
||||
"BCDEFHJK": "CJBDHFEK",
|
||||
"BCDEFHIL": "CEBDHFLI",
|
||||
"BCDEFHIK": "CEBDHFIK",
|
||||
"BCDEFHIJ": "CJBDHFEI",
|
||||
"BCDEFGKL": "CGBDEFLK",
|
||||
"BCDEFGJL": "CGBDJFLE",
|
||||
"BCDEFGJK": "CGBDJFEK",
|
||||
"BCDEFGIL": "CGBDEFLI",
|
||||
"BCDEFGIK": "CGBDEFIK",
|
||||
"BCDEFGIJ": "CGBDJFEI",
|
||||
"BCDEFGHL": "CGBDHFLE",
|
||||
"BCDEFGHK": "CGBDHFEK",
|
||||
"BCDEFGHJ": "HGBCJFDE",
|
||||
"BCDEFGHI": "CGBDHFEI",
|
||||
"AFGHIJKL": "HJIFAGLK",
|
||||
"AEGHIJKL": "EJIAHGLK",
|
||||
"AEFHIJKL": "EJIFAHLK",
|
||||
"AEFGIJKL": "EJIFAGLK",
|
||||
"AEFGHJKL": "EGJFAHLK",
|
||||
"AEFGHIKL": "EGIFAHLK",
|
||||
"AEFGHIJL": "EGJFAHLI",
|
||||
"AEFGHIJK": "EGJFAHIK",
|
||||
"ADGHIJKL": "HJIDAGLK",
|
||||
"ADFHIJKL": "HJIDAFLK",
|
||||
"ADFGIJKL": "IGJDAFLK",
|
||||
"ADFGHJKL": "HGJDAFLK",
|
||||
"ADFGHIKL": "HGIDAFLK",
|
||||
"ADFGHIJL": "HGJDAFLI",
|
||||
"ADFGHIJK": "HGJDAFIK",
|
||||
"ADEHIJKL": "EJIDAHLK",
|
||||
"ADEGIJKL": "EJIDAGLK",
|
||||
"ADEGHJKL": "EGJDAHLK",
|
||||
"ADEGHIKL": "EGIDAHLK",
|
||||
"ADEGHIJL": "EGJDAHLI",
|
||||
"ADEGHIJK": "EGJDAHIK",
|
||||
"ADEFIJKL": "EJIDAFLK",
|
||||
"ADEFHJKL": "HJEDAFLK",
|
||||
"ADEFHIKL": "HEIDAFLK",
|
||||
"ADEFHIJL": "HJEDAFLI",
|
||||
"ADEFHIJK": "HJEDAFIK",
|
||||
"ADEFGJKL": "EGJDAFLK",
|
||||
"ADEFGIKL": "EGIDAFLK",
|
||||
"ADEFGIJL": "EGJDAFLI",
|
||||
"ADEFGIJK": "EGJDAFIK",
|
||||
"ADEFGHKL": "HGEDAFLK",
|
||||
"ADEFGHJL": "HGJDAFLE",
|
||||
"ADEFGHJK": "HGJDAFEK",
|
||||
"ADEFGHIL": "HGEDAFLI",
|
||||
"ADEFGHIK": "HGEDAFIK",
|
||||
"ADEFGHIJ": "HGJDAFEI",
|
||||
"ACGHIJKL": "HJICAGLK",
|
||||
"ACFHIJKL": "HJICAFLK",
|
||||
"ACFGIJKL": "IGJCAFLK",
|
||||
"ACFGHJKL": "HGJCAFLK",
|
||||
"ACFGHIKL": "HGICAFLK",
|
||||
"ACFGHIJL": "HGJCAFLI",
|
||||
"ACFGHIJK": "HGJCAFIK",
|
||||
"ACEHIJKL": "EJICAHLK",
|
||||
"ACEGIJKL": "EJICAGLK",
|
||||
"ACEGHJKL": "EGJCAHLK",
|
||||
"ACEGHIKL": "EGICAHLK",
|
||||
"ACEGHIJL": "EGJCAHLI",
|
||||
"ACEGHIJK": "EGJCAHIK",
|
||||
"ACEFIJKL": "EJICAFLK",
|
||||
"ACEFHJKL": "HJECAFLK",
|
||||
"ACEFHIKL": "HEICAFLK",
|
||||
"ACEFHIJL": "HJECAFLI",
|
||||
"ACEFHIJK": "HJECAFIK",
|
||||
"ACEFGJKL": "EGJCAFLK",
|
||||
"ACEFGIKL": "EGICAFLK",
|
||||
"ACEFGIJL": "EGJCAFLI",
|
||||
"ACEFGIJK": "EGJCAFIK",
|
||||
"ACEFGHKL": "HGECAFLK",
|
||||
"ACEFGHJL": "HGJCAFLE",
|
||||
"ACEFGHJK": "HGJCAFEK",
|
||||
"ACEFGHIL": "HGECAFLI",
|
||||
"ACEFGHIK": "HGECAFIK",
|
||||
"ACEFGHIJ": "HGJCAFEI",
|
||||
"ACDHIJKL": "HJICADLK",
|
||||
"ACDGIJKL": "IGJCADLK",
|
||||
"ACDGHJKL": "HGJCADLK",
|
||||
"ACDGHIKL": "HGICADLK",
|
||||
"ACDGHIJL": "HGJCADLI",
|
||||
"ACDGHIJK": "HGJCADIK",
|
||||
"ACDFIJKL": "CJIDAFLK",
|
||||
"ACDFHJKL": "HJFCADLK",
|
||||
"ACDFHIKL": "HFICADLK",
|
||||
"ACDFHIJL": "HJFCADLI",
|
||||
"ACDFHIJK": "HJFCADIK",
|
||||
"ACDFGJKL": "CGJDAFLK",
|
||||
"ACDFGIKL": "CGIDAFLK",
|
||||
"ACDFGIJL": "CGJDAFLI",
|
||||
"ACDFGIJK": "CGJDAFIK",
|
||||
"ACDFGHKL": "HGFCADLK",
|
||||
"ACDFGHJL": "CGJDAFLH",
|
||||
"ACDFGHJK": "HGJCAFDK",
|
||||
"ACDFGHIL": "HGFCADLI",
|
||||
"ACDFGHIK": "HGFCADIK",
|
||||
"ACDFGHIJ": "HGJCAFDI",
|
||||
"ACDEIJKL": "EJICADLK",
|
||||
"ACDEHJKL": "HJECADLK",
|
||||
"ACDEHIKL": "HEICADLK",
|
||||
"ACDEHIJL": "HJECADLI",
|
||||
"ACDEHIJK": "HJECADIK",
|
||||
"ACDEGJKL": "EGJCADLK",
|
||||
"ACDEGIKL": "EGICADLK",
|
||||
"ACDEGIJL": "EGJCADLI",
|
||||
"ACDEGIJK": "EGJCADIK",
|
||||
"ACDEGHKL": "HGECADLK",
|
||||
"ACDEGHJL": "HGJCADLE",
|
||||
"ACDEGHJK": "HGJCADEK",
|
||||
"ACDEGHIL": "HGECADLI",
|
||||
"ACDEGHIK": "HGECADIK",
|
||||
"ACDEGHIJ": "HGJCADEI",
|
||||
"ACDEFJKL": "CJEDAFLK",
|
||||
"ACDEFIKL": "CEIDAFLK",
|
||||
"ACDEFIJL": "CJEDAFLI",
|
||||
"ACDEFIJK": "CJEDAFIK",
|
||||
"ACDEFHKL": "HEFCADLK",
|
||||
"ACDEFHJL": "HJFCADLE",
|
||||
"ACDEFHJK": "HJECAFDK",
|
||||
"ACDEFHIL": "HEFCADLI",
|
||||
"ACDEFHIK": "HEFCADIK",
|
||||
"ACDEFHIJ": "HJECAFDI",
|
||||
"ACDEFGKL": "CGEDAFLK",
|
||||
"ACDEFGJL": "CGJDAFLE",
|
||||
"ACDEFGJK": "CGJDAFEK",
|
||||
"ACDEFGIL": "CGEDAFLI",
|
||||
"ACDEFGIK": "CGEDAFIK",
|
||||
"ACDEFGIJ": "CGJDAFEI",
|
||||
"ACDEFGHL": "HGFCADLE",
|
||||
"ACDEFGHK": "HGECAFDK",
|
||||
"ACDEFGHJ": "HGJCAFDE",
|
||||
"ACDEFGHI": "HGECAFDI",
|
||||
"ABGHIJKL": "HJBAIGLK",
|
||||
"ABFHIJKL": "HJBAIFLK",
|
||||
"ABFGIJKL": "IJBFAGLK",
|
||||
"ABFGHJKL": "HJBFAGLK",
|
||||
"ABFGHIKL": "HGBAIFLK",
|
||||
"ABFGHIJL": "HJBFAGLI",
|
||||
"ABFGHIJK": "HJBFAGIK",
|
||||
"ABEHIJKL": "EJBAIHLK",
|
||||
"ABEGIJKL": "EJBAIGLK",
|
||||
"ABEGHJKL": "EJBAHGLK",
|
||||
"ABEGHIKL": "EGBAIHLK",
|
||||
"ABEGHIJL": "EJBAHGLI",
|
||||
"ABEGHIJK": "EJBAHGIK",
|
||||
"ABEFIJKL": "EJBAIFLK",
|
||||
"ABEFHJKL": "EJBFAHLK",
|
||||
"ABEFHIKL": "EIBFAHLK",
|
||||
"ABEFHIJL": "EJBFAHLI",
|
||||
"ABEFHIJK": "EJBFAHIK",
|
||||
"ABEFGJKL": "EJBFAGLK",
|
||||
"ABEFGIKL": "EGBAIFLK",
|
||||
"ABEFGIJL": "EJBFAGLI",
|
||||
"ABEFGIJK": "EJBFAGIK",
|
||||
"ABEFGHKL": "EGBFAHLK",
|
||||
"ABEFGHJL": "HJBFAGLE",
|
||||
"ABEFGHJK": "HJBFAGEK",
|
||||
"ABEFGHIL": "EGBFAHLI",
|
||||
"ABEFGHIK": "EGBFAHIK",
|
||||
"ABEFGHIJ": "HJBFAGEI",
|
||||
"ABDHIJKL": "IJBDAHLK",
|
||||
"ABDGIJKL": "IJBDAGLK",
|
||||
"ABDGHJKL": "HJBDAGLK",
|
||||
"ABDGHIKL": "IGBDAHLK",
|
||||
"ABDGHIJL": "HJBDAGLI",
|
||||
"ABDGHIJK": "HJBDAGIK",
|
||||
"ABDFIJKL": "IJBDAFLK",
|
||||
"ABDFHJKL": "HJBDAFLK",
|
||||
"ABDFHIKL": "HIBDAFLK",
|
||||
"ABDFHIJL": "HJBDAFLI",
|
||||
"ABDFHIJK": "HJBDAFIK",
|
||||
"ABDFGJKL": "FJBDAGLK",
|
||||
"ABDFGIKL": "IGBDAFLK",
|
||||
"ABDFGIJL": "FJBDAGLI",
|
||||
"ABDFGIJK": "FJBDAGIK",
|
||||
"ABDFGHKL": "HGBDAFLK",
|
||||
"ABDFGHJL": "HGBDAFLJ",
|
||||
"ABDFGHJK": "HGBDAFJK",
|
||||
"ABDFGHIL": "HGBDAFLI",
|
||||
"ABDFGHIK": "HGBDAFIK",
|
||||
"ABDFGHIJ": "HGBDAFIJ",
|
||||
"ABDEIJKL": "EJBAIDLK",
|
||||
"ABDEHJKL": "EJBDAHLK",
|
||||
"ABDEHIKL": "EIBDAHLK",
|
||||
"ABDEHIJL": "EJBDAHLI",
|
||||
"ABDEHIJK": "EJBDAHIK",
|
||||
"ABDEGJKL": "EJBDAGLK",
|
||||
"ABDEGIKL": "EGBAIDLK",
|
||||
"ABDEGIJL": "EJBDAGLI",
|
||||
"ABDEGIJK": "EJBDAGIK",
|
||||
"ABDEGHKL": "EGBDAHLK",
|
||||
"ABDEGHJL": "HJBDAGLE",
|
||||
"ABDEGHJK": "HJBDAGEK",
|
||||
"ABDEGHIL": "EGBDAHLI",
|
||||
"ABDEGHIK": "EGBDAHIK",
|
||||
"ABDEGHIJ": "HJBDAGEI",
|
||||
"ABDEFJKL": "EJBDAFLK",
|
||||
"ABDEFIKL": "EIBDAFLK",
|
||||
"ABDEFIJL": "EJBDAFLI",
|
||||
"ABDEFIJK": "EJBDAFIK",
|
||||
"ABDEFHKL": "HEBDAFLK",
|
||||
"ABDEFHJL": "HJBDAFLE",
|
||||
"ABDEFHJK": "HJBDAFEK",
|
||||
"ABDEFHIL": "HEBDAFLI",
|
||||
"ABDEFHIK": "HEBDAFIK",
|
||||
"ABDEFHIJ": "HJBDAFEI",
|
||||
"ABDEFGKL": "EGBDAFLK",
|
||||
"ABDEFGJL": "EGBDAFLJ",
|
||||
"ABDEFGJK": "EGBDAFJK",
|
||||
"ABDEFGIL": "EGBDAFLI",
|
||||
"ABDEFGIK": "EGBDAFIK",
|
||||
"ABDEFGIJ": "EGBDAFIJ",
|
||||
"ABDEFGHL": "HGBDAFLE",
|
||||
"ABDEFGHK": "HGBDAFEK",
|
||||
"ABDEFGHJ": "HGBDAFEJ",
|
||||
"ABDEFGHI": "HGBDAFEI",
|
||||
"ABCHIJKL": "IJBCAHLK",
|
||||
"ABCGIJKL": "IJBCAGLK",
|
||||
"ABCGHJKL": "HJBCAGLK",
|
||||
"ABCGHIKL": "IGBCAHLK",
|
||||
"ABCGHIJL": "HJBCAGLI",
|
||||
"ABCGHIJK": "HJBCAGIK",
|
||||
"ABCFIJKL": "IJBCAFLK",
|
||||
"ABCFHJKL": "HJBCAFLK",
|
||||
"ABCFHIKL": "HIBCAFLK",
|
||||
"ABCFHIJL": "HJBCAFLI",
|
||||
"ABCFHIJK": "HJBCAFIK",
|
||||
"ABCFGJKL": "CJBFAGLK",
|
||||
"ABCFGIKL": "IGBCAFLK",
|
||||
"ABCFGIJL": "CJBFAGLI",
|
||||
"ABCFGIJK": "CJBFAGIK",
|
||||
"ABCFGHKL": "HGBCAFLK",
|
||||
"ABCFGHJL": "HGBCAFLJ",
|
||||
"ABCFGHJK": "HGBCAFJK",
|
||||
"ABCFGHIL": "HGBCAFLI",
|
||||
"ABCFGHIK": "HGBCAFIK",
|
||||
"ABCFGHIJ": "HGBCAFIJ",
|
||||
"ABCEIJKL": "EJBAICLK",
|
||||
"ABCEHJKL": "EJBCAHLK",
|
||||
"ABCEHIKL": "EIBCAHLK",
|
||||
"ABCEHIJL": "EJBCAHLI",
|
||||
"ABCEHIJK": "EJBCAHIK",
|
||||
"ABCEGJKL": "EJBCAGLK",
|
||||
"ABCEGIKL": "EGBAICLK",
|
||||
"ABCEGIJL": "EJBCAGLI",
|
||||
"ABCEGIJK": "EJBCAGIK",
|
||||
"ABCEGHKL": "EGBCAHLK",
|
||||
"ABCEGHJL": "HJBCAGLE",
|
||||
"ABCEGHJK": "HJBCAGEK",
|
||||
"ABCEGHIL": "EGBCAHLI",
|
||||
"ABCEGHIK": "EGBCAHIK",
|
||||
"ABCEGHIJ": "HJBCAGEI",
|
||||
"ABCEFJKL": "EJBCAFLK",
|
||||
"ABCEFIKL": "EIBCAFLK",
|
||||
"ABCEFIJL": "EJBCAFLI",
|
||||
"ABCEFIJK": "EJBCAFIK",
|
||||
"ABCEFHKL": "HEBCAFLK",
|
||||
"ABCEFHJL": "HJBCAFLE",
|
||||
"ABCEFHJK": "HJBCAFEK",
|
||||
"ABCEFHIL": "HEBCAFLI",
|
||||
"ABCEFHIK": "HEBCAFIK",
|
||||
"ABCEFHIJ": "HJBCAFEI",
|
||||
"ABCEFGKL": "EGBCAFLK",
|
||||
"ABCEFGJL": "EGBCAFLJ",
|
||||
"ABCEFGJK": "EGBCAFJK",
|
||||
"ABCEFGIL": "EGBCAFLI",
|
||||
"ABCEFGIK": "EGBCAFIK",
|
||||
"ABCEFGIJ": "EGBCAFIJ",
|
||||
"ABCEFGHL": "HGBCAFLE",
|
||||
"ABCEFGHK": "HGBCAFEK",
|
||||
"ABCEFGHJ": "HGBCAFEJ",
|
||||
"ABCEFGHI": "HGBCAFEI",
|
||||
"ABCDIJKL": "IJBCADLK",
|
||||
"ABCDHJKL": "HJBCADLK",
|
||||
"ABCDHIKL": "HIBCADLK",
|
||||
"ABCDHIJL": "HJBCADLI",
|
||||
"ABCDHIJK": "HJBCADIK",
|
||||
"ABCDGJKL": "CJBDAGLK",
|
||||
"ABCDGIKL": "IGBCADLK",
|
||||
"ABCDGIJL": "CJBDAGLI",
|
||||
"ABCDGIJK": "CJBDAGIK",
|
||||
"ABCDGHKL": "HGBCADLK",
|
||||
"ABCDGHJL": "HGBCADLJ",
|
||||
"ABCDGHJK": "HGBCADJK",
|
||||
"ABCDGHIL": "HGBCADLI",
|
||||
"ABCDGHIK": "HGBCADIK",
|
||||
"ABCDGHIJ": "HGBCADIJ",
|
||||
"ABCDFJKL": "CJBDAFLK",
|
||||
"ABCDFIKL": "CIBDAFLK",
|
||||
"ABCDFIJL": "CJBDAFLI",
|
||||
"ABCDFIJK": "CJBDAFIK",
|
||||
"ABCDFHKL": "HFBCADLK",
|
||||
"ABCDFHJL": "CJBDAFLH",
|
||||
"ABCDFHJK": "HJBCAFDK",
|
||||
"ABCDFHIL": "HFBCADLI",
|
||||
"ABCDFHIK": "HFBCADIK",
|
||||
"ABCDFHIJ": "HJBCAFDI",
|
||||
"ABCDFGKL": "CGBDAFLK",
|
||||
"ABCDFGJL": "CGBDAFLJ",
|
||||
"ABCDFGJK": "CGBDAFJK",
|
||||
"ABCDFGIL": "CGBDAFLI",
|
||||
"ABCDFGIK": "CGBDAFIK",
|
||||
"ABCDFGIJ": "CGBDAFIJ",
|
||||
"ABCDFGHL": "CGBDAFLH",
|
||||
"ABCDFGHK": "HGBCAFDK",
|
||||
"ABCDFGHJ": "HGBCAFDJ",
|
||||
"ABCDFGHI": "HGBCAFDI",
|
||||
"ABCDEJKL": "EJBCADLK",
|
||||
"ABCDEIKL": "EIBCADLK",
|
||||
"ABCDEIJL": "EJBCADLI",
|
||||
"ABCDEIJK": "EJBCADIK",
|
||||
"ABCDEHKL": "HEBCADLK",
|
||||
"ABCDEHJL": "HJBCADLE",
|
||||
"ABCDEHJK": "HJBCADEK",
|
||||
"ABCDEHIL": "HEBCADLI",
|
||||
"ABCDEHIK": "HEBCADIK",
|
||||
"ABCDEHIJ": "HJBCADEI",
|
||||
"ABCDEGKL": "EGBCADLK",
|
||||
"ABCDEGJL": "EGBCADLJ",
|
||||
"ABCDEGJK": "EGBCADJK",
|
||||
"ABCDEGIL": "EGBCADLI",
|
||||
"ABCDEGIK": "EGBCADIK",
|
||||
"ABCDEGIJ": "EGBCADIJ",
|
||||
"ABCDEGHL": "HGBCADLE",
|
||||
"ABCDEGHK": "HGBCADEK",
|
||||
"ABCDEGHJ": "HGBCADEJ",
|
||||
"ABCDEGHI": "HGBCADEI",
|
||||
"ABCDEFKL": "CEBDAFLK",
|
||||
"ABCDEFJL": "CJBDAFLE",
|
||||
"ABCDEFJK": "CJBDAFEK",
|
||||
"ABCDEFIL": "CEBDAFLI",
|
||||
"ABCDEFIK": "CEBDAFIK",
|
||||
"ABCDEFIJ": "CJBDAFEI",
|
||||
"ABCDEFHL": "HFBCADLE",
|
||||
"ABCDEFHK": "HEBCAFDK",
|
||||
"ABCDEFHJ": "HJBCAFDE",
|
||||
"ABCDEFHI": "HEBCAFDI",
|
||||
"ABCDEFGL": "CGBDAFLE",
|
||||
"ABCDEFGK": "CGBDAFEK",
|
||||
"ABCDEFGJ": "CGBDAFEJ",
|
||||
"ABCDEFGI": "CGBDAFEI",
|
||||
"ABCDEFGH": "HGBCAFDE",
|
||||
};
|
||||
111
lib/bracket.ts
Normal file
111
lib/bracket.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ANNEX_C } from "./annexc-data";
|
||||
import { GroupId, GroupTable, ThirdPlaceRow } from "./types";
|
||||
|
||||
// Die festen Paarungen der Runde der letzten 32 (FIFA-Spielplan, Annex zur Auslosung).
|
||||
// Quelle: FIFA WM 2026 Wettbewerbsregeln, Spiele 73-88.
|
||||
// "W"=Sieger, "R"=Zweiter, "3"=bester Drittplatzierter (Gruppe per Annex C bestimmt).
|
||||
export interface BracketSlot {
|
||||
type: "W" | "R" | "3";
|
||||
group?: GroupId; // bei W/R: feste Gruppe
|
||||
thirdPool?: GroupId[]; // bei 3: mögliche Quellgruppen (Info / UI)
|
||||
}
|
||||
|
||||
export interface R32Match {
|
||||
matchNumber: number;
|
||||
home: BracketSlot;
|
||||
away: BracketSlot;
|
||||
}
|
||||
|
||||
// Die acht Gruppensieger, die einen Drittplatzierten bekommen — in der
|
||||
// Spaltenreihenfolge von Annex C: 1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L.
|
||||
export const ANNEX_WINNER_ORDER: GroupId[] = ["A", "B", "D", "E", "G", "I", "K", "L"];
|
||||
|
||||
// Statische R32-Struktur. thirdPool dient nur der Anzeige vor Turnierende.
|
||||
export const R32: R32Match[] = [
|
||||
{ matchNumber: 73, home: { type: "R", group: "A" }, away: { type: "R", group: "B" } },
|
||||
{ matchNumber: 74, home: { type: "W", group: "E" }, away: { type: "3", thirdPool: ["A","B","C","D","F"] } },
|
||||
{ matchNumber: 75, home: { type: "W", group: "F" }, away: { type: "R", group: "C" } },
|
||||
{ matchNumber: 76, home: { type: "W", group: "C" }, away: { type: "R", group: "F" } },
|
||||
{ matchNumber: 77, home: { type: "W", group: "I" }, away: { type: "3", thirdPool: ["C","D","F","G","H"] } },
|
||||
{ matchNumber: 78, home: { type: "R", group: "E" }, away: { type: "R", group: "I" } },
|
||||
{ matchNumber: 79, home: { type: "W", group: "A" }, away: { type: "3", thirdPool: ["C","E","F","H","I"] } },
|
||||
{ matchNumber: 80, home: { type: "W", group: "L" }, away: { type: "3", thirdPool: ["E","H","I","J","K"] } },
|
||||
{ matchNumber: 81, home: { type: "W", group: "D" }, away: { type: "3", thirdPool: ["B","E","F","I","J"] } },
|
||||
{ matchNumber: 82, home: { type: "W", group: "G" }, away: { type: "3", thirdPool: ["A","E","H","I","J"] } },
|
||||
{ matchNumber: 83, home: { type: "R", group: "K" }, away: { type: "R", group: "L" } },
|
||||
{ matchNumber: 84, home: { type: "W", group: "H" }, away: { type: "R", group: "J" } },
|
||||
{ matchNumber: 85, home: { type: "W", group: "B" }, away: { type: "3", thirdPool: ["E","F","G","I","J"] } },
|
||||
{ matchNumber: 86, home: { type: "W", group: "J" }, away: { type: "R", group: "H" } },
|
||||
{ matchNumber: 87, home: { type: "W", group: "K" }, away: { type: "3", thirdPool: ["D","E","I","J","L"] } },
|
||||
{ matchNumber: 88, home: { type: "R", group: "D" }, away: { type: "R", group: "G" } },
|
||||
];
|
||||
|
||||
// Folgerunden: jedes Spiel speist sich aus zwei Vorspielen (FIFA-Spielplan).
|
||||
export interface KnockoutMatch {
|
||||
matchNumber: number;
|
||||
stage: "R16" | "QF" | "SF" | "3RD" | "FINAL";
|
||||
fromHome: number; // Match-Nr des Vorspiels (Sieger), bei 3RD: Verlierer
|
||||
fromAway: number;
|
||||
losers?: boolean; // true beim Spiel um Platz 3
|
||||
}
|
||||
|
||||
export const LATER_ROUNDS: KnockoutMatch[] = [
|
||||
{ matchNumber: 89, stage: "R16", fromHome: 74, fromAway: 77 },
|
||||
{ matchNumber: 90, stage: "R16", fromHome: 73, fromAway: 75 },
|
||||
{ matchNumber: 91, stage: "R16", fromHome: 76, fromAway: 78 },
|
||||
{ matchNumber: 92, stage: "R16", fromHome: 79, fromAway: 80 },
|
||||
{ matchNumber: 93, stage: "R16", fromHome: 83, fromAway: 84 },
|
||||
{ matchNumber: 94, stage: "R16", fromHome: 81, fromAway: 82 },
|
||||
{ matchNumber: 95, stage: "R16", fromHome: 86, fromAway: 88 },
|
||||
{ matchNumber: 96, stage: "R16", fromHome: 85, fromAway: 87 },
|
||||
{ matchNumber: 97, stage: "QF", fromHome: 89, fromAway: 90 },
|
||||
{ matchNumber: 98, stage: "QF", fromHome: 93, fromAway: 94 },
|
||||
{ matchNumber: 99, stage: "QF", fromHome: 91, fromAway: 92 },
|
||||
{ matchNumber: 100, stage: "QF", fromHome: 95, fromAway: 96 },
|
||||
{ matchNumber: 101, stage: "SF", fromHome: 97, fromAway: 98 },
|
||||
{ matchNumber: 102, stage: "SF", fromHome: 99, fromAway: 100 },
|
||||
{ matchNumber: 103, stage: "3RD", fromHome: 101, fromAway: 102, losers: true },
|
||||
{ matchNumber: 104, stage: "FINAL", fromHome: 101, fromAway: 102 },
|
||||
];
|
||||
|
||||
// Ergebnis der Annex-C-Auflösung: pro Gruppensieger der zugeordnete Dritte.
|
||||
export type ThirdAssignment = Partial<Record<GroupId, GroupId>>;
|
||||
|
||||
// Normalisiert die qualifizierten Gruppen zum Annex-C-Schlüssel (alphabetisch).
|
||||
export function annexKey(qualifiedGroups: GroupId[]): string {
|
||||
return [...qualifiedGroups].sort().join("");
|
||||
}
|
||||
|
||||
// Schlägt die Zuordnung in Annex C nach. Gibt {Gruppensieger -> 3.Gruppe} zurück.
|
||||
// Liefert null, wenn nicht genau 8 Gruppen oder kein Treffer.
|
||||
export function resolveAnnexC(qualifiedGroups: GroupId[]): ThirdAssignment | null {
|
||||
if (qualifiedGroups.length !== 8) return null;
|
||||
const key = annexKey(qualifiedGroups);
|
||||
const value = ANNEX_C[key];
|
||||
if (!value) return null;
|
||||
const assignment: ThirdAssignment = {};
|
||||
for (let i = 0; i < ANNEX_WINNER_ORDER.length; i++) {
|
||||
assignment[ANNEX_WINNER_ORDER[i]] = value[i] as GroupId;
|
||||
}
|
||||
return assignment;
|
||||
}
|
||||
|
||||
// Bestimmt die qualifizierten Drittplatzierten-Gruppen aus der Tabelle.
|
||||
export function qualifiedThirdGroups(thirds: ThirdPlaceRow[]): GroupId[] {
|
||||
return thirds.filter((t) => t.qualifies).map((t) => t.group);
|
||||
}
|
||||
|
||||
// Hilfslabel für einen Slot, abhängig davon ob die Annex-C-Zuordnung schon feststeht.
|
||||
export function slotLabel(
|
||||
slot: BracketSlot,
|
||||
assignment: ThirdAssignment | null,
|
||||
winnerGroupForThisMatch?: GroupId,
|
||||
): string {
|
||||
if (slot.type === "W") return `Sieger ${slot.group}`;
|
||||
if (slot.type === "R") return `Zweiter ${slot.group}`;
|
||||
// 3. Platz
|
||||
if (assignment && winnerGroupForThisMatch && assignment[winnerGroupForThisMatch]) {
|
||||
return `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`;
|
||||
}
|
||||
return `3. ${slot.thirdPool?.join("/") ?? "?"}`;
|
||||
}
|
||||
199
lib/feeds.ts
Normal file
199
lib/feeds.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { GroupId, Match, MatchStatus, Team } from "./types";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
|
||||
// Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit).
|
||||
// ----------------------------------------------------------------------------
|
||||
interface CacheEntry<T> { value: T; expires: number; }
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
||||
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
||||
const now = Date.now();
|
||||
if (hit && hit.expires > now) return hit.value;
|
||||
try {
|
||||
const value = await fn();
|
||||
cache.set(key, { value, expires: now + ttlMs });
|
||||
return value;
|
||||
} catch (err) {
|
||||
// Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen.
|
||||
if (hit) return hit.value;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// football-data.org: Teams, Spiele, Status
|
||||
// ----------------------------------------------------------------------------
|
||||
const FD_BASE = "https://api.football-data.org/v4";
|
||||
const FD_COMP = "WC"; // FIFA World Cup
|
||||
|
||||
function fdHeaders(): Record<string, string> {
|
||||
const token = process.env.FOOTBALL_DATA_TOKEN;
|
||||
return token ? { "X-Auth-Token": token } : {};
|
||||
}
|
||||
|
||||
function mapStatus(s: string): MatchStatus {
|
||||
switch (s) {
|
||||
case "LIVE": return "LIVE";
|
||||
case "IN_PLAY": return "IN_PLAY";
|
||||
case "PAUSED": return "PAUSED";
|
||||
case "FINISHED": return "FINISHED";
|
||||
default: return "SCHEDULED";
|
||||
}
|
||||
}
|
||||
|
||||
// Wandelt einen football-data-Gruppennamen ("GROUP_A") in unsere GroupId.
|
||||
function parseGroup(g: string | null | undefined): GroupId | null {
|
||||
if (!g) return null;
|
||||
const m = /GROUP_([A-L])/.exec(g);
|
||||
return m ? (m[1] as GroupId) : null;
|
||||
}
|
||||
|
||||
interface FdMatchesResponse {
|
||||
matches: Array<{
|
||||
id: number;
|
||||
utcDate: string;
|
||||
status: string;
|
||||
minute?: number | null;
|
||||
matchday?: number | null;
|
||||
stage: string;
|
||||
group?: string | null;
|
||||
homeTeam: { id: number | null; name: string | null; tla?: string | null };
|
||||
awayTeam: { id: number | null; name: string | null; tla?: string | null };
|
||||
score: { fullTime: { home: number | null; away: number | null } };
|
||||
}>;
|
||||
}
|
||||
|
||||
function stageFor(stage: string, group: GroupId | null): Match["stage"] {
|
||||
if (group) return "GROUP";
|
||||
switch (stage) {
|
||||
case "LAST_32": return "R32";
|
||||
case "LAST_16": return "R16";
|
||||
case "QUARTER_FINALS": return "QF";
|
||||
case "SEMI_FINALS": return "SF";
|
||||
case "THIRD_PLACE": return "3RD";
|
||||
case "FINAL": return "FINAL";
|
||||
default: return "GROUP";
|
||||
}
|
||||
}
|
||||
|
||||
// Holt alle Spiele + leitet die Teamliste daraus ab (spart einen Extra-Call).
|
||||
export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: Team[] }> {
|
||||
return cached("fd:matches", 60_000, async () => {
|
||||
const res = await fetch(`${FD_BASE}/competitions/${FD_COMP}/matches`, {
|
||||
headers: fdHeaders(),
|
||||
// Next.js: kein eigenes Caching, wir cachen selbst
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`football-data ${res.status}`);
|
||||
const data = (await res.json()) as FdMatchesResponse;
|
||||
|
||||
const teamMap = new Map<string, Team>();
|
||||
const matches: Match[] = data.matches.map((m) => {
|
||||
const group = parseGroup(m.group);
|
||||
// Teams registrieren (nur wenn Gruppenspiel und ID vorhanden)
|
||||
for (const side of [m.homeTeam, m.awayTeam]) {
|
||||
if (side.id != null && side.name && group) {
|
||||
const id = String(side.id);
|
||||
if (!teamMap.has(id)) {
|
||||
teamMap.set(id, { id, name: side.name, code: side.tla ?? "", group });
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: String(m.id),
|
||||
group,
|
||||
stage: stageFor(m.stage, group),
|
||||
matchNumber: m.matchday ?? 0,
|
||||
utcDate: m.utcDate,
|
||||
status: mapStatus(m.status),
|
||||
minute: m.minute ?? null,
|
||||
homeTeamId: m.homeTeam.id != null ? String(m.homeTeam.id) : null,
|
||||
awayTeamId: m.awayTeam.id != null ? String(m.awayTeam.id) : null,
|
||||
homeScore: m.score.fullTime.home,
|
||||
awayScore: m.score.fullTime.away,
|
||||
};
|
||||
});
|
||||
|
||||
return { matches, teams: [...teamMap.values()] };
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel
|
||||
// ----------------------------------------------------------------------------
|
||||
const PM_BASE = "https://gamma-api.polymarket.com";
|
||||
|
||||
interface PmMarket {
|
||||
question: string;
|
||||
outcomes: string; // JSON-String, z.B. "[\"Germany\",\"Draw\",\"Mexico\"]"
|
||||
outcomePrices: string; // JSON-String, z.B. "[\"0.55\",\"0.25\",\"0.20\"]"
|
||||
slug: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
// Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit.
|
||||
export interface OddsEntry {
|
||||
outcomes: string[];
|
||||
prices: number[];
|
||||
question: string;
|
||||
}
|
||||
|
||||
function safeParse<T>(s: string, fallback: T): T {
|
||||
try { return JSON.parse(s) as T; } catch { return fallback; }
|
||||
}
|
||||
|
||||
// Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion.
|
||||
// Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar.
|
||||
export async function fetchOdds(): Promise<OddsEntry[]> {
|
||||
const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026";
|
||||
return cached("pm:odds", 120_000, async () => {
|
||||
const res = await fetch(
|
||||
`${PM_BASE}/events?slug=${encodeURIComponent(slug)}`,
|
||||
{ cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`polymarket ${res.status}`);
|
||||
const events = (await res.json()) as Array<{ markets?: PmMarket[] }>;
|
||||
const entries: OddsEntry[] = [];
|
||||
for (const ev of events) {
|
||||
for (const mk of ev.markets ?? []) {
|
||||
if (mk.closed) continue;
|
||||
const outcomes = safeParse<string[]>(mk.outcomes, []);
|
||||
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
|
||||
if (outcomes.length && outcomes.length === prices.length) {
|
||||
entries.push({ outcomes, prices, question: mk.question });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}
|
||||
|
||||
// Verknüpft Polymarket-Märkte mit Spielen anhand der Teamnamen im Markttitel.
|
||||
export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] {
|
||||
const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()]));
|
||||
return matches.map((m) => {
|
||||
if (!m.homeTeamId || !m.awayTeamId) return m;
|
||||
const hn = nameById.get(m.homeTeamId);
|
||||
const an = nameById.get(m.awayTeamId);
|
||||
if (!hn || !an) return m;
|
||||
const hit = odds.find(
|
||||
(o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an),
|
||||
);
|
||||
if (!hit) return m;
|
||||
// Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts.
|
||||
const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn));
|
||||
const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an));
|
||||
const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o));
|
||||
if (idxHome < 0 || idxAway < 0) return m;
|
||||
return {
|
||||
...m,
|
||||
prob: {
|
||||
home: hit.prices[idxHome] ?? 0,
|
||||
draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null,
|
||||
away: hit.prices[idxAway] ?? 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
140
lib/resolve-bracket.ts
Normal file
140
lib/resolve-bracket.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { GroupId, GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
|
||||
import {
|
||||
R32, LATER_ROUNDS, R32Match, BracketSlot,
|
||||
ThirdAssignment, slotLabel,
|
||||
} from "@/lib/bracket";
|
||||
|
||||
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
|
||||
export interface ResolvedSide {
|
||||
teamId: string | null; // null = noch Platzhalter
|
||||
label: string; // Anzeige (Teamname oder "Sieger E" etc.)
|
||||
code: string;
|
||||
score: number | null;
|
||||
isWinner: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedTie {
|
||||
matchNumber: number;
|
||||
stage: string;
|
||||
home: ResolvedSide;
|
||||
away: ResolvedSide;
|
||||
status: string;
|
||||
prob?: { home: number; draw: number | null; away: number } | null;
|
||||
}
|
||||
|
||||
// Findet das tatsächliche Spiel (aus dem Feed) zu einer FIFA-Match-Nummer.
|
||||
function feedMatch(matches: Match[], num: number): Match | undefined {
|
||||
return matches.find((m) => m.matchNumber === num && m.group == null);
|
||||
}
|
||||
|
||||
// Bestimmt den Sieger eines abgeschlossenen Spiels (Feed) als Team-ID.
|
||||
function winnerOf(m: Match | undefined): string | null {
|
||||
if (!m || m.status !== "FINISHED") return null;
|
||||
if (m.homeScore == null || m.awayScore == null) return null;
|
||||
if (m.homeScore > m.awayScore) return m.homeTeamId;
|
||||
if (m.awayScore > m.homeScore) return m.awayTeamId;
|
||||
return null; // Unentschieden -> Elfmeter; Feed liefert i.d.R. Sieger separat
|
||||
}
|
||||
function loserOf(m: Match | undefined): string | null {
|
||||
if (!m || m.status !== "FINISHED") return null;
|
||||
if (m.homeScore == null || m.awayScore == null) return null;
|
||||
if (m.homeScore > m.awayScore) return m.awayTeamId;
|
||||
if (m.awayScore > m.homeScore) return m.homeTeamId;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Löst einen R32-Slot (W/R/3) zu einer Team-ID auf, sofern bereits bekannt.
|
||||
function resolveR32Slot(
|
||||
slot: BracketSlot,
|
||||
winnerGroup: GroupId | undefined,
|
||||
tables: GroupTable[],
|
||||
assignment: ThirdAssignment | null,
|
||||
thirds: ThirdPlaceRow[],
|
||||
): string | null {
|
||||
const table = (g: GroupId) => tables.find((t) => t.group === g);
|
||||
if (slot.type === "W") {
|
||||
const t = table(slot.group!);
|
||||
return t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
|
||||
}
|
||||
if (slot.type === "R") {
|
||||
const t = table(slot.group!);
|
||||
return t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
|
||||
}
|
||||
// 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels
|
||||
if (slot.type === "3" && assignment && winnerGroup) {
|
||||
const thirdGroup = assignment[winnerGroup];
|
||||
if (thirdGroup) {
|
||||
const row = thirds.find((r) => r.group === thirdGroup && r.qualifies);
|
||||
return row?.teamId ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sideFrom(
|
||||
teamId: string | null, fallbackLabel: string,
|
||||
teams: Team[], feed: Match | undefined, which: "home" | "away",
|
||||
): ResolvedSide {
|
||||
const team = teamId ? teams.find((t) => t.id === teamId) : undefined;
|
||||
const score = feed
|
||||
? which === "home" ? feed.homeScore : feed.awayScore
|
||||
: null;
|
||||
const w = winnerOf(feed);
|
||||
return {
|
||||
teamId,
|
||||
label: team?.name ?? fallbackLabel,
|
||||
code: team?.code ?? "",
|
||||
score,
|
||||
isWinner: teamId != null && w === teamId,
|
||||
};
|
||||
}
|
||||
|
||||
// Baut die komplette aufgelöste Bracket-Struktur.
|
||||
export function resolveBracket(
|
||||
matches: Match[], teams: Team[], tables: GroupTable[],
|
||||
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
|
||||
): { r32: ResolvedTie[]; later: Record<number, ResolvedTie> } {
|
||||
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
|
||||
const winners = new Map<number, string | null>();
|
||||
const losers = new Map<number, string | null>();
|
||||
|
||||
const r32: ResolvedTie[] = R32.map((rm: R32Match) => {
|
||||
const feed = feedMatch(matches, rm.matchNumber);
|
||||
const homeGroup = rm.home.type === "W" ? rm.home.group : undefined;
|
||||
const awayGroup = rm.away.type === "W" ? rm.away.group : undefined;
|
||||
// Für 3.-Platz-Slots: Gruppensieger ist die andere Seite (immer W)
|
||||
const winnerGroup = (homeGroup ?? awayGroup) as GroupId | undefined;
|
||||
|
||||
const homeId = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds);
|
||||
const awayId = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds);
|
||||
const home = sideFrom(homeId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home");
|
||||
const away = sideFrom(awayId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away");
|
||||
|
||||
winners.set(rm.matchNumber, winnerOf(feed));
|
||||
losers.set(rm.matchNumber, loserOf(feed));
|
||||
return {
|
||||
matchNumber: rm.matchNumber, stage: "R32",
|
||||
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
|
||||
};
|
||||
});
|
||||
|
||||
const later: Record<number, ResolvedTie> = {};
|
||||
for (const km of LATER_ROUNDS) {
|
||||
const feed = feedMatch(matches, km.matchNumber);
|
||||
const src = km.losers ? losers : winners;
|
||||
const homeId = src.get(km.fromHome) ?? null;
|
||||
const awayId = src.get(km.fromAway) ?? null;
|
||||
const homeLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromHome}`;
|
||||
const awayLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromAway}`;
|
||||
const home = sideFrom(homeId, homeLabel, teams, feed, "home");
|
||||
const away = sideFrom(awayId, awayLabel, teams, feed, "away");
|
||||
winners.set(km.matchNumber, winnerOf(feed));
|
||||
losers.set(km.matchNumber, loserOf(feed));
|
||||
later[km.matchNumber] = {
|
||||
matchNumber: km.matchNumber, stage: km.stage,
|
||||
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
|
||||
};
|
||||
}
|
||||
|
||||
return { r32, later };
|
||||
}
|
||||
131
lib/standings.ts
Normal file
131
lib/standings.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
GROUP_IDS, GroupId, Match, Team,
|
||||
StandingRow, GroupTable, ThirdPlaceRow,
|
||||
} from "./types";
|
||||
|
||||
// Berechnet eine leere Tabellenzeile.
|
||||
function emptyRow(teamId: string): StandingRow {
|
||||
return {
|
||||
teamId, played: 0, won: 0, drawn: 0, lost: 0,
|
||||
goalsFor: 0, goalsAgainst: 0, goalDiff: 0, points: 0, rank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Trägt ein abgeschlossenes Spiel in zwei Tabellenzeilen ein.
|
||||
function applyMatch(rows: Map<string, StandingRow>, m: Match) {
|
||||
if (m.status !== "FINISHED") return;
|
||||
if (m.homeTeamId == null || m.awayTeamId == null) return;
|
||||
if (m.homeScore == null || m.awayScore == null) return;
|
||||
const h = rows.get(m.homeTeamId);
|
||||
const a = rows.get(m.awayTeamId);
|
||||
if (!h || !a) return;
|
||||
h.played++; a.played++;
|
||||
h.goalsFor += m.homeScore; h.goalsAgainst += m.awayScore;
|
||||
a.goalsFor += m.awayScore; a.goalsAgainst += m.homeScore;
|
||||
if (m.homeScore > m.awayScore) { h.won++; a.lost++; h.points += 3; }
|
||||
else if (m.homeScore < m.awayScore) { a.won++; h.lost++; a.points += 3; }
|
||||
else { h.drawn++; a.drawn++; h.points += 1; a.points += 1; }
|
||||
}
|
||||
|
||||
function finalizeRow(r: StandingRow) {
|
||||
r.goalDiff = r.goalsFor - r.goalsAgainst;
|
||||
}
|
||||
|
||||
// Vergleicht zwei Teams nach FIFA-Kriterien innerhalb derselben Gruppe.
|
||||
// Reihenfolge: Punkte, dann Direktvergleich (Punkte/TD/Tore in den Spielen
|
||||
// der punktgleichen Teams), dann Gesamt-TD, Gesamt-Tore. Fair-Play/Ranking
|
||||
// werden hier nicht abgebildet (kein Feed dafür) -> bleibt bei Gleichstand stabil.
|
||||
function compareInGroup(
|
||||
a: StandingRow, b: StandingRow,
|
||||
matches: Match[], tiedIds: Set<string>,
|
||||
): number {
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
|
||||
// Direktvergleich nur unter den punktgleichen Teams
|
||||
if (tiedIds.size >= 2 && tiedIds.has(a.teamId) && tiedIds.has(b.teamId)) {
|
||||
const mini = miniTable(matches, tiedIds);
|
||||
const ma = mini.get(a.teamId);
|
||||
const mb = mini.get(b.teamId);
|
||||
if (ma && mb) {
|
||||
if (mb.points !== ma.points) return mb.points - ma.points;
|
||||
const mdA = ma.goalsFor - ma.goalsAgainst;
|
||||
const mdB = mb.goalsFor - mb.goalsAgainst;
|
||||
if (mdB !== mdA) return mdB - mdA;
|
||||
if (mb.goalsFor !== ma.goalsFor) return mb.goalsFor - ma.goalsFor;
|
||||
}
|
||||
}
|
||||
|
||||
if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff;
|
||||
if (b.goalsFor !== a.goalsFor) return b.goalsFor - a.goalsFor;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Baut eine Minitabelle aus den Spielen, in denen NUR die tiedIds gegeneinander spielten.
|
||||
function miniTable(matches: Match[], tiedIds: Set<string>): Map<string, StandingRow> {
|
||||
const rows = new Map<string, StandingRow>();
|
||||
tiedIds.forEach((id) => rows.set(id, emptyRow(id)));
|
||||
for (const m of matches) {
|
||||
if (m.status !== "FINISHED") continue;
|
||||
if (m.homeTeamId == null || m.awayTeamId == null) continue;
|
||||
if (!tiedIds.has(m.homeTeamId) || !tiedIds.has(m.awayTeamId)) continue;
|
||||
applyMatch(rows, m);
|
||||
}
|
||||
rows.forEach(finalizeRow);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Berechnet alle 12 Gruppentabellen aus Teams + Spielen.
|
||||
export function computeGroupTables(teams: Team[], matches: Match[]): GroupTable[] {
|
||||
const tables: GroupTable[] = [];
|
||||
for (const g of GROUP_IDS) {
|
||||
const groupTeams = teams.filter((t) => t.group === g);
|
||||
const rows = new Map<string, StandingRow>();
|
||||
groupTeams.forEach((t) => rows.set(t.id, emptyRow(t.id)));
|
||||
const groupMatches = matches.filter((m) => m.group === g);
|
||||
for (const m of groupMatches) applyMatch(rows, m);
|
||||
rows.forEach(finalizeRow);
|
||||
|
||||
const arr = [...rows.values()];
|
||||
// Gruppen punktgleicher Teams für den Direktvergleich bestimmen
|
||||
const byPoints = new Map<number, string[]>();
|
||||
arr.forEach((r) => {
|
||||
const list = byPoints.get(r.points) ?? [];
|
||||
list.push(r.teamId);
|
||||
byPoints.set(r.points, list);
|
||||
});
|
||||
|
||||
arr.sort((a, b) => {
|
||||
const tied = byPoints.get(a.points) ?? [];
|
||||
const tiedIds = new Set(a.points === b.points && tied.length >= 2 ? tied : []);
|
||||
return compareInGroup(a, b, groupMatches, tiedIds);
|
||||
});
|
||||
arr.forEach((r, i) => (r.rank = i + 1));
|
||||
tables.push({ group: g, rows: arr });
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
// Rankt die 12 Gruppendritten gruppenübergreifend (ohne Direktvergleich).
|
||||
// Kriterien: Punkte, TD, Tore. Beste 8 -> qualifies = true.
|
||||
export function computeThirdPlaceTable(tables: GroupTable[]): ThirdPlaceRow[] {
|
||||
const thirds: ThirdPlaceRow[] = tables
|
||||
.map((t) => {
|
||||
const r = t.rows.find((x) => x.rank === 3);
|
||||
if (!r) return null;
|
||||
return { ...r, group: t.group, qualifies: false, overallRank: 0 };
|
||||
})
|
||||
.filter((x): x is ThirdPlaceRow => x !== null);
|
||||
|
||||
thirds.sort((a, b) => {
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff;
|
||||
if (b.goalsFor !== a.goalsFor) return b.goalsFor - a.goalsFor;
|
||||
return a.group.localeCompare(b.group); // stabil
|
||||
});
|
||||
|
||||
thirds.forEach((r, i) => {
|
||||
r.overallRank = i + 1;
|
||||
r.qualifies = i < 8;
|
||||
});
|
||||
return thirds;
|
||||
}
|
||||
62
lib/types.ts
Normal file
62
lib/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Datenmodell für die WM-Seite. Bewusst entkoppelt von den Feed-Formaten:
|
||||
// Adapter (siehe lib/feeds.ts) übersetzen football-data.org & Polymarket in diese Typen.
|
||||
|
||||
export type GroupId =
|
||||
| "A" | "B" | "C" | "D" | "E" | "F"
|
||||
| "G" | "H" | "I" | "J" | "K" | "L";
|
||||
|
||||
export const GROUP_IDS: GroupId[] = [
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L",
|
||||
];
|
||||
|
||||
export interface Team {
|
||||
id: string; // stabile ID aus dem Feed
|
||||
name: string; // Anzeigename
|
||||
code: string; // 3-Buchstaben-Code, z.B. GER
|
||||
group: GroupId;
|
||||
}
|
||||
|
||||
export type MatchStatus =
|
||||
| "SCHEDULED" | "LIVE" | "IN_PLAY" | "PAUSED" | "FINISHED";
|
||||
|
||||
export interface Match {
|
||||
id: string;
|
||||
group: GroupId | null; // null = K.o.-Spiel
|
||||
stage: "GROUP" | "R32" | "R16" | "QF" | "SF" | "3RD" | "FINAL";
|
||||
matchNumber: number; // FIFA-Spielnummer (1..104)
|
||||
utcDate: string;
|
||||
status: MatchStatus;
|
||||
minute: number | null;
|
||||
homeTeamId: string | null;
|
||||
awayTeamId: string | null;
|
||||
homeScore: number | null;
|
||||
awayScore: number | null;
|
||||
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
|
||||
prob?: { home: number; draw: number | null; away: number } | null;
|
||||
}
|
||||
|
||||
// Eine berechnete Tabellenzeile innerhalb einer Gruppe.
|
||||
export interface StandingRow {
|
||||
teamId: string;
|
||||
played: number;
|
||||
won: number;
|
||||
drawn: number;
|
||||
lost: number;
|
||||
goalsFor: number;
|
||||
goalsAgainst: number;
|
||||
goalDiff: number;
|
||||
points: number;
|
||||
rank: number; // 1..4 innerhalb der Gruppe
|
||||
}
|
||||
|
||||
export interface GroupTable {
|
||||
group: GroupId;
|
||||
rows: StandingRow[]; // sortiert, rank gesetzt
|
||||
}
|
||||
|
||||
// Eintrag in der gruppenübergreifenden Drittplatzierten-Tabelle.
|
||||
export interface ThirdPlaceRow extends StandingRow {
|
||||
group: GroupId;
|
||||
qualifies: boolean; // true für die besten 8
|
||||
overallRank: number; // 1..12
|
||||
}
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone", // schlankes Docker-Image
|
||||
reactStrictMode: true,
|
||||
};
|
||||
export default nextConfig;
|
||||
943
package-lock.json
generated
Normal file
943
package-lock.json
generated
Normal file
@@ -0,0 +1,943 @@
|
||||
{
|
||||
"name": "wm2026-board",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wm2026-board",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"next": "15.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz",
|
||||
"integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz",
|
||||
"integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz",
|
||||
"integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz",
|
||||
"integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz",
|
||||
"integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz",
|
||||
"integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz",
|
||||
"integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz",
|
||||
"integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz",
|
||||
"integrity": "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
|
||||
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
|
||||
"integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz",
|
||||
"integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz",
|
||||
"integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.1.6",
|
||||
"@swc/counter": "0.1.3",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
},
|
||||
"bin": {
|
||||
"next": "dist/bin/next"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.1.6",
|
||||
"@next/swc-darwin-x64": "15.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "15.1.6",
|
||||
"@next/swc-linux-arm64-musl": "15.1.6",
|
||||
"@next/swc-linux-x64-gnu": "15.1.6",
|
||||
"@next/swc-linux-x64-musl": "15.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "15.1.6",
|
||||
"@next/swc-win32-x64-msvc": "15.1.6",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"babel-plugin-react-compiler": "*",
|
||||
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||
"sass": "^1.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@playwright/test": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
"detect-libc": "^2.0.3",
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.33.5",
|
||||
"@img/sharp-darwin-x64": "0.33.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||
"@img/sharp-linux-arm": "0.33.5",
|
||||
"@img/sharp-linux-arm64": "0.33.5",
|
||||
"@img/sharp-linux-s390x": "0.33.5",
|
||||
"@img/sharp-linux-x64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||
"@img/sharp-wasm32": "0.33.5",
|
||||
"@img/sharp-win32-ia32": "0.33.5",
|
||||
"@img/sharp-win32-x64": "0.33.5"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"client-only": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "wm2026-board",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.7.3",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3"
|
||||
}
|
||||
}
|
||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user