Files
tsv08kulmbach-website/app/helpers.php

1773 lines
70 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
/**
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
*/
function config(?string $key = null, mixed $default = null): mixed
{
$value = $GLOBALS['__config'];
if ($key === null) {
return $value;
}
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
/**
* Deploy-Selbstprüfung: harte Konfigurationsprobleme, die den Betrieb kaputt oder
* unsicher machen. Leeres Array = alles gesetzt. Genutzt von bin/preflight.php und
* als Not-Aus in den Formular-Actions: fehlt config/config.php, läuft bootstrap.php
* auf config.example.php weiter deren app_secret steht öffentlich im Repo, die
* Time-Trap wäre also wertlos und der Mailversand würde ohnehin scheitern.
*/
function config_problems(): array
{
$problems = [];
if (!is_file(ROOT_PATH . '/config/config.php')) {
$problems[] = 'config/config.php fehlt — die Seite läuft auf der Vorlage config.example.php.';
}
$secret = (string) config('app_secret');
if ($secret === '' || $secret === 'CHANGE_ME' || strlen($secret) < 32) {
$problems[] = 'app_secret fehlt oder ist zu kurz/Platzhalter (neu: php -r "echo bin2hex(random_bytes(32));").';
}
$smtp = (array) config('smtp', []);
foreach (['host', 'port', 'username', 'password', 'from', 'to'] as $key) {
if (empty($smtp[$key])) {
$problems[] = "smtp.{$key} ist nicht gesetzt.";
}
}
if (in_array((string) ($smtp['password'] ?? ''), ['POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
$problems[] = 'smtp.password ist noch der Platzhalter aus der Vorlage.';
}
return $problems;
}
/**
* HTML-Escaping für JEDE dynamische Ausgabe verwenden.
*/
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/**
* Interner Link aus Slug: url('fussball') '/fussball', url('') '/'.
*/
function url(string $slug = ''): string
{
return '/' . trim($slug, '/');
}
/**
* Absolute URL für Canonical, OG und Sitemap.
*/
function abs_url(string $slug = ''): string
{
return rtrim((string) config('base_url'), '/') . url($slug);
}
/**
* Absolute URL auf der ÖFFENTLICHEN Vereinsadresse (`club.website`), unabhängig
* davon, wo dieser Code gerade läuft.
*
* Nicht dasselbe wie abs_url(): `base_url` ist bewusst umgebungsabhängig, weil
* Canonical, OG und Sitemap zur ausliefernden Adresse passen müssen. Gedruckte
* Dinge kennen dagegen kein „lokal" — ein QR-Code auf Papier und die
* Internet-Zeile im Heft-Impressum zeigen immer auf die echte Domain. Vorher
* liefen sie über base_url und trugen deshalb `http://localhost:8000` ins
* Druckmaterial.
*
* Fällt `club.website` weg, greift abs_url() ein fehlendes Feld darf keinen
* kaputten Link erzeugen.
*/
function club_url(string $slug = ''): string
{
$website = (string) (json_load('club')['website'] ?? '');
return $website !== '' ? rtrim($website, '/') . url($slug) : abs_url($slug);
}
/**
* Die öffentliche Adresse ohne Schema und ohne „www", zum Hinschreiben
* (Cover, Impressum, Plakate): `tsv08kulmbach.de/stadionzeitung`.
*/
function club_domain(string $slug = ''): string
{
$ohneSchema = (string) preg_replace('~^https?://(www\.)?~', '', club_url($slug));
// Ohne Pfad soll „tsv08kulmbach.de" stehen, nicht „tsv08kulmbach.de/" —
// url('') liefert den Wurzel-Slash, der hier nur Lärm wäre.
return $slug === '' ? rtrim($ohneSchema, '/') : $ohneSchema;
}
/**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
*
* $bust = false gibt den Pfad ohne ?v= zurück. Nötig für Ressourcen, die auch
* aus statischem CSS heraus angefordert werden (Fonts via @font-face in
* base.css): eine abweichende Query macht daraus für den Browser eine zweite
* URL, der Preload passt dann nicht zum @font-face-Request und läuft ins Leere
* („preloaded but not used"). Geänderte Font-Dateien bekommen deshalb einen
* neuen Dateinamen statt einer neuen Version.
*/
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
function asset(string $path, bool $bust = true): string
{
$path = ltrim($path, '/');
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
if (!$bust) {
return '/assets/' . $path;
}
$file = PUBLIC_PATH . '/assets/' . $path;
$version = is_file($file) ? (string) filemtime($file) : '0';
return '/assets/' . $path . '?v=' . $version;
}
/**
* Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.svg).
* Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild
* (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon.
* Quelle: Bootstrap Icons (MIT) neue Icons via bin/icons-add.php hinzufügen.
*/
function icon(string $name, string $class = '', ?string $label = null): string
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg';
$cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : '';
}
if ($cache[$name] === '') {
return '';
}
$attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"';
$attrs .= $label !== null && $label !== ''
? ' role="img" aria-label="' . e($label) . '"'
: ' aria-hidden="true" focusable="false"';
return preg_replace('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
}
/**
* JSON-Datendatei laden (data/<name>.json) mit Request-weitem Cache.
* Wirft bei kaputtem JSON Datenfehler sollen laut scheitern, nicht leise.
*/
function json_load(string $name): array
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = DATA_PATH . '/' . $name . '.json';
if (!is_file($file)) {
return [];
}
$cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
}
return $cache[$name];
}
/**
* Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie <img>-Tags.
* $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via
* viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert
* fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar.
*/
function img_intrinsic_attrs(string $rel): string
{
static $cache = [];
if (!isset($cache[$rel])) {
$file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/');
$w = $h = 0;
if (is_file($file)) {
if (str_ends_with(strtolower($file), '.svg')) {
$svg = (string) file_get_contents($file);
if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
} elseif (preg_match('/<svg\b[^>]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
}
} else {
[$w, $h] = (getimagesize($file) ?: [0, 0]);
}
}
$cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : '';
}
return $cache[$rel];
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* Responsive Bild-Varianten erzeugen (GD, läuft auch auf Shared Hosting)
* schreibt <destBase>-<breite>.jpg je Breite, skaliert nie hoch. Geteilte
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], ].
Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari) Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
*
* $alpha = true schreibt stattdessen <destBase>-<breite>.png und erhält die
* Transparenz nötig für freigestellte Motive (Spielerfoto auf dem
* Stadionzeitungs-Cover), bei denen JPG die Freistellung gegen Schwarz wegrechnen
* würde. $quality gilt dann nicht (PNG ist verlustfrei). Der Default bleibt JPG,
* weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern und deren
* Pfade in den data/*.json auf .jpg zeigen.
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
*/
Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari) Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78, bool $alpha = false): array
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
{
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
'png' => imagecreatefrompng($src),
'webp' => imagecreatefromwebp($src),
default => null,
};
if (!$image) {
throw new RuntimeException("Kann Quelle nicht lesen: {$src}");
}
$srcW = imagesx($image);
$srcH = imagesy($image);
$out = [];
foreach ($widths as $width) {
$w = min((int) $width, $srcW); // nie hochskalieren
$h = (int) round($srcH * $w / $srcW);
$frame = imagecreatetruecolor($w, $h);
Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari) Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
if ($alpha) {
// Transparenz retten: ohne Blending kopieren (sonst rechnet GD den
// Alphakanal gegen Schwarz weg) und die Fläche vorher transparent füllen.
imagealphablending($frame, false);
imagesavealpha($frame, true);
$leer = (int) imagecolorallocatealpha($frame, 0, 0, 0, 127);
imagefilledrectangle($frame, 0, 0, $w, $h, $leer);
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari) Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
if ($alpha) {
$file = "{$destBase}-{$w}.png";
imagepng($frame, $file, 9);
} else {
$file = "{$destBase}-{$w}.jpg";
imagejpeg($frame, $file, $quality);
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
}
return $out;
}
/**
* Freigestelltes PNG auf den Inhalt zuschneiden (Alpha-Bounding-Box + kleiner
* Rand) Stadionzeitung-Cover: Export-Tools lassen um eine Freistellung oft
* viel leeren, transparenten Rand stehen (z. B. 17% oben, ~1820% seitlich),
* dadurch wirkt das Spielerfoto auf dem Cover kleiner als nötig. $padding =
* Rand um die erkannte Fläche, Anteil der jeweiligen Kantenlänge (0.02 = 2%).
* Liefert false bei Fehlern (kein lesbares PNG, komplett transparent) statt
* zu werfen das Spielerfoto ist ein "nice to have" fürs Cover, ein
* Fehlschlag darf die Ausgabe nicht scheitern lassen.
*/
function crop_transparent_png_to_content(string $src, string $dest, float $padding = 0.02): bool
{
$im = @imagecreatefrompng($src);
if (!$im) {
return false;
}
imagealphablending($im, false);
imagesavealpha($im, true);
$w = imagesx($im);
$h = imagesy($im);
$minX = $w;
$minY = $h;
$maxX = 0;
$maxY = 0;
$found = false;
for ($y = 0; $y < $h; $y++) {
for ($x = 0; $x < $w; $x++) {
$alpha = (imagecolorat($im, $x, $y) >> 24) & 0x7F; // 0=opak, 127=transparent
if ($alpha < 110) {
$found = true;
$minX = min($minX, $x);
$maxX = max($maxX, $x);
$minY = min($minY, $y);
$maxY = max($maxY, $y);
}
}
}
if (!$found) {
return false;
}
$padX = (int) round(($maxX - $minX) * $padding);
$padY = (int) round(($maxY - $minY) * $padding);
$cropX = max(0, $minX - $padX);
$cropY = max(0, $minY - $padY);
$cropW = min($w - $cropX, $maxX - $minX + 1 + 2 * $padX);
$cropH = min($h - $cropY, $maxY - $minY + 1 + 2 * $padY);
$out = imagecreatetruecolor($cropW, $cropH);
imagealphablending($out, false);
imagesavealpha($out, true);
$transparent = imagecolorallocatealpha($out, 0, 0, 0, 127);
imagefill($out, 0, 0, $transparent);
imagecopy($out, $im, 0, 0, $cropX, $cropY, $cropW, $cropH);
return imagepng($out, $dest) && is_file($dest);
}
/**
* QR-Code als PNG erzeugen (Stadionzeitung-Cover: Link zur digitalen Ausgabe).
* Nutzt die manuell vendorte "PHP QR Code"-Bibliothek (vendor-manual/phpqrcode,
* GD-basiert, kein neues PHP-Modul, kein Composer) statt eines Laufzeit-Diensts.
* Die Bibliothek ist alter PHP-5-Code und meldet auf PHP 8 pro Aufruf ein paar
* Deprecation-Notices zur Parameter-Reihenfolge schon beim `require` (nicht
* erst beim Aufruf), deshalb error_reporting() um beides herum runtergesetzt.
* Genau dasselbe Muster wie bei der vendorten Files-Gallery (siehe
* public/dateien/_filesconfig.php, dort `display_errors = 0`).
* QR ist ein "nice to have" auf dem Cover: Rückgabe false statt Exception,
* ein Fehlschlag darf die Ausgabe nicht scheitern lassen.
*
* $scale = Pixel pro QR-Modul. Default 6 ergibt gut 200px richtig für die
* Stadionzeitungs-Seiten, viel zu klein für Druckmaterial. Für Plakate und
* Aushänge deutlich höher gehen (siehe bin/qr.php).
*/
function generate_qr_png(string $data, string $destPath, int $scale = 6): bool
{
$lib = ROOT_PATH . '/vendor-manual/phpqrcode/phpqrcode.php';
if (!is_file($lib)) {
return false;
}
$previous = error_reporting(E_ALL & ~E_DEPRECATED);
try {
require_once $lib;
QRcode::png($data, $destPath, QR_ECLEVEL_M, max(1, $scale), 2);
} catch (Throwable) {
return false;
} finally {
error_reporting($previous);
}
return is_file($destPath);
}
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
/**
* Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
* data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin
* PHP läuft hier global auf UTC, deshalb explizit. Liefert:
* iso maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
* countdown „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
* Unlesbarer Wert beide Felder leer; bereits angestoßenes Spiel countdown leer.
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
*
* $with_time = false lässt die Uhrzeit im Countdown weg („heute" statt
* „heute, 00:00 Uhr") — für ganztägige Vereins-Events aus data/events.json, die
* keine time-Angabe haben (siehe event-feature-Komponente). Spiele haben immer
* einen Anstoß, für sie bleibt der Default.
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
*/
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
function match_when(string $kickoff, bool $with_time = true): array
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
{
if ($kickoff === '') {
return ['iso' => '', 'countdown' => ''];
}
$tz = new DateTimeZone('Europe/Berlin');
try {
$start = new DateTimeImmutable($kickoff, $tz);
} catch (Exception) {
return ['iso' => '', 'countdown' => ''];
}
$now = new DateTimeImmutable('now', $tz);
if ($start <= $now) {
return ['iso' => $start->format('c'), 'countdown' => ''];
}
// Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
// ist „morgen", auch wenn es nur 20 Stunden hin sind.
$days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
$time = $with_time ? ', ' . $start->format('H:i') . ' Uhr' : '';
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
return [
'iso' => $start->format('c'),
'countdown' => match (true) {
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
$days === 0 => 'heute' . $time,
$days === 1 => 'morgen' . $time,
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
$days < 14 => 'in ' . $days . ' Tagen',
default => 'in ' . (int) round($days / 7) . ' Wochen',
},
];
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* Deutsches Langdatum aus einem ISO-Datum (YYYY-MM-DD), z. B. "13. September 2025".
* Kein IntlDateFormatter im Projekt (kein ext-intl-Vorbild) feste Monatsnamen
* reichen für einsprachiges Deutsch. Unlesbares Datum Rohwert zurück.
*/
function german_date(string $isoDate): string
{
static $months = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni',
7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
$date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
if ($date === false) {
return $isoDate;
}
return (int) $date->format('j') . '. ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
}
/**
* Wie german_date(), aber für mehrtägige Events (data/events.json: date_end).
* Gleicher Monat/Jahr "4.6. September 2026"; unterschiedlicher Monat
* "28. August 3. September 2026"; unterschiedliches Jahr volles Datum beidseitig.
* $end === null oder === $start einfaches german_date().
*/
function german_date_range(string $start, ?string $end): string
{
if ($end === null || $end === '' || $end === $start) {
return german_date($start);
}
$startDate = DateTimeImmutable::createFromFormat('Y-m-d', $start);
$endDate = DateTimeImmutable::createFromFormat('Y-m-d', $end);
if ($startDate === false || $endDate === false) {
return german_date($start);
}
if ($startDate->format('Y') !== $endDate->format('Y')) {
return german_date($start) . ' ' . german_date($end);
}
if ($startDate->format('n') !== $endDate->format('n')) {
static $months = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni',
7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
return (int) $startDate->format('j') . '. ' . $months[(int) $startDate->format('n')]
. ' ' . german_date($end);
}
return (int) $startDate->format('j') . '.' . german_date($end);
}
/**
* Kompaktes Datums-Chip fürs termine-list (Tag groß + Monat kurz), z. B. für
* den 4. September: ['day' => '4', 'month' => 'Sep']. Unlesbares Datum leere
* Strings (Aufrufer zeigt dann einfach nichts an, kein Fataler Fehler).
*/
function german_date_chip(string $isoDate): array
{
static $months = [
1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr', 5 => 'Mai', 6 => 'Jun',
7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez',
];
$date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
if ($date === false) {
return ['day' => '', 'month' => ''];
}
return ['day' => $date->format('j'), 'month' => $months[(int) $date->format('n')]];
}
/**
* Deutscher Wochentag aus einem ISO-Datum, z. B. "Freitag". Ergänzt
* german_date()/german_date_chip() um die eine Angabe, die ein Tagesprogramm
* braucht (Veranstaltungs-Programm: „Freitag, 4. September"). Kein
* IntlDateFormatter im Projekt, deshalb feste Namen wie bei den Monaten.
* Unlesbares Datum leerer String, der Aufrufer lässt es dann weg.
*/
function german_weekday(string $isoDate): string
{
static $days = [
1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag',
5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag',
];
$date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
if ($date === false) {
return '';
}
return $days[(int) $date->format('N')];
}
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
/**
* Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
* „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
* $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
* redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
* wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
* Tabelle zählt.
*/
function match_competition_label(string $competition, string $league = ''): string
{
return match ($competition) {
'Liga' => $league,
'Freundschaft' => 'Freundschaftsspiel',
default => $competition,
};
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* Alle last_results über sämtliche Teams gemergt, mit team_key + team_name
* angereichert (last_results[] hat das anders als upcoming[] nicht schon
* eingebacken). Genutzt von /termine (Ergebnis-Zeilen) und /news
* (Ergebnis-Karten) derselbe Datenschnitt, zwei Darstellungen, nie doppelt
* gepflegt. $mc = json_load('matchcenter').
*/
function matchcenter_recent_results(array $mc): array
{
$results = [];
foreach ($mc['teams'] ?? [] as $team) {
foreach ($team['last_results'] ?? [] as $result) {
$result['team_key'] = $team['key'] ?? '';
$result['team_name'] = $team['name'] ?? '';
$results[] = $result;
}
}
return $results;
}
/**
* Anzeigename eines Vereinsbereichs (department-Schlüssel aus data/events.json
* bzw. der normalisierten Termine-Form). Genutzt von der termine-list-Komponente
* (Badge) und vom Hero-Aufmacher (Slide-Beschriftung) dieselben Labels, eine
* Stelle. Unbekannter Schlüssel Rohwert, damit ein neuer Bereich sichtbar
* bleibt statt zu verschwinden.
*/
function department_label(string $department): string
{
return match ($department) {
'fussball' => 'Fußball',
'turnen' => 'Turnen',
'verein' => 'Verein',
default => $department,
};
}
/**
* Ist eine Veranstaltung veröffentlicht? Gleiche Semantik wie
* stadionzeitung_is_published(): ein fehlendes status-Feld gilt als
* veröffentlicht, damit ein Eintrag ohne das Feld nie unsichtbar wird.
*/
function veranstaltung_is_published(array $veranstaltung): bool
{
return ($veranstaltung['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
/**
* Alle veröffentlichten Veranstaltungen (data/veranstaltungen.json), neueste
* zuerst. EINZIGE Ladestelle für die Übersicht /veranstaltungen, den
* Termine-Merge und die Sitemap so fallen Entwürfe an einer Stelle heraus
* statt an drei. Die Detailseite (app/pages/veranstaltung.php) lädt bewusst
* ungefiltert: ein Entwurf muss unter seiner direkten URL lesbar bleiben,
* sonst könnte ihn niemand gegenlesen.
*/
function veranstaltungen_load(): array
{
$items = array_values(array_filter(
json_load('veranstaltungen')['veranstaltungen'] ?? [],
'veranstaltung_is_published'
));
usort(
$items,
static fn (array $a, array $b): int => (string) ($b['date_start'] ?? '') <=> (string) ($a['date_start'] ?? '')
);
return $items;
}
/**
* Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der
* Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
* Samstag noch zu den kommenden Terminen und nicht schon zu den vergangenen.
* Gemeinsame Regel für events.json, veranstaltungen.json und die Entscheidung
* „Ankündigung oder Rückblick" auf der Veranstaltungs-Detailseite.
*/
function termin_is_upcoming(array $entry, string $today): bool
{
$end = (string) ($entry['date_end'] ?? '');
$reference = $end !== '' ? $end : (string) ($entry['date_start'] ?? '');
return $reference >= $today;
}
/**
* Baut die zusammengeführten Termine-Listen (Fußball-Spiele + manuelle
* Vereins-Events + Veranstaltungen mit eigener Detailseite) gemeinsame
* Grundlage für app/pages/termine.php UND die Stadionzeitungs-Live-Seite
* „termine" (Phase 3), damit nie zwei Merge-Implementierungen auseinanderlaufen.
*
* DREI Quellen, jede genau einmal (siehe CLAUDE.md): Spiele aus
* data/matchcenter.json, Termine ohne eigene Seite aus data/events.json,
* Termine MIT eigener Seite aus data/veranstaltungen.json. Ein Fest darf
* niemals in beiden JSON-Dateien stehen, sonst steht es doppelt in der Liste.
* Rückgabe:
* upcoming normalisierte Termine-Form, aufsteigend sortiert
* past normalisierte Termine-Form, absteigend sortiert
* upcoming_matches rohe $mc['upcoming'] (für sportsevent_nodes())
* events rohe data/events.json (für event_schema())
* veranstaltungen rohe data/veranstaltungen.json (für event_schema() mit
* Slug, damit der Knoten seine eigene URL trägt)
*
* $asOf (YYYY-MM-DD, leer = heute): Bezugsdatum für „kommend"/„vergangen".
* Für die Website immer heute (Default). Die Stadionzeitung braucht dagegen
* einen FESTEN Stand zum Zeitpunkt des Ausgaben-Anlegens (bin/stadionzeitung-
* add.php ruft mit dem Spieltag auf und friert das Ergebnis in
* `termine_snapshot` ein) eine spätere, viel später aufgerufene Ausgabe
* soll weiter zeigen, was am Spieltag anstand, nicht was heute ansteht.
*/
function termine_build_lists(string $asOf = ''): array
{
$events = json_load('events')['events'] ?? [];
$veranstaltungen = veranstaltungen_load();
$mc = json_load('matchcenter');
$upcomingMatches = $mc['upcoming'] ?? [];
$teams = $mc['teams'] ?? [];
$today = $asOf !== '' ? $asOf : date('Y-m-d');
// Bei einem Bezugsdatum in der Vergangenheit (Stadionzeitungs-Snapshot)
// auch Spiele rausfiltern, die zu dem Zeitpunkt schon gelaufen wären —
// $mc['upcoming'] selbst kennt nur den AKTUELLEN Sync-Stand, nicht den
// historischen Stand am Spieltag der Ausgabe.
if ($asOf !== '') {
$upcomingMatches = array_values(array_filter(
$upcomingMatches,
static fn (array $m): bool => substr((string) ($m['kickoff'] ?? ''), 0, 10) >= $asOf
));
}
// team_key wird gebraucht, damit normalizeMatch() den Liganamen nachschlagen
// kann (upcoming[]-Einträge haben das schon eingebacken, last_results[] nicht).
$leagueByTeamKey = [];
foreach ($teams as $team) {
$leagueByTeamKey[$team['key'] ?? ''] = $team['league'] ?? '';
}
$pastMatches = matchcenter_recent_results($mc);
$normalizeMatch = static function (array $m) use ($leagueByTeamKey): array {
$isHome = !empty($m['is_home']);
$league = $leagueByTeamKey[$m['team_key'] ?? ''] ?? '';
$competition = match_competition_label((string) ($m['competition'] ?? ''), $league);
$meta = trim(($isHome ? 'Heimspiel' : 'Auswärtsspiel') . ($competition !== '' ? ' · ' . $competition : ''));
if (isset($m['goals_home'], $m['goals_away'])) {
$meta .= ' · ' . (int) $m['goals_home'] . ':' . (int) $m['goals_away'];
}
return [
'date' => substr((string) ($m['kickoff'] ?? ''), 0, 10),
'date_end' => null,
'title' => trim(($m['home'] ?? '') . ' ' . ($m['away'] ?? '')),
'department' => 'fussball',
'type' => 'spiel',
'meta' => $meta,
'href' => url('matchcenter'),
// Zusatzfelder für Darstellungen, die mehr als die Zeile brauchen
// (Hero-Aufmacher rendert daraus die match-feature-Karte mit Wappen).
// termine-list ignoriert sie.
'kickoff' => (string) ($m['kickoff'] ?? ''),
'team_name' => (string) ($m['team_name'] ?? ''),
'league' => $league,
'raw' => $m,
];
};
// $href bleibt für data/events.json leer (kein Ziel, die termine-list rendert
// die Zeile dann als <div>). Veranstaltungen aus data/veranstaltungen.json
// haben eine Detailseite und bekommen sie hier gesetzt — dieselbe
// Normalisierung, nur mit Ziel.
$normalizeEvent = static function (array $e, ?string $href = null): array {
$meta = trim(
(!empty($e['time']) ? $e['time'] . ' Uhr' : '')
. (!empty($e['time']) && !empty($e['location']) ? ' · ' : '')
. (string) ($e['location'] ?? '')
);
return [
'date' => (string) ($e['date_start'] ?? ''),
'date_end' => $e['date_end'] ?? null,
'title' => (string) ($e['title'] ?? ''),
'department' => (string) ($e['department'] ?? 'verein'),
'type' => 'event',
'meta' => $meta,
'href' => $href,
// s. Kommentar bei $normalizeMatch — Zusatzfelder für reichere
// Darstellungen (Hero-Aufmacher), von termine-list ignoriert.
'time' => (string) ($e['time'] ?? ''),
'location' => (string) ($e['location'] ?? ''),
'raw' => $e,
];
};
$normalizeVeranstaltung = static fn (array $v): array => $normalizeEvent(
$v,
url('veranstaltungen/' . (string) ($v['slug'] ?? ''))
);
$isUpcoming = static fn (array $e): bool => termin_is_upcoming($e, $today);
$isPast = static fn (array $e): bool => !termin_is_upcoming($e, $today);
$upcoming = array_merge(
array_map($normalizeMatch, $upcomingMatches),
array_map($normalizeEvent, array_filter($events, $isUpcoming)),
array_map($normalizeVeranstaltung, array_filter($veranstaltungen, $isUpcoming))
);
usort($upcoming, static fn (array $a, array $b): int => $a['date'] <=> $b['date']);
$past = array_merge(
array_map($normalizeMatch, $pastMatches),
array_map($normalizeEvent, array_filter($events, $isPast)),
array_map($normalizeVeranstaltung, array_filter($veranstaltungen, $isPast))
);
usort($past, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);
return [
'upcoming' => $upcoming,
'past' => $past,
'upcoming_matches' => $upcomingMatches,
// Roh-Quellen für event_schema(): getrennt, obwohl beide dieselben
// Feldnamen tragen. Nur eine Veranstaltung hat eine eigene URL, und die
// muss als @id/url in den Knoten — sonst beschreiben /termine und die
// Detailseite dasselbe Fest als zwei unabhängige Events.
'events' => $events,
'veranstaltungen' => $veranstaltungen,
];
}
/**
* Die nächsten Termine als Aufmacher für den Startseiten-Hero Spiele und
* Vereins-Events chronologisch gemischt, in genau der Reihenfolge, in der sie
* anstehen. Baut auf termine_build_lists() auf, damit es weiterhin nur EINE
* Merge-Logik für Termine gibt (siehe CLAUDE.md); hier kommt nur die
* Aufbereitung für die Karte dazu:
* type 'spiel' | 'event' bestimmt die Innenvariante der Karte
* label Beschriftung des Slides („Nächstes Spiel · 1. Mannschaft").
* Nur der erste Slide sagt „nächstes", danach würde es bei zwei
* Spielen hintereinander schief klingen.
* href Ziel des Slides: Spiel /matchcenter, Veranstaltung mit eigener
* Seite /veranstaltungen/<slug>, sonstiger Termin /termine
* (data/events.json-Einträge haben keine Detailseite)
* link_text visually-hidden Zusatz, damit der Linktext das Ziel nennt
* match roher Matchcenter-Eintrag (für die match-feature-Komponente)
* event roher events.json-Eintrag (für die event-feature-Komponente)
* Keine Termine leeres Array; der Hero rendert dann ohne Karte.
*/
function upcoming_highlights(int $limit = 3): array
{
$items = [];
foreach (array_slice(termine_build_lists()['upcoming'], 0, max(0, $limit)) as $i => $item) {
$isMatch = ($item['type'] ?? '') === 'spiel';
$lead = $isMatch
? ($i === 0 ? 'Nächstes Spiel' : 'Spiel')
: ($i === 0 ? 'Nächster Termin' : 'Termin');
$suffix = $isMatch
? (string) ($item['team_name'] ?? '')
: department_label((string) ($item['department'] ?? 'verein'));
// Termine mit eigener Detailseite bringen ihr Ziel schon aus
// termine_build_lists() mit — dann zeigt die Karte dorthin statt auf die
// Sammelseite. Spiele bleiben beim Matchcenter.
$detailHref = !$isMatch ? (string) ($item['href'] ?? '') : '';
$items[] = [
'type' => $isMatch ? 'spiel' : 'event',
'label' => $suffix !== '' ? $lead . ' · ' . $suffix : $lead,
'href' => $isMatch ? url('matchcenter') : ($detailHref !== '' ? $detailHref : url('termine')),
'link_text' => $isMatch
? 'zum Matchcenter'
: ($detailHref !== '' ? 'zur Veranstaltung' : 'zu allen Terminen'),
'match' => $isMatch ? ($item['raw'] ?? []) : null,
'event' => $isMatch ? null : ($item['raw'] ?? []),
'team_name' => (string) ($item['team_name'] ?? ''),
'league' => (string) ($item['league'] ?? ''),
];
}
return $items;
}
/**
* Ist eine Meldung veröffentlicht? Gleiche Semantik wie
* veranstaltung_is_published()/stadionzeitung_is_published(): ein fehlendes
* status-Feld gilt als veröffentlicht (Bestandsschutz).
*/
function news_is_published(array $item): bool
{
return ($item['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
/**
* Hat eine Meldung eine eigene Detailseite (/news/<slug>)? DIE EINZIGE STELLE,
* die das entscheidet Feed-Verlinkung, Archiv, Sitemap und Preflight fragen
* ausschließlich hier, damit die Antwort nicht an vier Orten auseinanderläuft.
*
* Maßgeblich ist der INHALT (`artikel`), nicht der `slug`: einen Slug schreibt
* man beim Anlegen reflexartig mit, sonst entstünden versehentlich Seiten mit
* zwei Sätzen Inhalt. Eine Kurzmeldung bleibt damit eine reine Feed-Zeile, so
* wie es vor den Detailseiten für alle Meldungen war.
*/
function news_has_article(array $item): bool
{
return !empty($item['slug']) && !empty($item['artikel']);
}
/**
* Alle veröffentlichten Meldungen (data/news.json), neueste zuerst. EINZIGE
* gefilterte Ladestelle für den Feed, das Archiv und die Sitemap. Die
* Detailseite (app/pages/news-artikel.php) lädt bewusst ungefiltert über
* news_find(): ein Entwurf muss unter seiner direkten URL lesbar bleiben,
* sonst könnte ihn niemand gegenlesen (wie bei veranstaltungen_load()).
*/
function news_load(): array
{
$items = array_values(array_filter(
json_load('news')['news'] ?? [],
'news_is_published'
));
usort($items, static fn (array $a, array $b): int => (string) ($b['date'] ?? '') <=> (string) ($a['date'] ?? ''));
return $items;
}
/**
* Eine Meldung über ihren Slug, UNGEFILTERT (Entwürfe eingeschlossen) für die
* Detailseite. Ohne Treffer null, der Aufrufer antwortet dann mit 404.
*/
function news_find(string $slug): ?array
{
foreach (json_load('news')['news'] ?? [] as $item) {
if (($item['slug'] ?? '') === $slug) {
return $item;
}
}
return null;
}
/**
* Baut den gemischten News-Feed (redaktionelle Meldungen + Instagram-Posts +
* Ergebnisse) gemeinsame Grundlage für app/pages/news.php UND die
* Stadionzeitungs-Live-Seite „news" (Phase 3). Rückgabe:
* items normalisierte, absteigend sortierte Form. config('news.max_items')
* kappt NUR Instagram und Ergebnisse (die wachsen automatisch);
* redaktionelle Meldungen stehen immer alle drin
* news veröffentlichte Meldungen aus data/news.json (für news_article_schema())
*
* Die Meldungen kommen über news_load(), also OHNE Entwürfe. Das gilt
* absichtlich auch für den Stadionzeitungs-Snapshot
* (stadionzeitung_build_snapshot()): der friert seinen Stand beim
* Veröffentlichen ein und ist die einzige Stelle, die sich nicht mehr
* zurücknehmen lässt ein Entwurf darf dort nie landen.
*/
function news_build_feed(): array
{
$news = news_load();
$instagramPosts = json_load('instagram')['posts'] ?? [];
$results = matchcenter_recent_results(json_load('matchcenter'));
$maxItems = (int) config('news.max_items', 20);
$normalizeNews = static function (array $n): array {
// Nur Meldungen mit Artikeltext verlinken (news_has_article()) — eine
// Kurzmeldung bleibt eine nicht verlinkte Zeile, news-feed.php rendert
// sie ohne href als <div>. `link_text` liefert die sichtbare
// Weiterlesen-Auszeichnung; gleiche Prop wie in upcoming_highlights(),
// damit die Komponente die Quellen nicht kennen muss.
$hasArticle = news_has_article($n);
return [
'date' => (string) ($n['date'] ?? ''),
'title' => (string) ($n['title'] ?? ''),
'text' => (string) ($n['text'] ?? ''),
'image' => $n['image'] ?? null,
'href' => $hasArticle ? url('news/' . $n['slug']) : null,
'link_text' => $hasArticle ? 'Artikel lesen' : null,
'source' => 'news',
];
};
$normalizeInstagram = static function (array $post): array {
// Titel = erste Caption-Zeile, Text = Rest ab Zeile zwei. So steht die
// erste Zeile nie doppelt da (Titel + kompletter Caption-Text waren
// vorher beide sichtbar). Einzeilige Captions → Text leer, die
// Anzeige lässt ihn dann weg.
$caption = trim((string) ($post['caption'] ?? ''));
[$firstLine, $rest] = array_pad(explode("\n", $caption, 2), 2, '');
$firstLine = trim($firstLine);
$title = $firstLine !== '' ? mb_substr($firstLine, 0, 120) : 'Instagram-Beitrag';
if ($title !== $firstLine && $firstLine !== '') {
$title .= '…'; // hart gekappt → nicht mitten im Wort enden lassen
}
return [
'date' => !empty($post['taken_at']) ? date('Y-m-d', (int) $post['taken_at']) : '',
'title' => $title,
'text' => trim($rest),
'image' => !empty($post['image'])
? ['src' => $post['image'], 'width' => 640, 'height' => 800, 'alt' => (string) ($post['alt'] ?? '')]
: null,
'href' => (string) ($post['url'] ?? ''),
'source' => 'instagram',
];
};
$normalizeResult = static function (array $r): array {
$isHome = !empty($r['is_home']);
$opponent = $isHome ? ($r['away'] ?? '') : ($r['home'] ?? '');
$opponentCrest = $isHome ? ($r['away_crest'] ?? null) : ($r['home_crest'] ?? null);
$outcomeLabel = match ($r['outcome'] ?? null) {
'win' => 'Sieg gegen',
'loss' => 'Niederlage gegen',
'draw' => 'Unentschieden gegen',
default => 'Spiel gegen',
};
return [
'date' => substr((string) ($r['kickoff'] ?? ''), 0, 10),
'title' => trim("{$outcomeLabel} {$opponent}"),
'text' => trim(($r['team_name'] ?? '') . " · Endstand {$r['goals_home']}:{$r['goals_away']}"),
'image' => null,
'crest' => ['src' => $opponentCrest, 'name' => $opponent],
'href' => url('matchcenter'),
'source' => 'ergebnis',
];
};
// Manche last_results-Einträge (z. B. Testspiele) haben nie ein erfasstes
// Ergebnis — ohne Tore ist es keine News ("Ergebnis" ohne Ergebnis), raus damit.
$resultsWithScore = array_filter(
$results,
static fn (array $r): bool => isset($r['goals_home'], $r['goals_away'])
);
// Gekappt werden NUR die maschinell gefüllten Quellen: Instagram und
// Ergebnisse wachsen von allein weiter (Sync 2×/Tag, jedes Wochenende neue
// Spiele) und dagegen schützt die Grenze. Redaktionelle Meldungen stehen
// immer alle da — sie sind wenige, gewollt und sollen dauerhaft erreichbar
// bleiben. Vorher fiel ein älterer Artikel irgendwann aus der Liste und war
// von der Website aus gar nicht mehr verlinkt.
$automatisch = array_merge(
array_map($normalizeInstagram, $instagramPosts),
array_map($normalizeResult, $resultsWithScore)
);
usort($automatisch, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);
$items = array_merge(
array_map($normalizeNews, $news),
array_slice($automatisch, 0, $maxItems)
);
usort($items, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);
return [
'items' => $items,
'news' => $news,
];
}
/**
* Ist eine Stadionzeitung-Ausgabe veröffentlicht? Ausgaben ohne status-Feld
* (vor Einführung von Entwurf/Veröffentlicht) gelten als veröffentlicht
* sie waren es faktisch, das Feld kam später dazu.
*/
function stadionzeitung_is_published(array $issue): bool
{
return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
/**
* Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) für die
* Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
* selbst nach `date` und verlässt sich nicht auf die Reihenfolge in der Datei:
* die stimmt zwar (bin/stadionzeitung-add.php sortiert absteigend), aber ein
* gedruckter QR-Code darf nicht von einer Dateireihenfolge abhängen.
*/
function stadionzeitung_latest(): ?array
{
$issues = array_values(array_filter(
json_load('stadionzeitung')['issues'] ?? [],
'stadionzeitung_is_published'
));
if ($issues === []) {
return null;
}
usort($issues, static fn (array $a, array $b): int => strcmp((string) ($b['date'] ?? ''), (string) ($a['date'] ?? '')));
return $issues[0];
}
/**
* Live-Daten einer Stadionzeitung-Ausgabe frisch berechnen: Tabellen aller
* Mannschaften, letzte Ergebnisse, Vorschau (nächstes Spiel je Team nach dem
* Spieltag) und News-Feed. Einzige Berechnungsstelle der Viewer (Entwurf,
* live bei jedem Aufruf) und bin/stadionzeitung-publish.php (friert genau
* dieses Ergebnis als `snapshot`-Feld ein) rufen dieselbe Funktion auf,
* damit Vorschau und eingefrorene Ausgabe nie auseinanderlaufen können.
*/
function stadionzeitung_build_snapshot(array $issue): array
{
$date = (string) ($issue['date'] ?? '');
$issueTeam = (string) ($issue['team_key'] ?? '');
$issueOpponent = trim((string) ($issue['opponent'] ?? ''));
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
$tabellen = [];
$ergebnisse = [];
$vorschau = [];
$gegner = null;
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
$key = (string) ($team['key'] ?? '');
if ($key === '') {
continue;
}
$tabellen[$key] = [
'name' => (string) ($team['name'] ?? ''),
'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''),
'table' => $team['table'] ?? [],
];
// Gegnerseite der tragenden Mannschaft: ausschließlich aus demselben
// Matchcenter-Datensatz wie die Tabelle ableiten. So gibt es keinen
// zweiten BFV-Abruf und der Stand wird beim Veröffentlichen zusammen
// mit den übrigen Live-Daten eingefroren.
if ($key === $issueTeam && $issueOpponent !== '') {
$table = is_array($team['table'] ?? null) ? $team['table'] : [];
$ownRow = null;
$opponentRow = null;
foreach ($table as $row) {
if (!empty($row['is_own_club'])) {
$ownRow = $row;
}
if (strcasecmp(trim((string) ($row['club'] ?? '')), $issueOpponent) === 0) {
$opponentRow = $row;
}
}
$fixture = null;
foreach ($team['next_matches'] ?? [] as $match) {
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
$clubs = [(string) ($match['home'] ?? ''), (string) ($match['away'] ?? '')];
if ($matchDate === $date && in_array($issueOpponent, $clubs, true)) {
$fixture = $match;
break;
}
}
$gegner = [
'team_name' => (string) ($team['name'] ?? ''),
'opponent' => $issueOpponent,
'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''),
'fixture' => $fixture,
'own' => $ownRow,
'opponent_stats' => $opponentRow,
];
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist
// kein Rückblick (dieselbe Regel wie im News-Feed).
$withScore = array_values(array_filter(
$team['last_results'] ?? [],
static fn (array $r): bool => isset($r['goals_home'], $r['goals_away'])
));
if ($withScore !== []) {
$ergebnisse[] = [
'team_key' => $key,
'team_name' => (string) ($team['name'] ?? ''),
'league' => (string) ($team['league'] ?? ''),
// EIN letztes Ergebnis pro Team: mit drei Mannschaften plus
// Matchcenter-Ticket passt mehr nicht zuverlässig auf die
// A5-Seite (zwei pro Team liefen über die Blattkante).
'matches' => array_slice($withScore, 0, 1),
];
}
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
// Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es
// schon); ein Spiel einer ANDEREN Mannschaft am selben Tag bleibt drin.
foreach ($team['next_matches'] ?? [] as $match) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
if (($match['home'] ?? '') === 'SPIELFREI' || ($match['away'] ?? '') === 'SPIELFREI') {
continue;
}
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
if ($date !== '' && $matchDate < $date) {
continue;
}
if ($matchDate === $date && $key === $issueTeam) {
continue;
}
$vorschau[] = [
'team_key' => $key,
'team_name' => (string) ($team['name'] ?? ''),
'league' => (string) ($team['league'] ?? ''),
'match' => $match,
];
break;
}
}
// Momente-Seite: die vier neuesten Instagram-Fotos (keine Videos — die
// haben zwar ein Vorschaubild, aber ein Standbild ohne Kontext wirkt im
// Heft wie ein kaputtes Foto).
$momente = array_slice(array_values(array_filter(
json_load('instagram')['posts'] ?? [],
static fn (array $post): bool => empty($post['is_video']) && !empty($post['image'])
)), 0, 4);
return [
'tabellen' => $tabellen,
'gegner' => $gegner,
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
'ergebnisse' => $ergebnisse,
'vorschau' => $vorschau,
// Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht
// die News-Seite den Platz, sonst klebt der CTA an der Blattkante.
'news' => array_slice(news_build_feed()['items'] ?? [], 0, 5),
'momente' => array_map(static fn (array $post): array => [
'image' => (string) $post['image'],
'alt' => (string) ($post['alt'] ?? ''),
], $momente),
];
}
/**
* Daten für die Live-Seiten einer Ausgabe: veröffentlichte Ausgaben rendern
* aus ihrem eingefrorenen `snapshot` (eine gedruckte Publikation ändert sich
* nicht mehr), Entwürfe rechnen bei jedem Aufruf frisch so sieht Felix beim
* Bauen immer den aktuellen Stand, und das Veröffentlichen friert genau
* diesen Stand ein.
*/
function stadionzeitung_live_data(array $issue): array
{
if (stadionzeitung_is_published($issue) && is_array($issue['snapshot'] ?? null)) {
return $issue['snapshot'];
}
return stadionzeitung_build_snapshot($issue);
}
/**
* Komponente rendern: component('hero', ['title' => ]).
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.
*/
function component(string $name, array $props = []): void
{
extract($props, EXTR_SKIP);
require APP_PATH . '/components/' . $name . '.php';
}
/**
* Page-Datei ausführen: sie setzt $meta und emittiert ihren Body.
* Rückgabe: [$meta, $html].
*/
function render_page(string $file): array
{
$meta = [];
ob_start();
require $file;
return [$meta, (string) ob_get_clean()];
}
/**
* Signierten Zeitstempel für die Formular-Time-Trap erzeugen.
*/
function form_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret'));
}
/**
* Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max.
* Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
* grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
*/
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
{
$parts = explode('.', $token);
if (count($parts) !== 2) {
return false;
}
[$ts, $sig] = $parts;
if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) {
return false;
}
$age = time() - (int) $ts;
return $age >= $min && $age <= $max;
}
/**
* Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR X-Forwarded-For
* ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet.
*/
function client_ip(): string
{
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
/**
* Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein
* APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den
* letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst
* false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt
* (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt.
*/
function rate_limit_ok(string $key, int $max, int $window): bool
{
$dir = STORAGE_PATH . '/ratelimit';
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return true;
}
// Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen.
if (random_int(1, 100) === 1) {
foreach (glob($dir . '/*.json') ?: [] as $stale) {
if ((int) @filemtime($stale) < time() - 86400) {
@unlink($stale);
}
}
}
$file = $dir . '/' . hash('sha256', $key) . '.json';
$fh = @fopen($file, 'c+');
if ($fh === false) {
return true;
}
try {
flock($fh, LOCK_EX);
$raw = (string) stream_get_contents($fh);
$hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : [];
$now = time();
$hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window));
if (count($hits) >= $max) {
return false;
}
$hits[] = $now;
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, (string) json_encode($hits));
return true;
} finally {
flock($fh, LOCK_UN);
fclose($fh);
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* EINZIGE Log-Schreibstelle des Projekts Web wie CLI, alle Kanäle.
*
* $channel Dateiname ohne Endung: mail | spam | instagram | matchcenter
* $level INFO (Normalbetrieb) | WARN (Auffälligkeit, Betrieb läuft weiter) |
* ERROR (Funktion ist ausgefallen)
*
* Format: [2026-07-27T08:11:45+00:00] ERROR Meldung
*
* Ein Ereignis = eine Zeile (Umbrüche werden ersetzt), damit die Logs grep- und
* auswertbar bleiben und Meldungstexte keine Zeilen fälschen können. ERROR läuft
* zusätzlich in eine gemeinsame `error.log` eine Datei beantwortet die Frage
* „ist gerade etwas kaputt?", die Kanal-Logs behalten den vollen Verlauf.
*
* Datensparsam: hier gehören keine Formularinhalte und keine Klartext-IPs hinein.
* Übersicht über alle Kanäle: php bin/logs.php
*/
function log_write(string $channel, string $level, string $message): void
{
$channel = preg_replace('/[^a-z0-9-]/', '', strtolower($channel)) ?: 'app';
$level = strtoupper($level);
if (!in_array($level, ['INFO', 'WARN', 'ERROR'], true)) {
$level = 'INFO';
}
$message = trim((string) preg_replace('/\s*[\r\n]+\s*/', ' | ', $message));
$stamp = date('c');
$dir = STORAGE_PATH . '/logs';
@file_put_contents($dir . '/' . $channel . '.log', "[{$stamp}] {$level} {$message}\n", FILE_APPEND | LOCK_EX);
if ($level === 'ERROR') {
@file_put_contents($dir . '/error.log', "[{$stamp}] {$channel} {$message}\n", FILE_APPEND | LOCK_EX);
}
}
/**
* Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) reine
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
* Beobachtbarkeit zum Tunen der Schwellen. Bewusst INFO: ein geblockter Bot ist
* kein Fehler, sondern der Schutz bei der Arbeit. Datensparsam: nur ein gekürzter,
* gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay.
*/
function log_spam(string $route, string $reason): void
{
$ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12);
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
log_write('spam', 'INFO', "{$route} {$reason} ip={$ipHash}");
}
/**
* Not-Aus für die Formular-Actions: false, wenn die Konfiguration unvollständig ist
* (dann liefe die Seite auf config.example.php öffentliches app_secret, kein
* SMTP-Passwort). Protokolliert den Grund einmal pro Versuch.
*/
function config_ready(string $route): bool
{
$problems = config_problems();
if ($problems === []) {
return true;
}
log_write('mail', 'ERROR', "{$route}: Formular abgewiesen, Konfiguration unvollständig — " . implode(' ', $problems));
return false;
}
/**
* Formular-Mail über All-Inkl-SMTP verschicken EINZIGE Versandstelle der Seite.
* Alle Actions gehen hierdurch, damit Timeout, Verschlüsselung und Fehler-Logging
* an genau einem Ort stehen.
*
* $opts: subject, body (Pflicht) · to, to_name (Default: smtp.to) ·
* reply_to ['email'=>, 'name'=>] · context (Präfix im Log).
*
* Gibt true/false zurück und wirft nie Versandfehler landen in
* storage/logs/mail.log und werden dem Besucher nie im Klartext gezeigt.
* Timeout bewusst kurz (10 s): der Versand hängt im Request-Pfad, ein toter
* SMTP-Server darf den Besucher nicht bis zur max_execution_time blockieren.
*/
function send_mail(array $opts): bool
{
$smtp = (array) config('smtp', []);
$context = (string) ($opts['context'] ?? 'Mailversand');
try {
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = (string) $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = (string) $smtp['username'];
$mail->Password = (string) $smtp['password'];
$mail->CharSet = PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
$mail->Timeout = 10;
$mail->XMailer = ' '; // kein X-Mailer-Header (verrät sonst die PHPMailer-Version)
$mail->setFrom((string) $smtp['from'], (string) ($smtp['from_name'] ?? ''));
$mail->addAddress((string) ($opts['to'] ?? $smtp['to']), (string) ($opts['to_name'] ?? ''));
if (!empty($opts['reply_to']['email'])) {
$mail->addReplyTo((string) $opts['reply_to']['email'], (string) ($opts['reply_to']['name'] ?? ''));
}
$mail->Subject = (string) $opts['subject'];
$mail->Body = (string) $opts['body'];
$mail->send();
// Erfolg als INFO mitschreiben. Vorher loggte nur der Fehlerfall, damit war ein
// funktionierender Versand von einem stummen nicht zu unterscheiden — beim
// Deploy am 31.07.2026 stand im Log gar kein mail-Kanal, was „nichts
// abgeschickt" und „einwandfrei versandt" gleichzeitig bedeuten konnte.
// Nur der Kontext (welches Formular), NIE Empfänger, Absender oder Inhalt:
// das verbietet die Logging-Regel, und für die Frage „läuft der Versand?"
// braucht es davon auch nichts.
log_write('mail', 'INFO', "{$context} versandt.");
return true;
} catch (Throwable $e) {
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
log_write('mail', 'ERROR', "{$context} fehlgeschlagen: " . $e->getMessage());
return false;
}
}
/**
* Formular-Zustand nach fehlgeschlagener Validierung (Eingaben + Feldfehler), damit
* der 422-Re-Render die Werte zurückschreiben kann. Ohne Argumente = nur lesen.
* $old: Feldname Wert. $errors: Feld-ID (wie im Markup) Fehlermeldung.
*/
function form_state(?array $old = null, ?array $errors = null): array
{
static $state = ['old' => [], 'errors' => []];
if ($old !== null) {
$state = ['old' => $old, 'errors' => $errors ?? []];
}
return $state;
}
/**
* Zuvor eingegebener Wert eines Feldes ('' wenn keiner) für value/selected/checked.
*/
function form_old(string $name): string
{
return (string) (form_state()['old'][$name] ?? '');
}
/**
* Serverseitige Fehlermeldung zu einer Feld-ID ('' wenn das Feld in Ordnung war).
*/
function form_field_error(string $id): string
{
return (string) (form_state()['errors'][$id] ?? '');
}
/**
* True, wenn der aktuelle Request ein Formular mit Feldfehlern rendert.
*/
function form_has_errors(): bool
{
return form_state()['errors'] !== [];
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* Die Varianten des EINEN Kontaktformulars, adressiert über das Hidden-Feld
* `department`. EINZIGE Quelle für Auswahlliste, Empfänger und Feldregeln
* `app/components/contact-form.php` und `app/actions/contact-submit.php` lesen
* beide hier, damit die Whitelist nicht an zwei Stellen gepflegt werden muss
* (das war sie vorher, mit einem Kommentar als einziger Absicherung).
*
* Ein unbekannter Wert fällt auf die allgemeine Variante zurück ein
* manipuliertes Feld kann damit weder einen fremden Empfänger erreichen noch
* die Validierung umgehen.
*
* `to` leer heißt: Standardempfänger aus `config('smtp.to')`. Die
* Turn-Variante geht an `club.email_turnen`, weil die Abteilung ihre Anfragen
* selbst beantwortet (so war es auch auf der alten Seite).
*/
function contact_variant(string $department): array
{
if ($department !== 'turnen') {
return [
'department' => '',
'interests' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
'interest_required' => false,
'ask_subject' => true,
Turn-Anfrageformular: Feld „Geburtsdatum des Kindes" wieder da Auf der alten Seite gab es das Feld, beim Neubau ist es untergegangen. Gegen 2024.tsv08kulmbach.de geprüft: dort hieß es input_alter_kind, war type="date", OPTIONAL und trug das Label „Geburtsdatum des Kindes" — genau so wieder eingebaut. Die Abteilung braucht es, weil die Gruppen nach Alter eingeteilt sind. Nur in der Turn-Variante. In der allgemeinen wäre es sinnlos und würde ohne Not personenbezogene Daten Minderjähriger erheben. Dafür drei Stellen: - contact_variant(): Schalter ask_birthdate (true nur für turnen) - contact-form.php: Feld definiert, bedingt gerendert, direkt nach der Gruppenauswahl wie auf der alten Seite - contact-submit.php: Validierung UND Rücksetzen auf leer, wenn die Variante das Feld nicht anbietet — sonst könnte ein manipulierter POST es an der allgemeinen Variante vorbei einschmuggeln Validierung serverseitig, nicht nur per type=date: echtes Datum, nicht in der Zukunft, nicht vor 1900. createFromFormat mit Rückvergleich, weil PHP sonst Scheindaten wie 2019-02-31 stillschweigend auf den 3. März verschiebt. Geprüft mit ausreichend altem Token (die Time-Trap verlangt 3 s, meine ersten Versuche liefen deshalb als Bot durch): 2019-02-31 abgewiesen, 2030-01-01 abgewiesen, gültiges Datum passiert. Ohne JS bleibt der Wert bei HTTP 422 erhalten (value="2019-03-12" im Re-Render nachgewiesen). Kein neues Markup: form-field kann type=date und min/max längst, die Prop für den Hilfetext heißt hint (nicht help, das war mein erster Fehlgriff). Docblock der Komponente um date und min/max ergänzt. Datenschutzerklärung mitgezogen, wie die Projektregel es für neue Formularfelder verlangt — mit Zweckbindung (Zuordnung zur Altersgruppe) und dem ausdrücklichen Hinweis, dass es keine Pflichtangabe ist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:10:47 +02:00
'ask_birthdate' => false,
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
'message_required' => true,
'to' => '',
'context' => 'Kontaktanfrage',
'subject_prefix' => 'Kontaktanfrage',
];
}
// Die Auswahl ist keine eigene Liste, sondern sind die Disziplinen aus
// data/turnen.json — so heißen die Optionen immer genau wie die Seiten, auf
// die sie sich beziehen, auch wenn eine Disziplin dazukommt oder wegfällt.
$interests = [];
foreach (json_load('turnen')['disciplines'] ?? [] as $discipline) {
if (!empty($discipline['hero']['title'])) {
$interests[] = (string) $discipline['hero']['title'];
}
}
$club = json_load('club');
return [
'department' => 'turnen',
'interests' => $interests,
// Pflicht, anders als in der allgemeinen Variante: die Disziplin ist der
// Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
'interest_required' => true,
'ask_subject' => false,
Turn-Anfrageformular: Feld „Geburtsdatum des Kindes" wieder da Auf der alten Seite gab es das Feld, beim Neubau ist es untergegangen. Gegen 2024.tsv08kulmbach.de geprüft: dort hieß es input_alter_kind, war type="date", OPTIONAL und trug das Label „Geburtsdatum des Kindes" — genau so wieder eingebaut. Die Abteilung braucht es, weil die Gruppen nach Alter eingeteilt sind. Nur in der Turn-Variante. In der allgemeinen wäre es sinnlos und würde ohne Not personenbezogene Daten Minderjähriger erheben. Dafür drei Stellen: - contact_variant(): Schalter ask_birthdate (true nur für turnen) - contact-form.php: Feld definiert, bedingt gerendert, direkt nach der Gruppenauswahl wie auf der alten Seite - contact-submit.php: Validierung UND Rücksetzen auf leer, wenn die Variante das Feld nicht anbietet — sonst könnte ein manipulierter POST es an der allgemeinen Variante vorbei einschmuggeln Validierung serverseitig, nicht nur per type=date: echtes Datum, nicht in der Zukunft, nicht vor 1900. createFromFormat mit Rückvergleich, weil PHP sonst Scheindaten wie 2019-02-31 stillschweigend auf den 3. März verschiebt. Geprüft mit ausreichend altem Token (die Time-Trap verlangt 3 s, meine ersten Versuche liefen deshalb als Bot durch): 2019-02-31 abgewiesen, 2030-01-01 abgewiesen, gültiges Datum passiert. Ohne JS bleibt der Wert bei HTTP 422 erhalten (value="2019-03-12" im Re-Render nachgewiesen). Kein neues Markup: form-field kann type=date und min/max längst, die Prop für den Hilfetext heißt hint (nicht help, das war mein erster Fehlgriff). Docblock der Komponente um date und min/max ergänzt. Datenschutzerklärung mitgezogen, wie die Projektregel es für neue Formularfelder verlangt — mit Zweckbindung (Zuordnung zur Altersgruppe) und dem ausdrücklichen Hinweis, dass es keine Pflichtangabe ist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:10:47 +02:00
// Geburtsdatum nur hier: die Turnabteilung braucht bei Anfragen für Kinder
// die Altersgruppe, um die passende Gruppe zuzuordnen (Mäuse, Tiger,
// Hühner …). Gab es auf der alten Seite als optionales Feld
// „Geburtsdatum des Kindes" und ist beim Neubau untergegangen.
// In der allgemeinen Variante wäre es sinnlos und würde unnötig
// personenbezogene Daten von Minderjährigen erheben.
'ask_birthdate' => true,
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// Bewusst optional (wie auf der alten Seite): „Interessiere mich für
// Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
// Disziplin stehen ja schon da.
'message_required' => false,
'to' => (string) ($club['email_turnen'] ?? ''),
'context' => 'Anfrage Turnabteilung',
'subject_prefix' => 'Anfrage Turnen',
];
}
/**
* Antwort auf eine fehlgeschlagene Formular-Validierung ohne JS: die absendende
* Seite mit HTTP 422 direkt neu rendern mit erhaltenen Eingaben und Feldfehlern.
* Bewusst kein PRG-Redirect, weil die Werte sonst verloren gingen (und ein
* Session-Cookie dafür unverhältnismäßig wäre). Die Statuszeile bekommt beim
* Re-Render `autofocus`, damit der Fokus auch ohne JS in der Fehlermeldung landet.
*/
function render_form_invalid(string $slug, array $old, array $errors): never
{
form_state($old, $errors);
$routes = require APP_PATH . '/routes.php';
$current = isset($routes[$slug]) ? $slug : '';
http_response_code(422);
header('Cache-Control: no-store');
[$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
require APP_PATH . '/layout.php';
exit;
}
/**
* BreadcrumbList-Knoten: $items = [['name'=>, 'slug'=>], ] (Reihenfolge = Pfad).
*/
function breadcrumb_schema(array $items): array
{
$list = [];
foreach (array_values($items) as $i => $item) {
$list[] = [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
'item' => abs_url($item['slug']),
];
}
return ['@type' => 'BreadcrumbList', 'itemListElement' => $list];
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* Redaktioneller Text aus data/*.json darf interne Deeplinks im Format
* [Label](slug) tragen slug wie in routes.php, optional mit #anker
* ('mitmachen', 'fussball#kontakt') oder nur ein Anker auf derselben Seite
* ('#kontakt'). Kein HTML im JSON: inline_links_html() rendert die Links als <a>
* und escaped alles andere, inline_links_text() liefert den reinen Text (für
* das FAQPage-Schema und jede andere HTML-freie Ausgabe).
*
* Genutzt von den FAQ-Antworten (beide FAQ-Komponenten, faq_schema()) und vom
* Artikelkörper der News-Detailseite. Die Escaping-Garantie ist der Wert dieser
* Funktion: sie ist die einzige Stelle, an der aus JSON HTML entsteht jede
* Erweiterung (Fettung, Listen, externe URLs) wäre eine neue Angriffsfläche.
*/
function inline_links_html(string $answer): string
{
$parts = preg_split('/\[([^\]]+)\]\(([a-z0-9\/#_-]*)\)/i', $answer, -1, PREG_SPLIT_DELIM_CAPTURE);
$html = '';
foreach ($parts as $i => $part) {
if ($i % 3 === 0) { // Text zwischen den Links
$html .= e($part);
} elseif ($i % 3 === 1) { // Label — der Slug folgt als nächstes Element
$target = $parts[$i + 1];
[$slug, $anchor] = array_pad(explode('#', $target, 2), 2, null);
$href = ($slug === '' ? '' : url($slug)) . ($anchor !== null ? '#' . $anchor : '');
$html .= '<a href="' . e($href) . '">' . e($part) . '</a>';
}
}
return $html;
}
function inline_links_text(string $answer): string
{
return preg_replace('/\[([^\]]+)\]\([a-z0-9\/#_-]*\)/i', '$1', $answer) ?? $answer;
}
/**
* FAQPage-Knoten aus [['q'=>, 'a'=>], ]. Leere Paare werden übersprungen;
* ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus).
*/
function faq_schema(array $faq): array
{
$questions = [];
foreach ($faq as $item) {
if (empty($item['q']) || empty($item['a'])) {
continue;
}
$questions[] = [
'@type' => 'Question',
'name' => $item['q'],
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
'acceptedAnswer' => ['@type' => 'Answer', 'text' => inline_links_text($item['a'])],
];
}
return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions];
}
/**
* Seiten-Schema-Knoten zusammenstellen (für $meta['schema']):
* Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten.
*/
function page_schema(array $breadcrumb, array $faq = []): array
{
$nodes = [];
if ($breadcrumb !== []) {
$nodes[] = breadcrumb_schema($breadcrumb);
}
if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) {
$nodes[] = $faqNode;
}
return $nodes;
}
/**
* WebSite-Knoten (für die Startseite): definiert die Site als Entität und
* verweist via publisher auf den Club (#club). Keine SearchAction — es gibt
* keine Site-Suche.
*/
function website_schema(): array
{
$club = json_load('club');
return [
'@type' => 'WebSite',
'@id' => abs_url() . '#website',
'url' => abs_url(),
'name' => $club['name'],
'inLanguage' => 'de-DE',
'publisher' => ['@id' => abs_url() . '#club'],
];
}
/**
* Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club
* via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage.
* $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug.
*/
function team_schema(array $team, string $slug): array
{
$teamNode = [
'@type' => 'SportsTeam',
'name' => $team['name'],
'sport' => 'Fußball',
'url' => abs_url($slug),
'memberOf' => ['@id' => abs_url() . '#club'],
];
if (!empty($team['hero']['text'])) {
$teamNode['description'] = $team['hero']['text'];
}
$nodes = [$teamNode];
$nodes = array_merge($nodes, page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => $team['name'], 'slug' => $slug],
],
$team['faq'] ?? []
));
return $nodes;
}
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
2026-06-19 10:57:14 +02:00
/**
* 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;
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
/**
* Event-Knoten für ein Club-Event (data/events.json: title, date_start, date_end?,
* location?, text?). Analog sportsevent_nodes(), nur für die manuell gepflegten
* Vereins-Events statt Spieltermine. Fehlt title/date_start null (Aufrufer filtert).
*
* $slug: interner Pfad der Detailseite, falls das Event eine hat
* (data/veranstaltungen.json). Dann bekommt der Knoten @id und url /termine
* und die Detailseite beschreiben damit nachweislich DASSELBE Event und nicht
* zwei zufällig gleich benannte. Events ohne eigene Seite lassen $slug leer.
*/
function event_schema(array $event, ?string $slug = null): ?array
{
if (empty($event['title']) || empty($event['date_start'])) {
return null;
}
$node = [
'@type' => 'Event',
'name' => $event['title'],
'startDate' => $event['date_start'] . (!empty($event['time']) ? 'T' . $event['time'] : ''),
'eventStatus' => 'https://schema.org/EventScheduled',
'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
];
if (!empty($event['date_end'])) {
$node['endDate'] = $event['date_end'];
}
if (!empty($event['location'])) {
$node['location'] = ['@type' => 'Place', 'name' => $event['location']];
}
if (!empty($event['text'])) {
$node['description'] = $event['text'];
}
if ($slug !== null && $slug !== '') {
$node['@id'] = abs_url($slug) . '#event';
$node['url'] = abs_url($slug);
}
// Hauptplakat als Event-Bild (nur Veranstaltungen haben eins) — gleiche
// Ableitung wie in news_article_schema().
$plakat = $event['plakate'][0] ?? null;
if (!empty($plakat['base']) && !empty($plakat['widths'])) {
$largest = max($plakat['widths']);
$node['image'] = rtrim((string) config('base_url'), '/') . asset("{$plakat['base']}-{$largest}.jpg");
}
return $node;
}
/**
* Article-Knoten für eine redaktionelle Meldung (data/news.json: title, date,
* text, image?). Fehlt title/date null (Aufrufer filtert). Verweist wie
* team_schema()/job_posting_schema() via #club auf den Organisation-Knoten
* (Autor und Herausgeber sind hier derselbe Verein, kein einzelner Redakteur).
*
* $pageSlug (z. B. 'news/neue-website-online') nur für Meldungen mit eigener
* Detailseite: dann bekommt der Knoten @id, url und mainEntityOfPage, damit die
* Feed-Seite /news und die Detailseite nachweislich DENSELBEN Article
* beschreiben. Gleiches Muster und dieselbe Begründung wie event_schema().
*/
function news_article_schema(array $item, ?string $pageSlug = null): ?array
{
if (empty($item['title']) || empty($item['date'])) {
return null;
}
$node = [
'@type' => 'Article',
'headline' => $item['title'],
'datePublished' => $item['date'],
'author' => ['@id' => abs_url() . '#club'],
'publisher' => ['@id' => abs_url() . '#club'],
];
if ($pageSlug !== null) {
$node['@id'] = abs_url($pageSlug) . '#article';
$node['url'] = abs_url($pageSlug);
$node['mainEntityOfPage'] = ['@type' => 'WebPage', '@id' => abs_url($pageSlug)];
}
if (!empty($item['text'])) {
$node['description'] = $item['text'];
}
if (!empty($item['image']['base']) && !empty($item['image']['widths'])) {
$largest = max($item['image']['widths']);
$node['image'] = rtrim((string) config('base_url'), '/') . asset("{$item['image']['base']}-{$largest}.jpg");
}
return $node;
}
/**
* JobPosting-Knoten für eine Ehrenamtsstelle (Seite /mitmachen). $job: Eintrag aus
* data/mitmachen.json positions[]; $pageSlug: Seiten-Slug für die Anker-URL.
* employmentType VOLUNTEER; hiringOrganization verweist via #club auf den Org-Knoten;
* jobLocation = Vereinsadresse aus club.json (Single Source). validThrough bewusst
* optional ein abgelaufenes Datum entfernt die Anzeige aktiv aus den Ergebnissen,
* deshalb nur bei echt befristeten Stellen setzen. Ohne title leerer Array (Aufrufer filtert).
*/
function job_posting_schema(array $job, string $pageSlug): array
{
if (empty($job['title'])) {
return [];
}
$club = json_load('club');
$node = [
'@type' => 'JobPosting',
'title' => $job['title'],
'description' => $job['description'] ?? ($job['summary'] ?? $job['title']),
'employmentType' => 'VOLUNTEER',
'hiringOrganization' => ['@id' => abs_url() . '#club'],
'jobLocation' => [
'@type' => 'Place',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => $club['address']['street'],
'postalCode' => $club['address']['zip'],
'addressLocality' => $club['address']['city'],
'addressCountry' => $club['address']['country'],
],
],
];
if (!empty($job['id'])) {
$node['identifier'] = [
'@type' => 'PropertyValue',
'name' => $club['name'],
'value' => $job['id'],
];
$node['url'] = abs_url($pageSlug) . '#' . $job['id'];
}
if (!empty($job['posted'])) {
$node['datePosted'] = $job['posted'];
}
if (!empty($job['valid_through'])) {
$node['validThrough'] = $job['valid_through'];
}
return $node;
}