Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
<?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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.
Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.
Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.
--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.
Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +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 asset(string $path, bool $bust = true): string
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
{
|
|
|
|
|
|
$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;
|
|
|
|
|
|
}
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
$file = PUBLIC_PATH . '/assets/' . $path;
|
|
|
|
|
|
$version = is_file($file) ? (string) filemtime($file) : '0';
|
|
|
|
|
|
return '/assets/' . $path . '?v=' . $version;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:11:58 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 00:27:19 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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], …].
|
|
|
|
|
|
*/
|
|
|
|
|
|
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$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);
|
|
|
|
|
|
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
|
|
|
|
|
|
$file = "{$destBase}-{$w}.jpg";
|
|
|
|
|
|
imagejpeg($frame, $file, $quality);
|
|
|
|
|
|
$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, ~18–20% 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'] ?? '');
|
|
|
|
|
|
|
|
|
|
|
|
$tabellen = [];
|
|
|
|
|
|
$ergebnisse = [];
|
|
|
|
|
|
$vorschau = [];
|
|
|
|
|
|
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'] ?? [],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
'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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-06-20 20:41:50 +02:00
|
|
|
|
* Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
|
|
|
|
|
|
* grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
*/
|
2026-06-20 20:41:50 +02:00
|
|
|
|
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
{
|
|
|
|
|
|
$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;
|
|
|
|
|
|
}
|
2026-06-19 08:12:05 +02:00
|
|
|
|
|
2026-06-20 20:41:50 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 20:41:50 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-06-20 20:41:50 +02:00
|
|
|
|
* 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;
|
2026-06-20 20:41:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.
Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.
Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.
--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.
Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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();
|
|
|
|
|
|
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());
|
Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.
Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.
Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.
--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.
Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00
|
|
|
|
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,
|
|
|
|
|
|
'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,
|
|
|
|
|
|
// 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',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.
Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.
Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.
--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.
Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:12:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:12:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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'])],
|
2026-06-19 08:12:05 +02:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 23:17:53 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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'],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:12:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
2026-06-20 20:47:13 +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
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 20:47:13 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|