This commit is contained in:
2026-06-22 15:44:54 -05:00
parent f5ab324cca
commit 73d07e7f18
12 changed files with 385 additions and 42 deletions

View File

@@ -3,15 +3,20 @@
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
import Flag from "./Flag";
function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
function Side({
s, prob, teams,
}: { s: ResolvedSide; prob?: number | null; teams: Team[] }) {
const team = s.teamId ? teams.find((t) => t.id === s.teamId) : undefined;
return (
<div className={`side ${s.isWinner ? "win" : ""}`}>
<div className={`side ${s.isWinner ? "win" : ""} ${s.provisional ? "prov" : ""}`}>
<span className="nm">
{s.teamId ? (
<>
<Flag team={team} size={18} />
<span>{s.label}</span>
{s.code && <span className="c">{s.code}</span>}
{s.provisional && <span className="prov-mark" title="vorläufig Gruppe/Zuordnung noch nicht fix"></span>}
</>
) : (
<span className="lbl">{s.label}</span>
@@ -25,12 +30,12 @@ function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
);
}
function Tie({ tie, isFinal }: { tie: ResolvedTie; isFinal?: boolean }) {
function Tie({ tie, teams, isFinal }: { tie: ResolvedTie; teams: Team[]; 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} />
<span className="tie-num">Spiel {tie.matchNumber}</span>
<Side s={tie.home} prob={tie.prob?.home} teams={teams} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} />
</div>
);
}
@@ -41,7 +46,9 @@ export default function Bracket({
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
}) {
const { r32, later } = resolveBracket(matches, teams, tables, thirds, assignment);
const { r32, later } = resolveBracket(
matches, teams, tables, thirds, assignment, annexResolved,
);
const pick = (nums: number[]) => nums.map((n) => later[n]).filter(Boolean);
const r16 = pick([89, 90, 91, 92, 93, 94, 95, 96]);
@@ -66,35 +73,35 @@ export default function Bracket({
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-matches">
{fin && <Tie tie={fin} isFinal />}
{fin && <Tie tie={fin} teams={teams} isFinal />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} />
<Tie tie={third} teams={teams} />
</div>
)}
</div>
@@ -105,6 +112,8 @@ export default function Bracket({
<div className="legend">
<span><i style={{ background: "var(--turf)" }} />Sieger / weiter</span>
<span><i style={{ background: "var(--gold)" }} />Finale</span>
<span><i className="leg-fix" />fix qualifiziert</span>
<span><i className="leg-prov" />vorläufig (, nach aktueller Tabelle)</span>
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
</div>

109
app/components/Fixtures.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client";
import { GROUP_IDS, GroupId, Match, Team } from "@/lib/types";
import Flag from "./Flag";
function teamById(teams: Team[], id: string | null) {
return id ? teams.find((t) => t.id === id) : undefined;
}
function fmtDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString("de-DE", {
weekday: "short", day: "2-digit", month: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
function statusText(m: Match): { text: string; live: boolean } {
switch (m.status) {
case "LIVE":
case "IN_PLAY":
return { text: m.minute != null ? `${m.minute}'` : "läuft", live: true };
case "PAUSED":
return { text: "Halbzeit", live: true };
case "FINISHED":
return { text: "Beendet", live: false };
default:
return { text: fmtDate(m.utcDate), live: false };
}
}
function ScoreOrTime({ m }: { m: Match }) {
const hasScore = m.homeScore != null && m.awayScore != null;
const st = statusText(m);
if (hasScore) {
return (
<span className={`fx-score ${st.live ? "score-live" : ""}`}>
{m.homeScore} : {m.awayScore}
</span>
);
}
return <span className="fx-time">{fmtDate(m.utcDate)}</span>;
}
export default function Fixtures({
group, teams, matches, onSelectGroup,
}: {
group: GroupId; teams: Team[]; matches: Match[];
onSelectGroup: (g: GroupId) => void;
}) {
const list = matches
.filter((m) => m.group === group)
.sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate));
return (
<div>
<div className="grp-switch">
{GROUP_IDS.map((g) => (
<button
key={g}
className={`grp-chip ${g === group ? "active" : ""}`}
onClick={() => onSelectGroup(g)}
>
{g}
</button>
))}
</div>
{list.length === 0 ? (
<div className="notice">Für Gruppe {group} liegen noch keine Spiele im Feed vor.</div>
) : (
<div className="fixtures">
{list.map((m) => {
const home = teamById(teams, m.homeTeamId);
const away = teamById(teams, m.awayTeamId);
const st = statusText(m);
const homeWin = m.homeScore != null && m.awayScore != null && m.homeScore > m.awayScore;
const awayWin = m.homeScore != null && m.awayScore != null && m.awayScore > m.homeScore;
return (
<div className={`fx ${st.live ? "fx-live" : ""}`} key={m.id}>
<div className="fx-status">
{st.live && <span className="dot live" />}
<span>{st.text}</span>
</div>
<div className="fx-teams">
<div className={`fx-team ${homeWin ? "win" : ""}`}>
<Flag team={home} size={22} />
<span className="fx-name">{home?.name ?? "—"}</span>
</div>
<ScoreOrTime m={m} />
<div className={`fx-team away ${awayWin ? "win" : ""}`}>
<span className="fx-name">{away?.name ?? "—"}</span>
<Flag team={away} size={22} />
</div>
</div>
<div className="fx-meta">
{m.venue && <span className="fx-venue">📍 {m.venue}</span>}
{m.attendance != null && (
<span className="fx-att">👥 {m.attendance.toLocaleString("de-DE")}</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

29
app/components/Flag.tsx Normal file
View File

@@ -0,0 +1,29 @@
"use client";
import { Team } from "@/lib/types";
// Zeigt die Flagge/das Wappen eines Teams. Fällt auf ein neutrales Rund
// zurück, wenn keine crest-URL vorliegt. SVG-Crests (football-data) skalieren
// sauber; das Rendern als rundes Bild gibt den Flaggen-Look aus dem Screenshot.
export default function Flag({
team, size = 22,
}: { team?: Team; size?: number }) {
const style = { width: size, height: size } as const;
if (team?.crest) {
return (
<img
src={team.crest}
alt={team.code || team.name}
className="flag"
style={style}
loading="lazy"
/>
);
}
// Fallback: Kreis mit Ländercode
return (
<span className="flag flag-fallback" style={style} aria-hidden>
{team?.code?.slice(0, 2) || "··"}
</span>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { GroupTable, Match, Team } from "@/lib/types";
import { GroupId, GroupTable, Match, Team } from "@/lib/types";
import Flag from "./Flag";
function teamById(teams: Team[], id: string) {
return teams.find((t) => t.id === id);
@@ -13,24 +14,31 @@ function liveMatchFor(group: string, matches: Match[]) {
}
export default function Groups({
tables, teams, matches,
}: { tables: GroupTable[]; teams: Team[]; matches: Match[] }) {
tables, teams, matches, onOpenGroup,
}: {
tables: GroupTable[]; teams: Team[]; matches: Match[];
onOpenGroup: (g: GroupId) => void;
}) {
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">
<button
className="group-head group-head-btn"
onClick={() => onOpenGroup(t.group)}
title={`Spiele der Gruppe ${t.group} ansehen`}
>
<span className="group-name">Gruppe {t.group}</span>
<span className="group-tag">
{live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`}
{live ? "● LIVE" : "Spiele ansehen →"}
</span>
</div>
</button>
<table className="standings">
<thead>
<tr>
<th className="team">Team</th>
<th className="team">Mannschaft</th>
<th>Sp</th><th>S</th><th>U</th><th>N</th>
<th>Tore</th><th>±</th><th>Pkt</th>
</tr>
@@ -43,8 +51,8 @@ export default function Groups({
<tr key={r.teamId}>
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<Flag team={team} size={20} />
<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>

View File

@@ -245,3 +245,94 @@ table.standings { width: 100%; border-collapse: collapse; }
.tab { padding: 14px 12px; font-size: 12px; }
.group-grid { grid-template-columns: 1fr; }
}
/* ============ Erweiterungen v2 ============ */
/* Flaggen / Wappen */
.flag {
border-radius: 50%; object-fit: cover; flex: none;
background: var(--bg-raised); border: 1px solid var(--line-soft);
}
.flag-fallback {
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 9px; color: var(--ink-faint);
text-transform: uppercase;
}
/* Gruppen-Header als Button */
.group-head-btn {
width: 100%; cursor: pointer; text-align: left;
font: inherit; color: inherit;
transition: background .15s;
}
.group-head-btn:hover { background: var(--bg-card); }
.group-head-btn:hover .group-tag { color: var(--turf); }
.group-head-btn:focus-visible { outline: 2px solid var(--turf); outline-offset: -2px; }
/* Gruppen-Wechsler im Spiele-Tab */
.grp-switch { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; }
.grp-chip {
width: 38px; height: 38px; border-radius: 8px;
border: 1px solid var(--line); background: var(--bg-card);
color: var(--ink-dim); font-family: var(--font-display); font-weight: 700;
font-size: 14px; cursor: pointer; transition: all .15s;
}
.grp-chip:hover { border-color: var(--turf); color: var(--ink); }
.grp-chip.active { background: var(--turf); color: var(--bg); border-color: var(--turf); }
/* Spiele-Karten */
.fixtures { display: flex; flex-direction: column; gap: 10px; }
.fx {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 16px;
display: grid; grid-template-columns: 120px 1fr auto; gap: 14px; align-items: center;
}
.fx-live { border-color: rgba(255,61,127,0.4); }
.fx-status {
display: flex; align-items: center; gap: 7px;
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
}
.fx-teams {
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 14px;
}
.fx-team { display: flex; align-items: center; gap: 10px; min-width: 0; }
.fx-team.away { justify-content: flex-end; }
.fx-team .fx-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fx-team.win .fx-name { color: var(--turf); }
.fx-score { font-family: var(--font-mono); font-weight: 700; font-size: 18px; color: var(--floodlight); white-space: nowrap; }
.fx-score.score-live { color: var(--live); }
.fx-time { font-family: var(--font-mono); font-size: 13px; color: var(--ink-faint); white-space: nowrap; }
.fx-meta {
display: flex; flex-direction: column; gap: 3px; align-items: flex-end;
font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); white-space: nowrap;
}
@media (max-width: 720px) {
.fx { grid-template-columns: 1fr; gap: 8px; }
.fx-meta { flex-direction: row; gap: 14px; align-items: center; }
.fx-status { order: -1; }
}
/* Bracket: Label-Fix oben (Match-Nummer ragt über den Rand) */
.round-matches { padding-top: 8px; }
.tie-num {
top: -8px; left: 10px; white-space: nowrap;
}
/* Bracket: fix vs. vorläufig */
.side.prov .nm { color: var(--ink-dim); }
.side.prov .nm span:not(.prov-mark) { font-style: italic; }
.prov-mark {
color: #f0a23a; font-family: var(--font-mono); font-weight: 700;
font-size: 12px; margin-left: 2px;
}
/* fix qualifizierte (nicht vorläufige) Teams: voller Kontrast + linker Marker */
.side:not(.prov) .nm > span:not(.c):not(.prob):not(.prov-mark) { color: var(--ink); font-weight: 600; }
.side:not(.prov):not(.win) { box-shadow: inset 2px 0 0 var(--turf-deep); }
/* Legende-Marker */
.legend i.leg-fix { background: var(--turf-deep); }
.legend i.leg-prov {
background: repeating-linear-gradient(45deg, #f0a23a, #f0a23a 3px, transparent 3px, transparent 6px);
border: 1px solid #f0a23a;
}

View File

@@ -1,11 +1,12 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { GroupId, 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";
import Fixtures from "./components/Fixtures";
interface ApiData {
updatedAt: string;
@@ -17,12 +18,14 @@ interface ApiData {
annexResolved: boolean;
}
type Tab = "groups" | "thirds" | "bracket";
type Tab = "groups" | "fixtures" | "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");
// Zuletzt gewählte Gruppe für den Spiele-Tab. null = noch keine gewählt.
const [fixturesGroup, setFixturesGroup] = useState<GroupId | null>(null);
const load = useCallback(async () => {
try {
@@ -44,6 +47,12 @@ export default function Home() {
return () => clearInterval(id);
}, [load]);
// Klick auf einen Gruppen-Header: Gruppe merken und zum Spiele-Tab wechseln.
const openGroupFixtures = useCallback((g: GroupId) => {
setFixturesGroup(g);
setTab("fixtures");
}, []);
const anyLive = data?.matches.some(
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
);
@@ -72,6 +81,12 @@ export default function Home() {
<button className={`tab ${tab === "groups" ? "active" : ""}`} onClick={() => setTab("groups")}>
Gruppen
</button>
<button
className={`tab ${tab === "fixtures" ? "active" : ""}`}
onClick={() => { if (!fixturesGroup) setFixturesGroup("A"); setTab("fixtures"); }}
>
{fixturesGroup ? `Spiele Gruppe ${fixturesGroup}` : "Spiele"}
</button>
<button className={`tab ${tab === "thirds" ? "active" : ""}`} onClick={() => setTab("thirds")}>
Drittplatzierte
</button>
@@ -96,7 +111,16 @@ export default function Home() {
)}
{data && tab === "groups" && (
<Groups tables={data.groupTables} teams={data.teams} matches={data.matches} />
<Groups
tables={data.groupTables} teams={data.teams} matches={data.matches}
onOpenGroup={openGroupFixtures}
/>
)}
{data && tab === "fixtures" && fixturesGroup && (
<Fixtures
group={fixturesGroup} teams={data.teams} matches={data.matches}
onSelectGroup={setFixturesGroup}
/>
)}
{data && tab === "thirds" && (
<ThirdPlace rows={data.thirdTable} teams={data.teams} />