Matchcenter: Spielplan, Ergebnisse & Tabellen (eigene Seite statt BFV-Widget)
Neue Unterseite /matchcenter ersetzt die alten BFV-Widget-Embeds (verstoßen
gegen CSP) durch eine self-hostete Lösung: Cron-Scraper holt die Daten aus der
öffentlichen BFV-Widget-JSON-API, schreibt data/matchcenter.json und lädt die
Vereinswappen lokal — Besucher kontaktieren nur unsere Domain.
- bin/matchcenter-sync.php: CLI/Cron-Scraper (Muster wie instagram-sync), JSON-API
team/{id}/matches + competition/{compoundId}/table, Wappen via getLogo lokal,
atomarer Write, Fail-safe. Auto-Saison: Tabellen-compoundId wird aus der
Matches-API abgeleitet (team_id ist saison-stabil). Per-Team-Cache-Fallback bei
Fetch-Fehlern, damit Sektionen nie leeren.
- Seite app/pages/matchcenter.php + Route + Nav-Eintrag (unter Fußball).
- Komponenten: match-slider (nächste Spiele), match-card, match-row, league-table
(mit Auf-/Abstiegszonen), matchcenter-section (kicker-Layout: Tabelle 2/3 +
Spiele 1/3), matchcenter-empty, crest (weißes Chip + Initialen-Fallback).
- carousel.js: banner.js zum generischen [data-carousel]-Antrieb verallgemeinert
(Banner- und Match-Slider teilen ihn); crest.js: Logo-Fallback im Browser.
- helpers.php: sportsevent_nodes() für SportsEvent-JSON-LD.
- Icons calendar-event; config.example + .gitignore + CLAUDE.md ergänzt.
- Responsive geprüft (kein Seiten-Overflow 360–1280, Tabelle scrollt in der Karte).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192vTfGmGpoWeyUse4TuQFj
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -15,6 +15,11 @@
|
|||||||
/public/assets/img/instagram/*
|
/public/assets/img/instagram/*
|
||||||
!/public/assets/img/instagram/.gitkeep
|
!/public/assets/img/instagram/.gitkeep
|
||||||
|
|
||||||
|
# Maschinenverwaltet (bin/matchcenter-sync.php)
|
||||||
|
/data/matchcenter.json
|
||||||
|
/public/assets/img/crests/*
|
||||||
|
!/public/assets/img/crests/.gitkeep
|
||||||
|
|
||||||
# Referenz-Material der alten Seite — NIE committen
|
# Referenz-Material der alten Seite — NIE committen
|
||||||
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)
|
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)
|
||||||
/.context/
|
/.context/
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -41,6 +41,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
|
|||||||
| Banner-Slider (Startseite) | `data/banners.json` | Slides in `pages/home.php` oder der Komponente |
|
| Banner-Slider (Startseite) | `data/banners.json` | Slides in `pages/home.php` oder der Komponente |
|
||||||
| Partner/Sponsoren | `data/partners.json` | — |
|
| Partner/Sponsoren | `data/partners.json` | — |
|
||||||
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
|
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
|
||||||
|
| Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) |
|
||||||
| Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
|
| Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
|
||||||
|
|
||||||
Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pages/*.php`) im `$meta`-Array.
|
Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pages/*.php`) im `$meta`-Array.
|
||||||
@@ -125,3 +126,16 @@ FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt und Markup nie a
|
|||||||
`data/instagram.json` atomar und lädt Bilder nach `public/assets/img/instagram/`. Bei Fehlern bleibt
|
`data/instagram.json` atomar und lädt Bilder nach `public/assets/img/instagram/`. Bei Fehlern bleibt
|
||||||
der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente
|
der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente
|
||||||
eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.
|
eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.
|
||||||
|
|
||||||
|
## Matchcenter (BFV-Widget-Ersatz)
|
||||||
|
|
||||||
|
`bin/matchcenter-sync.php` (nur CLI) holt Spielplan, Ergebnisse und Tabelle der drei Aktivenmannschaften
|
||||||
|
aus der öffentlichen **BFV-Widget-JSON-API** (`widget-prod.bfv.de`, reverse-engineert aus dem alten
|
||||||
|
`<BFVWidget.HTML5…>`-Embed — kein HTML-Scraping), lädt die Vereinswappen lokal nach
|
||||||
|
`public/assets/img/crests/` und schreibt `data/matchcenter.json` atomar. Endpunkte: `…/api/service/widget/v1/team/{teamId}/matches`,
|
||||||
|
`…/api/service/widget/v1/competition/{compoundId}/table`, Wappen `app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId}`.
|
||||||
|
Browser-User-Agent + `Referer` nötig (sonst HTTP 418). Eigene-Verein-Erkennung exakt über `permanentId`.
|
||||||
|
Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert
|
||||||
|
`matchcenter-empty`). Cron 2×/Tag + am Wochenende ½-stündlich nachmittags (siehe `config.example.php`).
|
||||||
|
Der „Nächste Spiele"-Slider teilt sich den Antrieb `assets/js/carousel.js` (generisch, `[data-carousel]`)
|
||||||
|
mit dem Banner-Slider. Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ declare(strict_types=1);
|
|||||||
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
|
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
|
||||||
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot
|
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot
|
||||||
* der Buttons für diese Slide, z. B. Sponsor-CI).
|
* der Buttons für diese Slide, z. B. Sponsor-CI).
|
||||||
* Auto-Rotation + Fortschritts-Indikatoren übernimmt assets/js/banner.js
|
* Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
|
||||||
* (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
|
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
|
||||||
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
|
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
|
||||||
*/
|
*/
|
||||||
$bannerData = json_load('banners');
|
$bannerData = json_load('banners');
|
||||||
@@ -24,7 +24,7 @@ $heading = $bannerData['heading'] ?? null;
|
|||||||
$subline = $bannerData['subline'] ?? null;
|
$subline = $bannerData['subline'] ?? null;
|
||||||
$total = count($banners);
|
$total = count($banners);
|
||||||
?>
|
?>
|
||||||
<section class="section ad-banner" id="anzeigen" aria-roledescription="Karussell" aria-label="Anzeigen unserer Sponsoren und Partner" data-banner>
|
<section class="section ad-banner" id="anzeigen" aria-roledescription="Karussell" aria-label="Anzeigen unserer Sponsoren und Partner" data-carousel>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<?php if ($heading !== null): ?>
|
<?php if ($heading !== null): ?>
|
||||||
<h2><?= e($heading) ?></h2>
|
<h2><?= e($heading) ?></h2>
|
||||||
@@ -34,7 +34,7 @@ $total = count($banners);
|
|||||||
<?php if ($subline !== null): ?>
|
<?php if ($subline !== null): ?>
|
||||||
<p class="text-muted ad-banner__subline"><?= e($subline) ?></p>
|
<p class="text-muted ad-banner__subline"><?= e($subline) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<ul class="ad-banner__track" id="ad-banner-track" aria-live="off">
|
<ul class="ad-banner__track" id="ad-banner-track" data-carousel-track aria-live="off">
|
||||||
<?php foreach ($banners as $i => $banner): ?>
|
<?php foreach ($banners as $i => $banner): ?>
|
||||||
<?php
|
<?php
|
||||||
$bg = $banner['background'] ?? null;
|
$bg = $banner['background'] ?? null;
|
||||||
@@ -76,7 +76,7 @@ $total = count($banners);
|
|||||||
. '</a>';
|
. '</a>';
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<li class="<?= e($classes) ?>"<?= $style ?> data-banner-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
<li class="<?= e($classes) ?>"<?= $style ?> data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
||||||
<?php if (!empty($banner['logo'])): ?>
|
<?php if (!empty($banner['logo'])): ?>
|
||||||
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
|
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
|
||||||
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>" loading="lazy" decoding="async">
|
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>" loading="lazy" decoding="async">
|
||||||
@@ -99,18 +99,18 @@ $total = count($banners);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
<?php if ($total > 1): ?>
|
<?php if ($total > 1): ?>
|
||||||
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); banner.js blendet sie ein. */ ?>
|
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
|
||||||
<div class="ad-banner__controls" data-banner-controls hidden>
|
<div class="ad-banner__controls" data-carousel-controls hidden>
|
||||||
<?php /* Explizite Pause/Play-Taste — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
|
<?php /* Explizite Pause/Play-Taste — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
|
||||||
<button class="ad-banner__toggle" type="button" data-banner-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
|
<button class="ad-banner__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
|
||||||
<?= icon('pause-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--pause') ?>
|
<?= icon('pause-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--pause') ?>
|
||||||
<?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?>
|
<?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?>
|
||||||
<span class="visually-hidden" data-banner-toggle-label>Automatischen Wechsel pausieren</span>
|
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen">
|
<div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen">
|
||||||
<?php foreach ($banners as $i => $banner): ?>
|
<?php foreach ($banners as $i => $banner): ?>
|
||||||
<button class="ad-banner__dot" type="button" data-banner-dot aria-controls="ad-banner-track" aria-label="Anzeige <?= ($i + 1) ?> von <?= $total ?>: <?= e($banner['company'] ?? $banner['title']) ?>">
|
<button class="ad-banner__dot" type="button" data-carousel-dot aria-controls="ad-banner-track" aria-label="Anzeige <?= ($i + 1) ?> von <?= $total ?>: <?= e($banner['company'] ?? $banner['title']) ?>">
|
||||||
<span class="ad-banner__progress" aria-hidden="true"></span>
|
<span class="ad-banner__progress" data-carousel-progress aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
app/components/crest.php
Normal file
32
app/components/crest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vereinswappen. Props: $src (lokaler Pfad relativ zu assets/, oder null/leer),
|
||||||
|
* $name (Vereinsname — für Initialen-Fallback). Das Wappen ist dekorativ (alt=""),
|
||||||
|
* weil der Vereinsname direkt daneben als Text steht. Fehlt das Bild, rendert ein
|
||||||
|
* Initialen-Kreis statt eines kaputten <img>; lädt das Bild im Browser nicht
|
||||||
|
* (z. B. 404), ersetzt assets/js/crest.js es ebenfalls durch die Initialen
|
||||||
|
* (data-club liefert den Namen). Das weiße Chip im CSS hält auch helle/weiße
|
||||||
|
* Logos auf dunklem Grund sichtbar.
|
||||||
|
*/
|
||||||
|
$src = $src ?? null;
|
||||||
|
$name = (string) ($name ?? '');
|
||||||
|
|
||||||
|
$initials = '';
|
||||||
|
foreach (preg_split('/[\s.-]+/', trim($name)) ?: [] as $word) {
|
||||||
|
if ($word !== '') {
|
||||||
|
$initials .= mb_substr($word, 0, 1);
|
||||||
|
}
|
||||||
|
if (mb_strlen($initials) >= 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$initials = mb_strtoupper($initials !== '' ? $initials : '?');
|
||||||
|
?>
|
||||||
|
<?php if (!empty($src)): ?>
|
||||||
|
<img class="crest" src="<?= e(asset($src)) ?>" alt="" width="40" height="40" loading="lazy" decoding="async" data-club="<?= e($name) ?>">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="crest crest--initials" aria-hidden="true"><?= e($initials) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
82
app/components/league-table.php
Normal file
82
app/components/league-table.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liga-Tabelle. Props: $table (data/matchcenter.json → teams[].table[]),
|
||||||
|
* $caption (Beschriftung für Screenreader/Region, z. B. "Tabelle Kreisklasse").
|
||||||
|
* Die eigene Vereinszeile wird hervorgehoben; Auf-/Abstiegszonen über farbige
|
||||||
|
* Randmarkierungen (Legende darunter). Horizontal scrollbar auf schmalen Screens
|
||||||
|
* (fokussierbare Region, WCAG). Spaltenköpfe in Abel (Coolvetica wäre zu klein).
|
||||||
|
*/
|
||||||
|
$table = $table ?? [];
|
||||||
|
$caption = (string) ($caption ?? 'Tabelle');
|
||||||
|
if ($table === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$zones = array_column($table, 'zone');
|
||||||
|
$hasPromo = in_array('promotion', $zones, true);
|
||||||
|
$hasPromoPlayoff = in_array('promotion-playoff', $zones, true);
|
||||||
|
$hasRelegPlayoff = in_array('relegation-playoff', $zones, true);
|
||||||
|
$hasReleg = in_array('relegation', $zones, true);
|
||||||
|
?>
|
||||||
|
<div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0">
|
||||||
|
<table class="league-table">
|
||||||
|
<caption class="visually-hidden"><?= e($caption) ?></caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="league-table__rank">#</th>
|
||||||
|
<th scope="col" class="league-table__club">Verein</th>
|
||||||
|
<th scope="col"><abbr title="Spiele">Sp</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Siege">S</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Unentschieden">U</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Niederlagen">N</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Tordifferenz">Diff</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Punkte">Pkt</abbr></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($table as $row): ?>
|
||||||
|
<?php
|
||||||
|
$trClass = [];
|
||||||
|
if (!empty($row['is_own_club'])) {
|
||||||
|
$trClass[] = 'is-own';
|
||||||
|
}
|
||||||
|
if (!empty($row['zone'])) {
|
||||||
|
$trClass[] = 'zone-' . e($row['zone']);
|
||||||
|
}
|
||||||
|
$diff = (int) ($row['goal_diff'] ?? 0);
|
||||||
|
?>
|
||||||
|
<tr<?= $trClass !== [] ? ' class="' . implode(' ', $trClass) . '"' : '' ?>>
|
||||||
|
<td class="league-table__rank"><?= (int) ($row['rank'] ?? 0) ?></td>
|
||||||
|
<th scope="row" class="league-table__club">
|
||||||
|
<?php component('crest', ['src' => $row['crest'] ?? null, 'name' => $row['club'] ?? '']); ?>
|
||||||
|
<span class="league-table__name" title="<?= e($row['club'] ?? '') ?>"><?= e($row['club'] ?? '') ?></span>
|
||||||
|
</th>
|
||||||
|
<td><?= (int) ($row['played'] ?? 0) ?></td>
|
||||||
|
<td><?= (int) ($row['won'] ?? 0) ?></td>
|
||||||
|
<td><?= (int) ($row['drawn'] ?? 0) ?></td>
|
||||||
|
<td><?= (int) ($row['lost'] ?? 0) ?></td>
|
||||||
|
<td><?= ($diff > 0 ? '+' : '') . $diff ?></td>
|
||||||
|
<td class="league-table__pts"><?= (int) ($row['points'] ?? 0) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php if ($hasPromo || $hasPromoPlayoff || $hasRelegPlayoff || $hasReleg): ?>
|
||||||
|
<ul class="league-table__legend">
|
||||||
|
<?php if ($hasPromo): ?>
|
||||||
|
<li class="league-table__legend-item zone-promotion">Aufstieg</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($hasPromoPlayoff): ?>
|
||||||
|
<li class="league-table__legend-item zone-promotion-playoff">Aufstiegsrelegation</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($hasRelegPlayoff): ?>
|
||||||
|
<li class="league-table__legend-item zone-relegation-playoff">Abstiegsrelegation</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($hasReleg): ?>
|
||||||
|
<li class="league-table__legend-item zone-relegation">Abstieg</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
47
app/components/match-card.php
Normal file
47
app/components/match-card.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelnes Spiel (Begegnung) als Karte. Props:
|
||||||
|
* $match Eintrag aus data/matchcenter.json (next_matches[]/last_results[]/upcoming[])
|
||||||
|
* $variant '' (Default) | 'hero' (groß, für den Slider)
|
||||||
|
* Aufbau (kicker-Stil): Datum oben mittig, darunter Heim | Ergebnis/Anstoß | Gast,
|
||||||
|
* die Zeit mittig unter dem Ergebnis. Die Heimmannschaft steht immer links — daher
|
||||||
|
* kein zusätzlicher Heim/Auswärts-Hinweis. Die eigene Mannschaft wird hervorgehoben.
|
||||||
|
*/
|
||||||
|
$variant = $variant ?? '';
|
||||||
|
$m = $match;
|
||||||
|
$hasResult = isset($m['goals_home'], $m['goals_away']);
|
||||||
|
$isHome = !empty($m['is_home']);
|
||||||
|
|
||||||
|
$classes = 'match-card';
|
||||||
|
if ($variant !== '') {
|
||||||
|
$classes .= ' match-card--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<article class="<?= $classes ?>">
|
||||||
|
<?php if (!empty($m['date_display'])): ?>
|
||||||
|
<p class="match-card__date"><?= icon('calendar-event', 'match-card__date-icon') ?><?= e($m['date_display']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="match-card__fixture">
|
||||||
|
<div class="match-card__side<?= $isHome ? ' is-own' : '' ?>">
|
||||||
|
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
|
||||||
|
<span class="match-card__club"><?= e($m['home'] ?? '') ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="match-card__center">
|
||||||
|
<?php if ($hasResult): ?>
|
||||||
|
<span class="match-card__score"><?= (int) $m['goals_home'] ?><span class="match-card__colon">:</span><?= (int) $m['goals_away'] ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="match-card__vs" aria-label="gegen">:</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($m['time_display'])): ?>
|
||||||
|
<span class="match-card__time"><?= e($m['time_display']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="match-card__side<?= !$isHome ? ' is-own' : '' ?>">
|
||||||
|
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
|
||||||
|
<span class="match-card__club"><?= e($m['away'] ?? '') ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
38
app/components/match-row.php
Normal file
38
app/components/match-row.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakte Spielzeile für die Matchcenter-Seitenspalte (kicker-Stil): Datum oben,
|
||||||
|
* darunter Heim über Gast (Wappen + Name, lange Namen werden mit … gekürzt), rechts
|
||||||
|
* das Ergebnis bzw. — bei anstehenden Spielen — die Anstoßzeit. Heim steht immer
|
||||||
|
* oben/links. Props: $match (Eintrag aus data/matchcenter.json).
|
||||||
|
*/
|
||||||
|
$m = $match;
|
||||||
|
$hasResult = isset($m['goals_home'], $m['goals_away']);
|
||||||
|
$isHome = !empty($m['is_home']);
|
||||||
|
?>
|
||||||
|
<li class="match-row">
|
||||||
|
<?php if (!empty($m['date_display'])): ?>
|
||||||
|
<span class="match-row__date"><?= e($m['date_display']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="match-row__teams">
|
||||||
|
<span class="match-row__team<?= $isHome ? ' is-own' : '' ?>">
|
||||||
|
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
|
||||||
|
<span class="match-row__name" title="<?= e($m['home'] ?? '') ?>"><?= e($m['home'] ?? '') ?></span>
|
||||||
|
</span>
|
||||||
|
<span class="match-row__team<?= !$isHome ? ' is-own' : '' ?>">
|
||||||
|
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
|
||||||
|
<span class="match-row__name" title="<?= e($m['away'] ?? '') ?>"><?= e($m['away'] ?? '') ?></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="match-row__outcome">
|
||||||
|
<?php if ($hasResult): ?>
|
||||||
|
<span class="match-row__score"><?= (int) $m['goals_home'] ?>:<?= (int) $m['goals_away'] ?></span>
|
||||||
|
<?php elseif (!empty($m['time_display'])): ?>
|
||||||
|
<span class="match-row__time"><?= e($m['time_display']) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="match-row__time" aria-hidden="true">–</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
45
app/components/match-slider.php
Normal file
45
app/components/match-slider.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Nächste Spiele"-Slider (teamübergreifend). Props: $matches (data/matchcenter.json
|
||||||
|
* → upcoming[]). Spiegelt die Mechanik des banner-slider (Crossfade, Pause/Play,
|
||||||
|
* prefers-reduced-motion) über den generischen Antrieb assets/js/carousel.js.
|
||||||
|
* Ohne JS ist nur die erste Folie sichtbar. Leer → nichts rendern (Off-Season).
|
||||||
|
*/
|
||||||
|
$matches = $matches ?? [];
|
||||||
|
if ($matches === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$total = count($matches);
|
||||||
|
?>
|
||||||
|
<section class="section match-slider" id="naechste-spiele" aria-roledescription="Karussell" aria-label="Nächste Spiele unserer Mannschaften" data-carousel>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Nächste Spiele</h2>
|
||||||
|
<ul class="match-slider__track" id="match-slider-track" data-carousel-track aria-live="off">
|
||||||
|
<?php foreach ($matches as $i => $m): ?>
|
||||||
|
<li class="match-slider__slide" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
||||||
|
<p class="match-slider__team"><?= e($m['team_name'] ?? '') ?></p>
|
||||||
|
<?php component('match-card', ['match' => $m, 'variant' => 'hero']); ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php if ($total > 1): ?>
|
||||||
|
<div class="match-slider__controls" data-carousel-controls hidden>
|
||||||
|
<button class="match-slider__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="match-slider-track" hidden>
|
||||||
|
<?= icon('pause-fill', 'match-slider__toggle-icon match-slider__toggle-icon--pause') ?>
|
||||||
|
<?= icon('play-fill', 'match-slider__toggle-icon match-slider__toggle-icon--play') ?>
|
||||||
|
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
|
||||||
|
</button>
|
||||||
|
<div class="match-slider__dots" role="group" aria-label="Spiel wählen">
|
||||||
|
<?php foreach ($matches as $i => $m): ?>
|
||||||
|
<button class="match-slider__dot" type="button" data-carousel-dot aria-label="Spiel <?= ($i + 1) ?> von <?= $total ?>: <?= e(($m['home'] ?? '') . ' gegen ' . ($m['away'] ?? '')) ?>">
|
||||||
|
<span class="match-slider__progress" data-carousel-progress aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
23
app/components/matchcenter-empty.php
Normal file
23
app/components/matchcenter-empty.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback, wenn data/matchcenter.json (noch) fehlt oder leer ist — z. B. vor dem
|
||||||
|
* ersten erfolgreichen Cron-Lauf. Bricht nie, verweist auf die Mannschaftsseiten.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="matchcenter-empty">
|
||||||
|
<?= icon('info-circle', 'matchcenter-empty__icon') ?>
|
||||||
|
<h2>Spieldaten in Kürze verfügbar</h2>
|
||||||
|
<p>Die aktuellen Spiele, Ergebnisse und Tabellen werden gerade aktualisiert. Schau gleich noch einmal vorbei – oder besuche direkt die Seiten unserer Mannschaften.</p>
|
||||||
|
<div class="matchcenter-empty__actions">
|
||||||
|
<a class="btn" href="<?= e(url('fussball/erste-mannschaft')) ?>">1. Mannschaft</a>
|
||||||
|
<a class="btn btn--outline" href="<?= e(url('fussball/zweite-mannschaft')) ?>">2. Mannschaft</a>
|
||||||
|
<a class="btn btn--outline" href="<?= e(url('fussball/damen')) ?>">Damen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
60
app/components/matchcenter-section.php
Normal file
60
app/components/matchcenter-section.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matchcenter-Sektion einer Mannschaft (kicker-Stil): Kopf (Name + Liga + Link zur
|
||||||
|
* Mannschaftsseite), darunter zweispaltig — Tabelle 2/3 links, anstehende Spiele +
|
||||||
|
* letzte Ergebnisse als kompakte Liste 1/3 rechts. Auf schmalen Screens gestapelt.
|
||||||
|
* Props: $team (data/matchcenter.json → teams[]). Leere Blöcke verstecken sich selbst.
|
||||||
|
*/
|
||||||
|
$team = $team ?? [];
|
||||||
|
$next = $team['next_matches'] ?? [];
|
||||||
|
$results = $team['last_results'] ?? [];
|
||||||
|
$table = $team['table'] ?? [];
|
||||||
|
$league = $team['league'] ?? null;
|
||||||
|
$key = preg_replace('/[^a-z0-9-]+/', '', strtolower((string) ($team['key'] ?? '')));
|
||||||
|
?>
|
||||||
|
<section class="section matchcenter-team" id="team-<?= e($key) ?>">
|
||||||
|
<div class="container">
|
||||||
|
<div class="matchcenter-team__head">
|
||||||
|
<div class="matchcenter-team__heading">
|
||||||
|
<h2><?= e($team['name'] ?? '') ?></h2>
|
||||||
|
<?php if ($league): ?>
|
||||||
|
<p class="matchcenter-team__league"><?= icon('trophy', 'matchcenter-team__league-icon') ?><?= e($league) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($team['slug'])): ?>
|
||||||
|
<a class="btn btn--outline" href="<?= e(url($team['slug'])) ?>">Zur Mannschaft</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="matchcenter-team__grid">
|
||||||
|
<?php if ($table !== []): ?>
|
||||||
|
<div class="matchcenter-team__main">
|
||||||
|
<?php /* Sichtbare Überschrift bewusst weggelassen; <caption> labelt die Tabelle für Screenreader. */ ?>
|
||||||
|
<?php component('league-table', ['table' => $table, 'caption' => 'Tabelle ' . ($team['name'] ?? '') . ($league ? ' · ' . $league : '')]); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($next !== [] || $results !== []): ?>
|
||||||
|
<aside class="matchcenter-team__aside">
|
||||||
|
<?php if ($next !== []): ?>
|
||||||
|
<h3 class="visually-hidden">Nächste Spiele</h3>
|
||||||
|
<ul class="match-list">
|
||||||
|
<?php foreach ($next as $m): ?>
|
||||||
|
<?php component('match-row', ['match' => $m]); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($results !== []): ?>
|
||||||
|
<h3 class="visually-hidden">Letzte Ergebnisse</h3>
|
||||||
|
<ul class="match-list">
|
||||||
|
<?php foreach ($results as $m): ?>
|
||||||
|
<?php component('match-row', ['match' => $m]); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</aside>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -226,3 +226,31 @@ function team_schema(array $team, string $slug): array
|
|||||||
));
|
));
|
||||||
return $nodes;
|
return $nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus
|
||||||
|
* data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn
|
||||||
|
* der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema'].
|
||||||
|
*/
|
||||||
|
function sportsevent_nodes(array $upcoming): array
|
||||||
|
{
|
||||||
|
$nodes = [];
|
||||||
|
foreach ($upcoming as $m) {
|
||||||
|
if (empty($m['home']) || empty($m['away'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$node = [
|
||||||
|
'@type' => 'SportsEvent',
|
||||||
|
'name' => $m['home'] . ' – ' . $m['away'],
|
||||||
|
'sport' => 'Fußball',
|
||||||
|
'homeTeam' => ['@type' => 'SportsTeam', 'name' => $m['home']],
|
||||||
|
'awayTeam' => ['@type' => 'SportsTeam', 'name' => $m['away']],
|
||||||
|
'eventStatus' => 'https://schema.org/EventScheduled',
|
||||||
|
];
|
||||||
|
if (!empty($m['kickoff'])) {
|
||||||
|
$node['startDate'] = $m['kickoff'];
|
||||||
|
}
|
||||||
|
$nodes[] = $node;
|
||||||
|
}
|
||||||
|
return $nodes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ $meta = [
|
|||||||
'title' => 'Fußball & Turnen in Kulmbach – TSV 08 Kulmbach',
|
'title' => 'Fußball & Turnen in Kulmbach – TSV 08 Kulmbach',
|
||||||
'title_absolute' => true,
|
'title_absolute' => true,
|
||||||
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Damenfußball, Turnen und Trampolin – seit 1908 im Katzbachtal. Jetzt Mitglied werden!',
|
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Damenfußball, Turnen und Trampolin – seit 1908 im Katzbachtal. Jetzt Mitglied werden!',
|
||||||
'scripts' => ['form.js', 'banner.js'],
|
'scripts' => ['form.js', 'carousel.js'],
|
||||||
];
|
];
|
||||||
|
|
||||||
component('hero', ['hero' => $home['hero']]);
|
component('hero', ['hero' => $home['hero']]);
|
||||||
|
|||||||
54
app/pages/matchcenter.php
Normal file
54
app/pages/matchcenter.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matchcenter: Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften.
|
||||||
|
* Daten: data/matchcenter.json (maschinenverwaltet von bin/matchcenter-sync.php).
|
||||||
|
* Struktur: Hero → „Nächste Spiele"-Slider → pro Mannschaft eine Sektion
|
||||||
|
* (Nächste Spiele · Letzte Ergebnisse · Tabelle). Ohne Cache rendert die Seite
|
||||||
|
* einen freundlichen Hinweis statt eines Fehlers (Graceful Degradation).
|
||||||
|
*/
|
||||||
|
$mc = json_load('matchcenter');
|
||||||
|
$teams = $mc['teams'] ?? [];
|
||||||
|
$upcoming = $mc['upcoming'] ?? [];
|
||||||
|
|
||||||
|
$meta = [
|
||||||
|
'title' => 'Matchcenter – Spiele, Ergebnisse & Tabellen',
|
||||||
|
'description' => 'Spielplan, Ergebnisse und Tabellen der Mannschaften des TSV 08 Kulmbach – 1. und 2. Mannschaft sowie Damen, automatisch aktuell.',
|
||||||
|
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||||
|
'scripts' => ['carousel.js', 'crest.js'],
|
||||||
|
'schema' => array_merge(
|
||||||
|
page_schema([
|
||||||
|
['name' => 'Startseite', 'slug' => ''],
|
||||||
|
['name' => 'Fußball', 'slug' => 'fussball'],
|
||||||
|
['name' => 'Matchcenter', 'slug' => 'matchcenter'],
|
||||||
|
]),
|
||||||
|
sportsevent_nodes($upcoming)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
component('hero', ['hero' => [
|
||||||
|
'compact' => true,
|
||||||
|
'align' => 'bottom-left',
|
||||||
|
'kicker' => 'TSV 08 Kulmbach',
|
||||||
|
'title' => 'Matchcenter',
|
||||||
|
'tagline' => 'Spiele · Ergebnisse · Tabellen',
|
||||||
|
'text' => 'Alle Spiele, Ergebnisse und Tabellen unserer Aktivenmannschaften – immer aktuell.',
|
||||||
|
'image' => [
|
||||||
|
'base' => 'img/pages/fussball-hero',
|
||||||
|
'widths' => [640, 1000, 1600],
|
||||||
|
'alt' => '',
|
||||||
|
],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
if ($teams === []) {
|
||||||
|
component('matchcenter-empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
component('match-slider', ['matches' => $upcoming]);
|
||||||
|
|
||||||
|
foreach ($teams as $team) {
|
||||||
|
component('matchcenter-section', ['team' => $team]);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ return [
|
|||||||
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
|
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
|
||||||
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
|
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
|
||||||
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
|
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
|
||||||
|
'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true],
|
||||||
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
|
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
|
||||||
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
|
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
|
||||||
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
|
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
|
||||||
|
|||||||
435
bin/matchcenter-sync.php
Normal file
435
bin/matchcenter-sync.php
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matchcenter-Sync (BFV-Widget-Ersatz): holt Spielplan, Ergebnisse und Tabelle der
|
||||||
|
* drei Aktivenmannschaften aus der öffentlichen BFV-Widget-JSON-API, lädt die
|
||||||
|
* Vereinswappen lokal nach public/assets/img/crests/ und schreibt data/matchcenter.json
|
||||||
|
* atomar. Bei JEDEM Fehler bleibt der letzte gute Cache unangetastet — die Website
|
||||||
|
* degradiert nur, sie bricht nie. Reverse-engineert aus dem BFV-Widget (das alte
|
||||||
|
* <BFVWidget.HTML5…>-Embed lud genau diese Endpunkte client-seitig nach).
|
||||||
|
*
|
||||||
|
* Aufruf: php bin/matchcenter-sync.php [-v] [--dump]
|
||||||
|
* Cron: 37 7,19 * * * cd /pfad/zur/site && /usr/bin/php bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
|
||||||
|
* (zusätzlich am Wochenende häufiger, siehe config.example.php)
|
||||||
|
*
|
||||||
|
* Datenquelle (Host https://widget-prod.bfv.de, liefert sauberes JSON, kein HTML-Scraping):
|
||||||
|
* - Spiele/Ergebnisse: /api/service/widget/v1/team/{teamPermanentId}/matches
|
||||||
|
* - Liga-Tabelle: /api/service/widget/v1/competition/{compoundId}/table
|
||||||
|
* - Vereinswappen: https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId} (PNG)
|
||||||
|
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
|
||||||
|
* setzt ihn (wie bin/instagram-sync.php).
|
||||||
|
*/
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
$verbose = in_array('-v', $argv, true);
|
||||||
|
$dump = in_array('--dump', $argv, true);
|
||||||
|
|
||||||
|
$apiHost = 'https://widget-prod.bfv.de';
|
||||||
|
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
|
||||||
|
$referer = (string) config('matchcenter.referer', 'https://widget-prod.bfv.de/');
|
||||||
|
$maxUpcoming = (int) config('matchcenter.max_upcoming', 5);
|
||||||
|
$maxResults = (int) config('matchcenter.max_results', 5);
|
||||||
|
$teamsCfg = (array) config('matchcenter.teams', []);
|
||||||
|
|
||||||
|
$cacheFile = DATA_PATH . '/matchcenter.json';
|
||||||
|
$crestDir = PUBLIC_PATH . '/assets/img/crests';
|
||||||
|
|
||||||
|
// Anzeigename/Liga/Slug kommen aus der Single Source of Truth (data/teams.json),
|
||||||
|
// nicht aus der API — der Scraper erfindet keine kuratierten Namen.
|
||||||
|
$teamsData = json_load('teams');
|
||||||
|
$slugMap = [
|
||||||
|
'erste-mannschaft' => 'fussball/erste-mannschaft',
|
||||||
|
'zweite-mannschaft' => 'fussball/zweite-mannschaft',
|
||||||
|
'damen' => 'fussball/damen',
|
||||||
|
];
|
||||||
|
|
||||||
|
$tz = new DateTimeZone('Europe/Berlin');
|
||||||
|
$today = (new DateTimeImmutable('now', $tz))->setTime(0, 0);
|
||||||
|
$weekdaysDe = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
// Wettbewerbsart → kurzes, sprechendes Badge-Label.
|
||||||
|
$competitionLabels = [
|
||||||
|
'Meisterschaften' => 'Liga',
|
||||||
|
'Freundschaftsspiele' => 'Freundschaft',
|
||||||
|
'Pokalspiele' => 'Pokal',
|
||||||
|
'Pokal' => 'Pokal',
|
||||||
|
'Hallenrunde' => 'Halle',
|
||||||
|
];
|
||||||
|
|
||||||
|
$log = static function (string $msg) use ($verbose): void {
|
||||||
|
$line = '[' . date('c') . "] {$msg}\n";
|
||||||
|
file_put_contents(STORAGE_PATH . '/logs/matchcenter.log', $line, FILE_APPEND);
|
||||||
|
if ($verbose) {
|
||||||
|
echo $line;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$fail = static function (string $msg) use ($log): never {
|
||||||
|
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
|
||||||
|
exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($teamsCfg === []) {
|
||||||
|
$fail('Keine Teams in config.matchcenter.teams konfiguriert');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lock gegen parallele Läufe ---
|
||||||
|
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
|
||||||
|
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||||
|
$fail('Läuft bereits (Lock belegt)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
|
||||||
|
$fetch = static function (string $url, bool $json = true) use ($referer): string|false {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
CURLOPT_ENCODING => '',
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: ' . ($json ? 'application/json, text/plain, */*' : '*/*'),
|
||||||
|
'Accept-Language: de-DE,de;q=0.9',
|
||||||
|
'Referer: ' . $referer,
|
||||||
|
'Sec-Fetch-Dest: ' . ($json ? 'empty' : 'image'),
|
||||||
|
'Sec-Fetch-Mode: ' . ($json ? 'cors' : 'no-cors'),
|
||||||
|
'Sec-Fetch-Site: cross-site',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
return ($body !== false && $status === 200) ? $body : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** JSON-Endpoint holen und data-Knoten zurückgeben (oder null). */
|
||||||
|
$fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
|
||||||
|
$body = $fetch($url, true);
|
||||||
|
if ($body === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($dump) {
|
||||||
|
$log("DUMP {$url}\n" . substr($body, 0, 4000));
|
||||||
|
}
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Wappen-Download: idempotent, normalisiert auf max 96px PNG (Transparenz erhalten) ---
|
||||||
|
if (!is_dir($crestDir)) {
|
||||||
|
mkdir($crestDir, 0755, true);
|
||||||
|
}
|
||||||
|
$crestKeep = [];
|
||||||
|
$downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $crestDir, &$crestKeep, $log): ?string {
|
||||||
|
$clubId = trim((string) $clubId);
|
||||||
|
if ($clubId === '' || !preg_match('/^[A-Z0-9-]+$/i', $clubId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$rel = "img/crests/{$clubId}.png";
|
||||||
|
$file = "{$crestDir}/{$clubId}.png";
|
||||||
|
$crestKeep["{$clubId}.png"] = true;
|
||||||
|
if (is_file($file)) {
|
||||||
|
return $rel;
|
||||||
|
}
|
||||||
|
$bin = $fetch($crestBase . $clubId, false);
|
||||||
|
if ($bin === false) {
|
||||||
|
$log("Wappen-Download fehlgeschlagen: {$clubId}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$src = @imagecreatefromstring($bin);
|
||||||
|
if ($src === false) {
|
||||||
|
$log("Wappen unlesbar: {$clubId}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$w = imagesx($src);
|
||||||
|
$h = imagesy($src);
|
||||||
|
$scale = min(96 / $w, 96 / $h, 1.0);
|
||||||
|
$nw = max(1, (int) round($w * $scale));
|
||||||
|
$nh = max(1, (int) round($h * $scale));
|
||||||
|
$out = imagecreatetruecolor($nw, $nh);
|
||||||
|
imagealphablending($out, false);
|
||||||
|
imagesavealpha($out, true);
|
||||||
|
imagefilledrectangle($out, 0, 0, $nw, $nh, imagecolorallocatealpha($out, 0, 0, 0, 127));
|
||||||
|
imagecopyresampled($out, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
||||||
|
imagepng($out, $file);
|
||||||
|
return $rel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Datums-/Zeit-Helfer (deutsche Quellformate, Europe/Berlin) ---
|
||||||
|
$parseKickoff = static function (string $date, string $time) use ($tz): ?DateTimeImmutable {
|
||||||
|
$date = trim($date);
|
||||||
|
$time = trim($time) !== '' ? trim($time) : '00:00';
|
||||||
|
if ($date === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$dt = DateTimeImmutable::createFromFormat('d.m.Y H:i', "{$date} {$time}", $tz);
|
||||||
|
return $dt ?: null;
|
||||||
|
};
|
||||||
|
$dateDisplay = static function (?DateTimeImmutable $dt, string $raw) use ($weekdaysDe): string {
|
||||||
|
if ($dt === null) {
|
||||||
|
return $raw; // Roh-String behalten — Anzeige bricht nie weg.
|
||||||
|
}
|
||||||
|
return $weekdaysDe[(int) $dt->format('w')] . ', ' . $dt->format('d.m.');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Match aus der API in unser Anzeige-Format überführen.
|
||||||
|
* @param array<string,mixed> $m Rohes Match aus data.matches[]
|
||||||
|
* @param string $teamCfgId permanentId der eigenen Mannschaft (für is_home/outcome)
|
||||||
|
*/
|
||||||
|
$mapMatch = static function (array $m, string $teamCfgId) use ($parseKickoff, $dateDisplay, $downloadCrest, $competitionLabels, $tz): array {
|
||||||
|
$rawDate = (string) ($m['kickoffDate'] ?? '');
|
||||||
|
$rawTime = (string) ($m['kickoffTime'] ?? '');
|
||||||
|
$dt = $parseKickoff($rawDate, $rawTime);
|
||||||
|
$isHome = ($m['homeTeamPermanentId'] ?? null) === $teamCfgId;
|
||||||
|
$type = (string) ($m['competitionType'] ?? '');
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'kickoff' => $dt?->format('Y-m-d\TH:i'),
|
||||||
|
'date_display' => $dateDisplay($dt, $rawDate),
|
||||||
|
'time_display' => $rawTime !== '' ? $rawTime . ' Uhr' : '',
|
||||||
|
'competition' => $competitionLabels[$type] ?? ($type !== '' ? $type : 'Spiel'),
|
||||||
|
'home' => (string) ($m['homeTeamName'] ?? ''),
|
||||||
|
'away' => (string) ($m['guestTeamName'] ?? ''),
|
||||||
|
'home_crest' => $downloadCrest($m['homeClubId'] ?? null),
|
||||||
|
'away_crest' => $downloadCrest($m['guestClubId'] ?? null),
|
||||||
|
'is_home' => $isHome,
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = trim((string) ($m['result'] ?? ''));
|
||||||
|
if ($result !== '' && preg_match('/^(\d+)\s*:\s*(\d+)/', $result, $mm)) {
|
||||||
|
$gh = (int) $mm[1];
|
||||||
|
$ga = (int) $mm[2];
|
||||||
|
$own = $isHome ? $gh : $ga;
|
||||||
|
$opp = $isHome ? $ga : $gh;
|
||||||
|
$row['goals_home'] = $gh;
|
||||||
|
$row['goals_away'] = $ga;
|
||||||
|
$row['result'] = $result;
|
||||||
|
$row['outcome'] = $own > $opp ? 'win' : ($own === $opp ? 'draw' : 'loss');
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Vorheriger Cache: bei Teil-Ausfällen (BFV liefert 418/Fehler) behalten wir
|
||||||
|
// die alten Daten dieses Teams bzw. Teils, statt die Sektion zu leeren. ---
|
||||||
|
$prev = [];
|
||||||
|
if (is_file($cacheFile)) {
|
||||||
|
$decoded = json_decode((string) file_get_contents($cacheFile), true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$prev = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$prevByKey = [];
|
||||||
|
foreach ($prev['teams'] ?? [] as $pt) {
|
||||||
|
if (!empty($pt['key'])) {
|
||||||
|
$prevByKey[$pt['key']] = $pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cacheFallbackUsed = false;
|
||||||
|
|
||||||
|
// Wappen-Dateinamen aus (ggf. aus dem Cache übernommenen) Zeilen in die Keep-Liste
|
||||||
|
// aufnehmen, damit die GC unten sie nicht löscht.
|
||||||
|
$keepCrests = static function (array $rows) use (&$crestKeep): void {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
foreach (['home_crest', 'away_crest', 'crest'] as $k) {
|
||||||
|
if (!empty($r[$k]) && is_string($r[$k])) {
|
||||||
|
$crestKeep[basename($r[$k])] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Pro Team: Spiele + Tabelle holen, parsen, Wappen laden (isoliert) ---
|
||||||
|
$resultTeams = [];
|
||||||
|
$upcomingAll = [];
|
||||||
|
|
||||||
|
foreach ($teamsCfg as $cfg) {
|
||||||
|
$key = (string) ($cfg['key'] ?? '');
|
||||||
|
$teamCfgId = (string) ($cfg['team_id'] ?? '');
|
||||||
|
$cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
|
||||||
|
if ($key === '' || $teamCfgId === '') {
|
||||||
|
$log("Team-Eintrag ohne key/team_id übersprungen");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$prevTeam = $prevByKey[$key] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Spiele zuerst — daraus leiten wir die aktuelle Wettbewerbs-ID (Tabelle) ab,
|
||||||
|
// damit eine neue Saison automatisch übernommen wird (die team_id ist stabil,
|
||||||
|
// die compound_id der Tabelle wechselt pro Saison).
|
||||||
|
$matchesData = $fetchJson("{$apiHost}/api/service/widget/v1/team/{$teamCfgId}/matches");
|
||||||
|
$apiCompound = (string) ($matchesData['data']['team']['compoundId'] ?? '');
|
||||||
|
$compoundId = $apiCompound !== '' ? $apiCompound : $cfgCompoundId;
|
||||||
|
$tableData = $compoundId !== '' ? $fetchJson("{$apiHost}/api/service/widget/v1/competition/{$compoundId}/table") : null;
|
||||||
|
|
||||||
|
// Name/Liga bevorzugt aus teams.json (Single Source), sonst API/alter Cache.
|
||||||
|
$apiCompetition = (string) ($matchesData['data']['team']['competitionName'] ?? '');
|
||||||
|
$name = $teamsData[$key]['name'] ?? ($matchesData['data']['team']['name'] ?? ($prevTeam['name'] ?? $key));
|
||||||
|
$league = $teamsData[$key]['league'] ?? null;
|
||||||
|
if (!$league) {
|
||||||
|
$league = $apiCompetition !== '' ? $apiCompetition : ($prevTeam['league'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spiele-Teil ---
|
||||||
|
if ($matchesData !== null) {
|
||||||
|
$matches = $matchesData['data']['matches'] ?? [];
|
||||||
|
$upcoming = [];
|
||||||
|
$results = [];
|
||||||
|
foreach ($matches as $m) {
|
||||||
|
$hasResult = trim((string) ($m['result'] ?? '')) !== '';
|
||||||
|
$row = $mapMatch($m, $teamCfgId);
|
||||||
|
if ($hasResult) {
|
||||||
|
$results[] = $row;
|
||||||
|
} else {
|
||||||
|
// Ohne Ergebnis nur zeigen, wenn Anstoß heute/künftig.
|
||||||
|
$dt = $row['kickoff'] !== null ? new DateTimeImmutable($row['kickoff'], $tz) : null;
|
||||||
|
if ($dt === null || $dt->setTime(0, 0) >= $today) {
|
||||||
|
$upcoming[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($upcoming, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
|
||||||
|
usort($results, static fn ($a, $b) => ($b['kickoff'] ?? '') <=> ($a['kickoff'] ?? ''));
|
||||||
|
$upcoming = array_slice($upcoming, 0, $maxUpcoming);
|
||||||
|
$results = array_slice($results, 0, $maxResults);
|
||||||
|
|
||||||
|
// permanentId → clubId-Karte (für Wappen in der Tabelle).
|
||||||
|
$clubIdByPermanent = [];
|
||||||
|
foreach ($matches as $m) {
|
||||||
|
if (!empty($m['homeTeamPermanentId']) && !empty($m['homeClubId'])) {
|
||||||
|
$clubIdByPermanent[$m['homeTeamPermanentId']] = $m['homeClubId'];
|
||||||
|
}
|
||||||
|
if (!empty($m['guestTeamPermanentId']) && !empty($m['guestClubId'])) {
|
||||||
|
$clubIdByPermanent[$m['guestTeamPermanentId']] = $m['guestClubId'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Spiele-Fetch fehlgeschlagen → alten Cache dieses Teams behalten.
|
||||||
|
$upcoming = $prevTeam['next_matches'] ?? [];
|
||||||
|
$results = $prevTeam['last_results'] ?? [];
|
||||||
|
$clubIdByPermanent = [];
|
||||||
|
$cacheFallbackUsed = true;
|
||||||
|
$keepCrests($upcoming);
|
||||||
|
$keepCrests($results);
|
||||||
|
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tabellen-Teil ---
|
||||||
|
if ($tableData !== null) {
|
||||||
|
$tableRows = $tableData['data']['table'] ?? [];
|
||||||
|
$cfgZone = $tableData['data']['configuration'] ?? [];
|
||||||
|
$rowCount = count($tableRows);
|
||||||
|
$promo = (int) ($cfgZone['promotionTeamCount'] ?? 0);
|
||||||
|
$promoPlayoff = (int) ($cfgZone['promotionPlayoffTeamCount'] ?? 0);
|
||||||
|
$releg = (int) ($cfgZone['relegationTeamCount'] ?? 0);
|
||||||
|
$relegPlayoff = (int) ($cfgZone['relegationPlayoffTeamCount'] ?? 0);
|
||||||
|
|
||||||
|
$table = [];
|
||||||
|
foreach ($tableRows as $r) {
|
||||||
|
$pos = (int) ($r['position'] ?? 0);
|
||||||
|
$permanentId = (string) ($r['team']['permanentId'] ?? '');
|
||||||
|
$zone = null;
|
||||||
|
if ($pos >= 1 && $pos <= $promo) {
|
||||||
|
$zone = 'promotion';
|
||||||
|
} elseif ($pos <= $promo + $promoPlayoff) {
|
||||||
|
$zone = 'promotion-playoff';
|
||||||
|
} elseif ($releg > 0 && $pos > $rowCount - $releg) {
|
||||||
|
$zone = 'relegation';
|
||||||
|
} elseif ($relegPlayoff > 0 && $pos > $rowCount - $releg - $relegPlayoff) {
|
||||||
|
$zone = 'relegation-playoff';
|
||||||
|
}
|
||||||
|
$table[] = [
|
||||||
|
'rank' => $pos,
|
||||||
|
'club' => (string) ($r['team']['name'] ?? ''),
|
||||||
|
'crest' => $downloadCrest($clubIdByPermanent[$permanentId] ?? null),
|
||||||
|
'played' => (int) ($r['matches'] ?? 0),
|
||||||
|
'won' => (int) ($r['matchesWon'] ?? 0),
|
||||||
|
'drawn' => (int) ($r['matchesDrawn'] ?? 0),
|
||||||
|
'lost' => (int) ($r['matchesLost'] ?? 0),
|
||||||
|
'goal_diff' => (int) ($r['goalsDiff'] ?? 0),
|
||||||
|
'points' => (int) ($r['points'] ?? 0),
|
||||||
|
'zone' => $zone,
|
||||||
|
'is_own_club' => $permanentId === $teamCfgId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tabellen-Fetch fehlgeschlagen → alte Tabelle dieses Teams behalten.
|
||||||
|
$table = $prevTeam['table'] ?? [];
|
||||||
|
$cacheFallbackUsed = true;
|
||||||
|
$keepCrests($table);
|
||||||
|
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($upcoming === [] && $results === [] && $table === []) {
|
||||||
|
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resultTeams[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'name' => $name,
|
||||||
|
'league' => $league,
|
||||||
|
'slug' => $slugMap[$key] ?? '',
|
||||||
|
'next_matches' => $upcoming,
|
||||||
|
'last_results' => $results,
|
||||||
|
'table' => $table,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($upcoming as $u) {
|
||||||
|
$upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$log("Team {$key}: " . count($upcoming) . ' anstehend, ' . count($results) . ' Ergebnisse, ' . count($table) . ' Tabellenplätze');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
|
||||||
|
$log("Team {$key} übersprungen: " . $e->getMessage());
|
||||||
|
if ($prevTeam !== null) {
|
||||||
|
$resultTeams[] = $prevTeam;
|
||||||
|
$cacheFallbackUsed = true;
|
||||||
|
$keepCrests($prevTeam['next_matches'] ?? []);
|
||||||
|
$keepCrests($prevTeam['last_results'] ?? []);
|
||||||
|
$keepCrests($prevTeam['table'] ?? []);
|
||||||
|
foreach ($prevTeam['next_matches'] ?? [] as $u) {
|
||||||
|
$upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $prevTeam['name'] ?? $key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resultTeams === []) {
|
||||||
|
$fail('Kein einziges Team lieferte Daten (API blockt evtl. die Server-IP oder Struktur geändert)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globaler „nächste Spiele"-Slider: teamübergreifend, nach Anstoß sortiert.
|
||||||
|
usort($upcomingAll, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
|
||||||
|
$upcomingAll = array_slice($upcomingAll, 0, $maxUpcoming);
|
||||||
|
|
||||||
|
// --- Nicht mehr referenzierte Wappen aufräumen. Nur, wenn ALLE Teams frisch
|
||||||
|
// geladen wurden (sonst könnte ein transienter Ausfall ein noch genutztes
|
||||||
|
// Wappen löschen) und die Keep-Liste nicht leer ist. ---
|
||||||
|
if (!$cacheFallbackUsed && $crestKeep !== []) {
|
||||||
|
foreach (glob("{$crestDir}/*.png") ?: [] as $existing) {
|
||||||
|
if (!isset($crestKeep[basename($existing)])) {
|
||||||
|
unlink($existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cache atomar schreiben (tmp + rename) ---
|
||||||
|
$payload = json_encode([
|
||||||
|
'fetched_at' => time(),
|
||||||
|
'teams' => $resultTeams,
|
||||||
|
'upcoming' => $upcomingAll,
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$tmp = $cacheFile . '.tmp';
|
||||||
|
file_put_contents($tmp, $payload);
|
||||||
|
rename($tmp, $cacheFile);
|
||||||
|
|
||||||
|
$log('OK: ' . count($resultTeams) . ' Teams gecached, ' . count($crestKeep) . ' Wappen lokal in assets/img/crests/');
|
||||||
@@ -35,4 +35,24 @@ return [
|
|||||||
'username' => 'tsv08kulmbach',
|
'username' => 'tsv08kulmbach',
|
||||||
'max_posts' => 9,
|
'max_posts' => 9,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Matchcenter-Scraper (bin/matchcenter-sync.php) — Spielplan/Ergebnisse/Tabelle
|
||||||
|
// aus der öffentlichen BFV-Widget-JSON-API. Die IDs sind keine Geheimnisse
|
||||||
|
// (sie steckten schon im alten BFV-Widget-Embed) und dürfen hier stehen.
|
||||||
|
'matchcenter' => [
|
||||||
|
// Referer für die BFV-Requests (gegen HTTP 418).
|
||||||
|
'referer' => 'https://widget-prod.bfv.de/',
|
||||||
|
'max_upcoming' => 5, // anstehende Spiele pro Team + im globalen Slider
|
||||||
|
'max_results' => 5, // letzte Ergebnisse pro Team
|
||||||
|
// key = Schlüssel in data/teams.json (für kuratierten Namen/Liga/Slug),
|
||||||
|
// team_id = BFV permanentId (saison-stabil). compound_id = Wettbewerbs-ID
|
||||||
|
// der Tabelle — nur Fallback: der Scraper leitet die aktuelle compoundId
|
||||||
|
// automatisch aus der Matches-API ab, sodass eine neue Saison von selbst
|
||||||
|
// übernommen wird. compound_id nur pflegen, falls die Auto-Erkennung mal fehlt.
|
||||||
|
'teams' => [
|
||||||
|
['key' => 'erste-mannschaft', 'team_id' => '016PKHIPIO000000VV0AG811VUDIC8D7', 'compound_id' => '02TBKEVREG000006VS5489BTVVQ0O654-G'],
|
||||||
|
['key' => 'zweite-mannschaft', 'team_id' => '016PCRVRJC000000VV0AG811VTE5EA5R', 'compound_id' => '02TD8CKMD8000006VS5489BTVTLPPK10-G'],
|
||||||
|
['key' => 'damen', 'team_id' => '01HLQ7QVES000000VV0AG80NVVKQLCU9', 'compound_id' => '02TK5AGFE0000004VS5489BUVVPGA9RV-G'],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ "label": "1. Mannschaft", "slug": "fussball/erste-mannschaft" },
|
{ "label": "1. Mannschaft", "slug": "fussball/erste-mannschaft" },
|
||||||
{ "label": "2. Mannschaft", "slug": "fussball/zweite-mannschaft" },
|
{ "label": "2. Mannschaft", "slug": "fussball/zweite-mannschaft" },
|
||||||
{ "label": "Damenmannschaft", "slug": "fussball/damen" },
|
{ "label": "Damenmannschaft", "slug": "fussball/damen" },
|
||||||
|
{ "label": "Matchcenter", "slug": "matchcenter" },
|
||||||
{ "label": "Jugendfußball", "slug": "jugendfussball" }
|
{ "label": "Jugendfußball", "slug": "jugendfussball" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2903,3 +2903,613 @@
|
|||||||
.facility__actions {
|
.facility__actions {
|
||||||
margin-top: 1.75rem;
|
margin-top: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === matchcenter === */
|
||||||
|
/* Vereinswappen — gemeinsam für Karten und Tabelle; fehlt das Bild, rendert die
|
||||||
|
Komponente einen Initialen-Kreis (.crest--initials) statt eines kaputten <img>. */
|
||||||
|
/* Weißes Chip hält auch helle/weiße Logos auf dunklem Grund sichtbar. */
|
||||||
|
.crest {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: none;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 3px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crest--initials {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--ff-body);
|
||||||
|
font-size: var(--fs-300);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
background: var(--clr-glass-bg);
|
||||||
|
border: 1px solid var(--clr-glass-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Einzelne Begegnung. */
|
||||||
|
.match-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: clamp(1rem, 0.8rem + 0.8vw, 1.4rem);
|
||||||
|
background: var(--clr-glass-bg);
|
||||||
|
border: 1px solid var(--clr-glass-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datum oben mittig (kicker-Stil). */
|
||||||
|
.match-card__date {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-size: var(--fs-300);
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__date-icon {
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__fixture {
|
||||||
|
display: grid;
|
||||||
|
/* minmax(0, …) statt 1fr: erlaubt den Spalten, schmaler als ihr Inhalt zu
|
||||||
|
werden (sonst überläuft die Karte auf sehr schmalen Screens). */
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0; /* verhindert Grid-Blowout der 1fr-Spalten bei langen Namen */
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__club {
|
||||||
|
font-size: var(--fs-400);
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere; /* lange Namen brechen um statt zu überlaufen */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__side.is-own .match-card__club {
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--clr-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 3.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__score {
|
||||||
|
font-family: var(--ff-heading);
|
||||||
|
font-size: var(--fs-700);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__colon {
|
||||||
|
margin: 0 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card__vs {
|
||||||
|
font-family: var(--ff-heading);
|
||||||
|
font-size: var(--fs-650);
|
||||||
|
color: var(--clr-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeit mittig unter dem Ergebnis/Anstoß (kicker-Stil). */
|
||||||
|
.match-card__time {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: var(--fs-400);
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero-Variante (im Slider): größer und ruhiger. */
|
||||||
|
.match-card--hero {
|
||||||
|
padding: clamp(1.5rem, 1rem + 2vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card--hero .match-card__score {
|
||||||
|
font-size: var(--fs-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card--hero .crest,
|
||||||
|
.match-card--hero .crest--initials {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card--hero .match-card__club {
|
||||||
|
font-size: var(--fs-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* „Nächste Spiele"-Slider — Mechanik wie .ad-banner (gemeinsamer Antrieb
|
||||||
|
carousel.js, gemeinsame Keyframe ad-progress). */
|
||||||
|
.match-slider {
|
||||||
|
--slider-interval: 6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider > .container > h2 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__track {
|
||||||
|
list-style: none;
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__slide[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__team {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: var(--fs-300);
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crossfade nur mit JS: Slides im selben Grid-Feld stapeln (konstante Höhe). */
|
||||||
|
.match-slider.is-enhanced .match-slider__track {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr); /* gestapelte Slides nie breiter als der Track */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider.is-enhanced .match-slider__slide {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.55s ease, transform 0.55s ease, visibility 0s linear 0.55s;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider.is-enhanced .match-slider__slide.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
visibility: visible;
|
||||||
|
transition: opacity 0.55s ease, transform 0.55s ease, visibility 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__controls[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--clr-text);
|
||||||
|
background: var(--clr-glass-bg);
|
||||||
|
border: 1px solid var(--clr-glass-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle:hover {
|
||||||
|
background: rgb(255 255 255 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--clr-text);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle-icon {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle .match-slider__toggle-icon--play {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle[aria-pressed="true"] .match-slider__toggle-icon--pause {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__toggle[aria-pressed="true"] .match-slider__toggle-icon--play {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__dots {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__dot {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--clr-glass-border);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__dot:hover {
|
||||||
|
background: rgb(255 255 255 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__dot:focus-visible {
|
||||||
|
outline: 2px solid var(--clr-text);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__progress {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--clr-accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider__dot.is-active .match-slider__progress {
|
||||||
|
animation: ad-progress var(--slider-interval) linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-slider.is-paused .match-slider__dot.is-active .match-slider__progress {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.match-slider__dot.is-active .match-slider__progress {
|
||||||
|
animation: none;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pro-Mannschaft-Sektion: Kopf mit Name/Liga + Link zur Mannschaftsseite. */
|
||||||
|
.matchcenter-team__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchcenter-team__league {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* kicker-Layout: Tabelle 2/3 links, Spiele 1/3 rechts; gestapelt auf Mobil.
|
||||||
|
align-items:start + 0-Margins der Karten → beide Spalten starten oben bündig. */
|
||||||
|
.matchcenter-team__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: clamp(1.5rem, 1rem + 2vw, 2.75rem);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.matchcenter-team__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* min-width:0 erlaubt der Tabellenspalte, schmaler als die min-width:520 der
|
||||||
|
Tabelle zu werden — sonst schiebt die Tabelle die ganze Seite in den
|
||||||
|
horizontalen Scroll, statt selbst in ihrer Karte zu scrollen. */
|
||||||
|
.matchcenter-team__grid > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mehrere Listen (Nächste Spiele / Letzte Ergebnisse) gleichmäßig gestapelt.
|
||||||
|
max-width hält die kompakten Zeilen gestapelt (≤992px) lesbar schmal, statt sie
|
||||||
|
über die volle Breite zu ziehen; im 2-spaltigen Desktop greift es nicht (die
|
||||||
|
1fr-Spalte ist ohnehin schmaler). */
|
||||||
|
.matchcenter-team__aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kompakte Spielliste (Seitenspalte) — gleiche Karten-Optik wie die Tabelle. */
|
||||||
|
.match-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--clr-glass-bg);
|
||||||
|
border: 1px solid var(--clr-glass-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--clr-glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__date {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
font-size: var(--fs-300);
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__teams {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0; /* erlaubt Ellipsis in den Namen */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__team {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__team .crest,
|
||||||
|
.match-row__team .crest--initials {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--fs-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__team.is-own .match-row__name {
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--clr-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__outcome {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__score {
|
||||||
|
font-family: var(--ff-heading);
|
||||||
|
font-size: var(--fs-650);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row__time {
|
||||||
|
font-size: var(--fs-400);
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Liga-Tabelle: horizontal scrollbar (fokussierbare Region) auf schmalen Screens. */
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border: 1px solid var(--clr-glass-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll:focus-visible {
|
||||||
|
outline: 2px solid var(--clr-text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 520px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--clr-glass-bg);
|
||||||
|
font-size: var(--fs-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table th,
|
||||||
|
.league-table td {
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--clr-glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody tr:last-child th,
|
||||||
|
.league-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spaltenköpfe in Abel (Coolvetica wäre hier zu klein, < 28px). */
|
||||||
|
.league-table thead th {
|
||||||
|
font-family: var(--ff-body);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
font-size: var(--fs-300);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table thead th abbr {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__rank {
|
||||||
|
width: 2.75rem;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vereinsspalte (Kopf + Zellen) linksbündig — höhere Spezifität als .league-table th. */
|
||||||
|
.league-table .league-table__club {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody th.league-table__club {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-width: 0; /* erlaubt Ellipsis im Namen */
|
||||||
|
font-weight: var(--fw-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody .crest,
|
||||||
|
.league-table tbody .crest--initials {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lange Vereinsnamen sauber kürzen (voller Name im title-Attribut). */
|
||||||
|
.league-table__name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__pts {
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--clr-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eigene Vereinszeile hervorheben (rote Tönung hinter weißem Text). */
|
||||||
|
.league-table tbody tr.is-own {
|
||||||
|
background: rgb(var(--clr-accent-rgb) / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody tr.is-own th,
|
||||||
|
.league-table tbody tr.is-own td {
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--clr-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf-/Abstiegszonen: farbiger linker Rand am Rang. */
|
||||||
|
.league-table tbody tr.zone-promotion .league-table__rank {
|
||||||
|
border-left-color: var(--clr-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody tr.zone-promotion-playoff .league-table__rank {
|
||||||
|
border-left-color: color-mix(in srgb, var(--clr-success) 60%, var(--clr-text-faint));
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody tr.zone-relegation-playoff .league-table__rank {
|
||||||
|
border-left-color: color-mix(in srgb, var(--clr-error) 60%, var(--clr-text-faint));
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table tbody tr.zone-relegation .league-table__rank {
|
||||||
|
border-left-color: var(--clr-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: var(--fs-300);
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend-item::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.85rem;
|
||||||
|
height: 0.85rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--clr-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend-item.zone-promotion::before {
|
||||||
|
background: var(--clr-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend-item.zone-promotion-playoff::before {
|
||||||
|
background: color-mix(in srgb, var(--clr-success) 60%, var(--clr-text-faint));
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend-item.zone-relegation-playoff::before {
|
||||||
|
background: color-mix(in srgb, var(--clr-error) 60%, var(--clr-text-faint));
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-table__legend-item.zone-relegation::before {
|
||||||
|
background: var(--clr-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback ohne Cache. */
|
||||||
|
.matchcenter-empty {
|
||||||
|
max-width: 540px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: clamp(1.5rem, 1rem + 2vw, 2.5rem);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--clr-glass-bg);
|
||||||
|
border: 1px solid var(--clr-glass-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchcenter-empty__icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--clr-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchcenter-empty p {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchcenter-empty__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|||||||
2
public/assets/icons/calendar-event.svg
Normal file
2
public/assets/icons/calendar-event.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5z"/>
|
||||||
|
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
0
public/assets/img/crests/.gitkeep
Normal file
0
public/assets/img/crests/.gitkeep
Normal file
@@ -1,123 +0,0 @@
|
|||||||
/* Werbe-/Sponsoren-Banner-Slider: Auto-Rotation über die Fortschrittsanimation
|
|
||||||
des aktiven Indikators (animationend → nächste Folie).
|
|
||||||
|
|
||||||
Barrierefreiheit:
|
|
||||||
- explizite Pause/Play-Taste (WCAG 2.2.2 „Pause, Stop, Hide"),
|
|
||||||
- zusätzlich Pause bei Hover/Fokus (Komfort),
|
|
||||||
- aria-live: im Autoplay "off" (kein Vorlese-Spam), bei Pause/manuell "polite",
|
|
||||||
- bei prefers-reduced-motion läuft keine Animation → keine Auto-Rotation;
|
|
||||||
Indikatoren bleiben per Klick bedienbar, die Pause-Taste entfällt. */
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var root = document.querySelector('[data-banner]');
|
|
||||||
if (!root) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var track = root.querySelector('#ad-banner-track');
|
|
||||||
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-banner-slide]'));
|
|
||||||
var dots = Array.prototype.slice.call(root.querySelectorAll('[data-banner-dot]'));
|
|
||||||
var controls = root.querySelector('[data-banner-controls]');
|
|
||||||
var toggle = root.querySelector('[data-banner-toggle]');
|
|
||||||
var toggleLabel = root.querySelector('[data-banner-toggle-label]');
|
|
||||||
if (slides.length <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
var index = 0;
|
|
||||||
var hoverPaused = false;
|
|
||||||
var userPaused = false;
|
|
||||||
|
|
||||||
function syncPaused() {
|
|
||||||
// Bei reduzierter Bewegung gilt jeder Wechsel als manuell → ansagen.
|
|
||||||
var paused = hoverPaused || userPaused || reduce;
|
|
||||||
root.classList.toggle('is-paused', paused);
|
|
||||||
if (track) {
|
|
||||||
track.setAttribute('aria-live', paused ? 'polite' : 'off');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(i) {
|
|
||||||
index = (i % slides.length + slides.length) % slides.length;
|
|
||||||
slides.forEach(function (slide, n) {
|
|
||||||
// Sichtbarkeit per Klasse → CSS blendet über (siehe .is-enhanced).
|
|
||||||
slide.classList.toggle('is-active', n === index);
|
|
||||||
});
|
|
||||||
dots.forEach(function (dot, n) {
|
|
||||||
var on = n === index;
|
|
||||||
dot.classList.toggle('is-active', on);
|
|
||||||
if (on) {
|
|
||||||
dot.setAttribute('aria-current', 'true');
|
|
||||||
} else {
|
|
||||||
dot.removeAttribute('aria-current');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sobald die Fortschrittsanimation des aktiven Indikators durchgelaufen ist,
|
|
||||||
// zur nächsten Folie wechseln. (Beim Wechsel wird die alte Animation
|
|
||||||
// abgebrochen → 'animationcancel', nicht 'animationend' → kein Doppelsprung.)
|
|
||||||
root.addEventListener('animationend', function (event) {
|
|
||||||
if (event.animationName === 'ad-progress') {
|
|
||||||
activate(index + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pause bei Hover/Fokus.
|
|
||||||
root.addEventListener('mouseenter', function () {
|
|
||||||
hoverPaused = true;
|
|
||||||
syncPaused();
|
|
||||||
});
|
|
||||||
root.addEventListener('mouseleave', function () {
|
|
||||||
hoverPaused = false;
|
|
||||||
syncPaused();
|
|
||||||
});
|
|
||||||
root.addEventListener('focusin', function () {
|
|
||||||
hoverPaused = true;
|
|
||||||
syncPaused();
|
|
||||||
});
|
|
||||||
root.addEventListener('focusout', function (event) {
|
|
||||||
if (!root.contains(event.relatedTarget)) {
|
|
||||||
hoverPaused = false;
|
|
||||||
syncPaused();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dots.forEach(function (dot, n) {
|
|
||||||
dot.addEventListener('click', function () {
|
|
||||||
activate(n);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Steuerung einblenden (ohne JS bleibt sie verborgen, da funktionslos).
|
|
||||||
if (controls) {
|
|
||||||
controls.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause/Play nur anbieten, wenn überhaupt automatisch rotiert wird.
|
|
||||||
if (toggle && !reduce) {
|
|
||||||
toggle.hidden = false;
|
|
||||||
toggle.addEventListener('click', function () {
|
|
||||||
userPaused = !userPaused;
|
|
||||||
toggle.setAttribute('aria-pressed', String(userPaused));
|
|
||||||
if (toggleLabel) {
|
|
||||||
toggleLabel.textContent = userPaused
|
|
||||||
? 'Automatischen Wechsel fortsetzen'
|
|
||||||
: 'Automatischen Wechsel pausieren';
|
|
||||||
}
|
|
||||||
syncPaused();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crossfade aktivieren: Slides stapeln (CSS) und Sichtbarkeit per Klasse
|
|
||||||
// steuern. Vorher gesetzte hidden-Attribute (No-JS-Fallback) entfernen.
|
|
||||||
root.classList.add('is-enhanced');
|
|
||||||
slides.forEach(function (slide) {
|
|
||||||
slide.hidden = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
activate(0);
|
|
||||||
syncPaused();
|
|
||||||
})();
|
|
||||||
131
public/assets/js/carousel.js
Normal file
131
public/assets/js/carousel.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/* Generischer Karussell-Antrieb für alle [data-carousel]-Elemente
|
||||||
|
(Werbe-/Sponsoren-Banner und Matchcenter „Nächste Spiele"). Auto-Rotation über
|
||||||
|
die Fortschrittsanimation des aktiven Indikators (animationend → nächste Folie).
|
||||||
|
|
||||||
|
Erwartete Hooks je Wurzel:
|
||||||
|
[data-carousel] Wurzel
|
||||||
|
[data-carousel-track] Folien-Container (bekommt aria-live)
|
||||||
|
[data-carousel-slide] einzelne Folie
|
||||||
|
[data-carousel-controls] Steuerleiste (ohne JS hidden)
|
||||||
|
[data-carousel-toggle] Pause/Play-Taste
|
||||||
|
[data-carousel-toggle-label] visually-hidden Beschriftung
|
||||||
|
[data-carousel-dot] Indikator-Button
|
||||||
|
[data-carousel-progress] Fortschritts-Element im aktiven Dot (treibt den Wechsel)
|
||||||
|
|
||||||
|
Barrierefreiheit:
|
||||||
|
- explizite Pause/Play-Taste (WCAG 2.2.2 „Pause, Stop, Hide"),
|
||||||
|
- zusätzlich Pause bei Hover/Fokus,
|
||||||
|
- aria-live: im Autoplay "off", bei Pause/manuell "polite",
|
||||||
|
- bei prefers-reduced-motion läuft keine Animation → keine Auto-Rotation;
|
||||||
|
Indikatoren bleiben per Klick bedienbar, die Pause-Taste entfällt.
|
||||||
|
Ohne JS ist nur die erste Folie sichtbar (Graceful Degradation). */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function initCarousel(root) {
|
||||||
|
var track = root.querySelector('[data-carousel-track]');
|
||||||
|
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-carousel-slide]'));
|
||||||
|
var dots = Array.prototype.slice.call(root.querySelectorAll('[data-carousel-dot]'));
|
||||||
|
var controls = root.querySelector('[data-carousel-controls]');
|
||||||
|
var toggle = root.querySelector('[data-carousel-toggle]');
|
||||||
|
var toggleLabel = root.querySelector('[data-carousel-toggle-label]');
|
||||||
|
if (slides.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var index = 0;
|
||||||
|
var hoverPaused = false;
|
||||||
|
var userPaused = false;
|
||||||
|
|
||||||
|
function syncPaused() {
|
||||||
|
var paused = hoverPaused || userPaused || reduce;
|
||||||
|
root.classList.toggle('is-paused', paused);
|
||||||
|
if (track) {
|
||||||
|
track.setAttribute('aria-live', paused ? 'polite' : 'off');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(i) {
|
||||||
|
index = (i % slides.length + slides.length) % slides.length;
|
||||||
|
slides.forEach(function (slide, n) {
|
||||||
|
slide.classList.toggle('is-active', n === index);
|
||||||
|
});
|
||||||
|
dots.forEach(function (dot, n) {
|
||||||
|
var on = n === index;
|
||||||
|
dot.classList.toggle('is-active', on);
|
||||||
|
if (on) {
|
||||||
|
dot.setAttribute('aria-current', 'true');
|
||||||
|
} else {
|
||||||
|
dot.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fortschrittsanimation des aktiven Indikators durchgelaufen → nächste Folie.
|
||||||
|
// (Beim Wechsel bricht die alte Animation ab → 'animationcancel' statt
|
||||||
|
// 'animationend' → kein Doppelsprung.)
|
||||||
|
root.addEventListener('animationend', function (event) {
|
||||||
|
if (event.target && event.target.matches && event.target.matches('[data-carousel-progress]')) {
|
||||||
|
activate(index + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener('mouseenter', function () {
|
||||||
|
hoverPaused = true;
|
||||||
|
syncPaused();
|
||||||
|
});
|
||||||
|
root.addEventListener('mouseleave', function () {
|
||||||
|
hoverPaused = false;
|
||||||
|
syncPaused();
|
||||||
|
});
|
||||||
|
root.addEventListener('focusin', function () {
|
||||||
|
hoverPaused = true;
|
||||||
|
syncPaused();
|
||||||
|
});
|
||||||
|
root.addEventListener('focusout', function (event) {
|
||||||
|
if (!root.contains(event.relatedTarget)) {
|
||||||
|
hoverPaused = false;
|
||||||
|
syncPaused();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dots.forEach(function (dot, n) {
|
||||||
|
dot.addEventListener('click', function () {
|
||||||
|
activate(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Steuerung einblenden (ohne JS bleibt sie verborgen, da funktionslos).
|
||||||
|
if (controls) {
|
||||||
|
controls.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause/Play nur anbieten, wenn überhaupt automatisch rotiert wird.
|
||||||
|
if (toggle && !reduce) {
|
||||||
|
toggle.hidden = false;
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
userPaused = !userPaused;
|
||||||
|
toggle.setAttribute('aria-pressed', String(userPaused));
|
||||||
|
if (toggleLabel) {
|
||||||
|
toggleLabel.textContent = userPaused
|
||||||
|
? 'Automatischen Wechsel fortsetzen'
|
||||||
|
: 'Automatischen Wechsel pausieren';
|
||||||
|
}
|
||||||
|
syncPaused();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crossfade aktivieren: Slides stapeln (CSS) und Sichtbarkeit per Klasse
|
||||||
|
// steuern. Vorher gesetzte hidden-Attribute (No-JS-Fallback) entfernen.
|
||||||
|
root.classList.add('is-enhanced');
|
||||||
|
slides.forEach(function (slide) {
|
||||||
|
slide.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
activate(0);
|
||||||
|
syncPaused();
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.slice.call(document.querySelectorAll('[data-carousel]')).forEach(initCarousel);
|
||||||
|
})();
|
||||||
39
public/assets/js/crest.js
Normal file
39
public/assets/js/crest.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* Vereinswappen-Fallback: lädt ein <img class="crest"> nicht (404, kaputtes Bild),
|
||||||
|
wird es durch den Initialen-Kreis ersetzt — kein kaputtes Bild-Icon. Progressive
|
||||||
|
Enhancement; ohne JS bleibt höchstens das (alt="") leere Bild. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function initials(name) {
|
||||||
|
var parts = (name || '').split(/[\s.\-]+/);
|
||||||
|
var out = '';
|
||||||
|
for (var i = 0; i < parts.length && out.length < 2; i++) {
|
||||||
|
if (parts[i]) {
|
||||||
|
out += parts[i].charAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (out || '?').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replace(img) {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'crest crest--initials';
|
||||||
|
span.setAttribute('aria-hidden', 'true');
|
||||||
|
span.textContent = initials(img.getAttribute('data-club'));
|
||||||
|
if (img.parentNode) {
|
||||||
|
img.parentNode.replaceChild(span, img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgs = document.querySelectorAll('img.crest');
|
||||||
|
Array.prototype.forEach.call(imgs, function (img) {
|
||||||
|
// Bereits fehlgeschlagen, bevor JS lief?
|
||||||
|
if (img.complete && img.naturalWidth === 0) {
|
||||||
|
replace(img);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
img.addEventListener('error', function () {
|
||||||
|
replace(img);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user