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:
2026-06-19 10:57:14 +02:00
parent ea622244db
commit 185365dc79
23 changed files with 1679 additions and 135 deletions

5
.gitignore vendored
View File

@@ -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/

View File

@@ -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.

View File

@@ -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
View 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; ?>

View 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; ?>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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;
}

View File

@@ -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
View 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]);
}

View File

@@ -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
View 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/');

View File

@@ -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'],
],
],
]; ];

View File

@@ -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" }
] ]
}, },

View File

@@ -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;
}

View 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

View File

View 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();
})();

View 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
View 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);
});
});
})();