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
This commit is contained in:
2026-07-31 14:55:27 +02:00
parent 3fb480dd78
commit a101b0c3ec
861 changed files with 75244 additions and 487 deletions

14
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Secrets — nur config.example.php wird committed
/config/config.php
/config/config.production.php
# Composer
/vendor/
@@ -8,6 +9,10 @@
/storage/logs/*
/storage/cache/*
/storage/ratelimit/*
# Druck-QR-Codes (bin/qr.php) — Zwischenprodukte für Canva, kein Repo-Inhalt.
# Wichtig: sie enthalten die base_url fest eingebacken, ein versehentlich
# eingecheckter localhost-Code wäre eine Falle.
/storage/qr/
!/storage/logs/.gitkeep
!/storage/cache/.gitkeep
!/storage/ratelimit/.gitkeep
@@ -22,6 +27,15 @@
/public/assets/img/crests/*
!/public/assets/img/crests/.gitkeep
# Dateiablage /dateien — Nutzerdaten und Laufzeit-Kram der Files Gallery.
# Committed bleiben nur system/users/*/config.php (Rechte, keine Passwörter) und
# system/include/* (Skin). system/config/config.php generiert die App selbst und
# überschreibt sie bei jedem Update — unsere Werte stehen in _filesconfig.php.
/storage/dateien/uploads/*
!/storage/dateien/uploads/.gitkeep
/storage/dateien/system/cache/
/storage/dateien/system/config/
# Referenz-Material der alten Seite — NIE committen
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)
/.context/

View File

@@ -7,7 +7,12 @@ declare(strict_types=1);
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…),
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
* Das Formular kommt von / wie auch von /kontakt.
* Das Formular kommt von / wie auch von /kontakt und /turnen.
*
* Eine Action für alle Varianten: `department` (Hidden-Feld) wählt über
* contact_variant() Auswahlliste, Feldregeln und Empfänger. Eine zweite Action
* für die Turnabteilung hätte den kompletten Spam-Schutz verdoppelt — und damit
* eine zweite Stelle, die man bei jeder Änderung mitziehen müsste.
*/
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
@@ -36,8 +41,7 @@ $token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (config_problems() !== []) {
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
if (!config_ready($route)) {
$respond(false, 'mail');
}
@@ -60,7 +64,16 @@ $subject = $field('subject');
$message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
// Variante bestimmen (unbekannter Wert fällt auf die allgemeine zurück, siehe
// contact_variant()) — dieselbe Quelle, aus der die Komponente ihr Formular baut.
$variant = contact_variant($field('department'));
$interests = $variant['interests'];
if (!$variant['interest_required']) {
$interests[] = ''; // die allgemeine Variante darf das Feld leer lassen
}
if (!$variant['ask_subject']) {
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
}
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
@@ -74,12 +87,14 @@ if (mb_strlen($phone) > 50) {
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
}
if (!in_array($interest, $interests, true)) {
$errors['contact-interest'] = 'Bitte wähle einen der angebotenen Bereiche.';
$errors['contact-interest'] = $variant['interest_required']
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
: 'Bitte wähle einen der angebotenen Bereiche.';
}
if ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject)) {
if ($variant['ask_subject'] && ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject))) {
$errors['contact-subject'] = 'Bitte gib einen Betreff an (max. 200 Zeichen, einzeilig).';
}
if ($message === '' || mb_strlen($message) > 5000) {
if (($variant['message_required'] && $message === '') || mb_strlen($message) > 5000) {
$errors['contact-message'] = 'Bitte schreib uns eine Nachricht (max. 5000 Zeichen).';
}
if (!$privacy) {
@@ -124,23 +139,28 @@ if (!rate_limit_ok('daily', 200, 86400)) {
}
// --- Versand ---
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
// Ohne Betreff-Feld trägt die Auswahl den Mail-Betreff.
$mailSubject = $variant['subject_prefix'] . ': ' . ($subject !== '' ? $subject : $interest);
$body = $variant['context'] . " über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Name: {$name}\n"
. "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
. "Betreff: {$subject}\n"
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
. str_repeat('-', 40) . "\n\n"
. $message . "\n\n"
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
. str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
$sent = send_mail([
'context' => 'Kontaktanfrage',
'subject' => 'Kontaktanfrage: ' . $subject,
'context' => $variant['context'],
'subject' => $mailSubject,
'body' => $body,
'reply_to' => ['email' => $email, 'name' => $name],
// Leer heißt: Standardempfänger aus config('smtp.to') (send_mail()).
'to' => $variant['to'] !== '' ? $variant['to'] : null,
]);
$respond($sent, $sent ? '' : 'mail');

View File

@@ -30,8 +30,7 @@ $token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (config_problems() !== []) {
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
if (!config_ready($route)) {
$respond(false, 'mail');
}
@@ -156,7 +155,7 @@ $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. "Satzung anerkannt: ja\n"
. "Datenschutz: ja\n"
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
. "Hinweis: Bankdaten werden online nicht erhoben Beitrittserklärung\n"
. "Hinweis: Bankdaten werden online nicht erhoben. Beitrittserklärung\n"
. "per Post zur Unterschrift zusenden.\n";
// Mail 1 (intern) — erfolgskritisch.

View File

@@ -29,8 +29,7 @@ $token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (config_problems() !== []) {
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
if (!config_ready($route)) {
$respond(false, 'mail');
}

View File

@@ -9,6 +9,11 @@ declare(strict_types=1);
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
* Startseite springt.
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
* Betreff, weil die Disziplin-Auswahl ihn ersetzt. Bewusst dieselbe Komponente
* und dieselbe Action: ein zweites Formular hieße ein zweiter Spam-Schutz.
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
@@ -17,26 +22,29 @@ $club = json_load('club');
$return = $return ?? '';
$intro = $intro ?? null;
$interests = ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$variant = contact_variant((string) ($department ?? ''));
$interests = $variant['interests'];
// Sichtbare Direkt-Adresse: bei einer Abteilungsanfrage die der Abteilung.
$directMail = $variant['to'] !== '' ? $variant['to'] : $club['email'];
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
// serverseitigen Feldfehler aus der Action.
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $directMail . '.',
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $directMail . '.',
];
$f = [
'name' => ['type' => 'text', 'name' => 'name', 'id' => 'contact-name', 'label' => 'Name', 'required' => true, 'autocomplete' => 'name', 'maxlength' => 200],
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests],
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => true, 'maxlength' => 5000, 'rows' => 6],
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
'privacy' => [
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true,
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
@@ -50,7 +58,7 @@ $f = [
<div class="contact__intro">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
</div>
<?php endif; ?>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
@@ -73,7 +81,11 @@ $f = [
<?php component('form-field', ['field' => $f['interest']]); ?>
</div>
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
worum es geht, und die Action baut den Mail-Betreff daraus. */ ?>
<?php if ($variant['ask_subject']): ?>
<?php component('form-field', ['field' => $f['subject']]); ?>
<?php endif; ?>
<?php component('form-field', ['field' => $f['message']]); ?>
<?php component('form-field', ['field' => $f['privacy']]); ?>
@@ -84,6 +96,7 @@ $f = [
</div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<input type="hidden" name="return" value="<?= e($return) ?>">
<input type="hidden" name="department" value="<?= e($variant['department']) ?>">
<p class="form__submit">
<button class="btn" type="submit">Nachricht senden</button>

View File

@@ -5,11 +5,16 @@ declare(strict_types=1);
/**
* CTA-Button. Props: $cta array{label: string, slug: string,
* style?: 'primary'|'outline', size?: 'sm'|'lg'}
* Alternativ zu 'slug': 'href' für nicht-Seiten-Ziele (mailto:, tel:) — gewinnt
* gegen slug/hash. Externe http-Ziele gehören hier nicht rein (siehe Regel 1).
*/
$style = $cta['style'] ?? 'primary';
$size = $cta['size'] ?? null;
$class = 'btn'
. ($style === 'outline' ? ' btn--outline' : '')
. ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : ''));
$href = !empty($cta['href'])
? $cta['href']
: url($cta['slug']) . (!empty($cta['hash']) ? '#' . $cta['hash'] : '');
?>
<a class="<?= e($class) ?>" href="<?= e(url($cta['slug'])) . (!empty($cta['hash']) ? '#' . e($cta['hash']) : '') ?>"><?= e($cta['label']) ?></a>
<a class="<?= e($class) ?>" href="<?= e($href) ?>"><?= e($cta['label']) ?></a>

View File

@@ -5,12 +5,16 @@ declare(strict_types=1);
/**
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
* Props:
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor}]}
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor, image?}]}
* $anchor string (optional, Sektions-ID)
* $title_hidden bool — Titel nur für Screenreader ausgeben (hält die Heading-Kette
* h1 → h2 → h3 intakt, wenn die Karten optisch für sich sprechen)
* $item['image'] optional: {base, widths, alt} — Bildband oben, wie team-cards.
* Bewusst eigenes <a href="#…">-Markup (nicht die cta-Komponente): cta() schickt
* jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen.
*/
$anchor = $anchor ?? null;
$title_hidden = !empty($title_hidden);
if (empty($choice['items'])) {
return;
}
@@ -18,7 +22,7 @@ if (empty($choice['items'])) {
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
<div class="container">
<?php if (!empty($choice['title'])): ?>
<h2 class="department-choice__title"><?= e($choice['title']) ?></h2>
<h2 class="<?= $title_hidden ? 'visually-hidden' : 'department-choice__title' ?>"><?= e($choice['title']) ?></h2>
<?php endif; ?>
<?php if (!empty($choice['intro'])): ?>
<p class="department-choice__intro"><?= e($choice['intro']) ?></p>
@@ -26,12 +30,19 @@ if (empty($choice['items'])) {
<ul class="department-choice__grid" role="list">
<?php foreach ($choice['items'] as $item): ?>
<li class="department-choice__card">
<?php if (!empty($item['image']['base'])): ?>
<div class="department-choice__media">
<?php component('img', ['image' => $item['image'], 'sizes' => '(max-width: 600px) 100vw, 50vw', 'class' => 'department-choice__img']); ?>
</div>
<?php endif; ?>
<div class="department-choice__body">
<h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
<?php if (!empty($item['subtitle'])): ?>
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
<?php endif; ?>
<p class="department-choice__text"><?= e($item['text']) ?></p>
<a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
</div>
</li>
<?php endforeach; ?>
</ul>

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* Schmaler Kopf einer Detailseite (statt hero): Rückweg, h1, optionaler
* Entwurf-Badge, Meta-Zeile. Props:
* $back array{href, label} — Rückweg auf die Übersicht (Pflicht)
* $title string — die h1 der Seite (Pflicht)
* $badge string (optional) — Auszeichnung neben dem Titel, z. B. 'Entwurf'
* $date_iso string (optional) — für <time datetime>
* $date_text string (optional) — sichtbares Datum (german_date()/german_date_range())
* $location string (optional) — Ort, erscheint mit Nadel-Icon hinter dem Datum
*
* Gemeinsam genutzt von der Veranstaltungs- und der News-Artikel-Seite. Beide
* haben bewusst KEINEN hero: ein Archiv-Eintrag hat kein eigenes Titelbild, und
* beim News-Artikel trägt der Aufmacher im Artikelkörper das Bild (Blog-Optik)
* statt eines cover-beschnittenen Hintergrunds.
*
* Der Kopf der Stadionzeitungs-Ausgabe (.stadionzeitung-issue-head) bleibt
* bewusst eine eigene Fassung und wird hier NICHT mitbedient: er ist
* Viewer-Chrome, kein Artikelkopf — Drei-Zonen-Grid, Titel als <p> (die
* Seitenbilder tragen die Ausgabe), Druck-Knopf, zwei Badge-Varianten und zwei
* eigene @media-print-Regeln. Ihn in dieselbe Komponente zu zwingen, hieße eine
* Prop-Matrix für zwei Layouts zu bauen.
*/
$back = $back ?? [];
$title = (string) ($title ?? '');
$badge = (string) ($badge ?? '');
$dateIso = (string) ($date_iso ?? '');
$dateText = (string) ($date_text ?? '');
$location = (string) ($location ?? '');
$hasMeta = $dateText !== '' || $location !== '';
?>
<header class="detail-head">
<div class="container detail-head__inner">
<?php if (!empty($back['href'])): ?>
<a class="detail-head__back" href="<?= e($back['href']) ?>">
<?= icon('chevron-left') ?> <?= e($back['label'] ?? 'Zurück') ?>
</a>
<?php endif; ?>
<h1 class="detail-head__title">
<?= e($title) ?>
<?php if ($badge !== ''): ?>
<span class="detail-head__badge"><?= e($badge) ?></span>
<?php endif; ?>
</h1>
<?php if ($hasMeta): ?>
<p class="detail-head__meta">
<?php if ($dateText !== ''): ?>
<time<?= $dateIso !== '' ? ' datetime="' . e($dateIso) . '"' : '' ?>><?= e($dateText) ?></time>
<?php endif; ?>
<?php if ($location !== ''): ?>
<?php if ($dateText !== ''): ?>
<span class="detail-head__sep" aria-hidden="true">·</span>
<?php endif; ?>
<?php /* Icon und Ort in EINEM span, sonst bricht die Zeile am Handy zwischen
Nadel und Ortsname um. */ ?>
<span class="detail-head__ort"><?= icon('geo-alt') ?> <?= e($location) ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</header>

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* Aufmacher für einen Vereins-Termin — das Gegenstück zu match-feature für
* alles, was kein Spiel ist (data/events.json). Gleiches Bauprinzip: drei
* Schriftrollen (Meta/Ort in Abel · Titel in Coolvetica · Zeitzeile in Abel),
* keine Badges, keine Icons, die Wirkung kommt aus der Größe. Statt des
* Fixtures mit Wappen steht links das Datums-Chip (Tag über Monat, dieselbe
* Optik wie in der termine-list).
* Props:
* $event Eintrag aus data/events.json (title, date_start, date_end?, time?, location?)
*/
$e = $event;
$dateStart = (string) ($e['date_start'] ?? '');
$dateEnd = $e['date_end'] ?? null;
$time = (string) ($e['time'] ?? '');
$chip = german_date_chip($dateStart);
$dateText = german_date_range($dateStart, $dateEnd);
// Ganztägige Events (ohne time) sollen im Countdown nicht „heute, 00:00 Uhr"
// behaupten — deshalb der zweite Parameter.
$when = match_when($dateStart !== '' ? $dateStart . 'T' . ($time !== '' ? $time : '00:00') : '', $time !== '');
$dateLine = trim($dateText . ($time !== '' ? ' · ' . $time . ' Uhr' : ''));
?>
<article class="event-feature">
<div class="event-feature__head">
<?php if ($chip['day'] !== ''): ?>
<span class="event-feature__chip" aria-hidden="true">
<span class="event-feature__chip-day"><?= e($chip['day']) ?></span>
<span class="event-feature__chip-month"><?= e($chip['month']) ?></span>
</span>
<?php endif; ?>
<span class="event-feature__body">
<span class="event-feature__title"><?= e($e['title'] ?? '') ?></span>
<?php if (!empty($e['location'])): ?>
<span class="event-feature__where"><?= e($e['location']) ?></span>
<?php endif; ?>
</span>
</div>
<p class="event-feature__when">
<?php if ($dateStart !== ''): ?>
<time datetime="<?= e($dateStart) ?>"><?= e($dateLine) ?></time>
<?php else: ?>
<span><?= e($dateLine) ?></span>
<?php endif; ?>
<?php if ($when['countdown'] !== ''): ?>
<span class="event-feature__sep" aria-hidden="true">·</span>
<span class="event-feature__countdown" data-countdown="<?= e($when['iso']) ?>"><?= e($when['countdown']) ?></span>
<?php endif; ?>
</p>
</article>

View File

@@ -41,7 +41,7 @@ if ($faq === [] && !$hasPerson) {
<?php foreach ($faq as $item): ?>
<details class="faq__item">
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
<div class="faq__a"><p><?= e($item['a']) ?></p></div>
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
</details>
<?php endforeach; ?>
</div>

View File

@@ -27,7 +27,7 @@ if ($faq === []) {
<?php foreach ($faq as $item): ?>
<details class="faq__item">
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
<div class="faq__a"><p><?= e($item['a']) ?></p></div>
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
</details>
<?php endforeach; ?>
</div>

View File

@@ -10,7 +10,7 @@ $nav = json_load('navigation');
?>
<header class="site-header">
<div class="container site-header__inner">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> Startseite">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?>, Startseite">
<img class="site-header__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<span class="site-header__name" aria-hidden="true"><?= e($club['name']) ?></span>
</a>

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* Glas-Karte im Startseiten-Hero: die nächsten Termine des Vereins als
* automatisch wechselnder Slider. Props:
* $items aus upcoming_highlights() (app/helpers.php) — Spiele und
* Vereins-Events chronologisch gemischt, je Eintrag type/label/href/
* link_text plus die rohen Daten für die Innenvariante
*
* Zwei Innenvarianten, eine Karte: 'spiel' rendert match-feature (Heim über
* Gast mit Wappen, wie im Matchcenter-Band), 'event' rendert event-feature
* (Datums-Chip + Titel + Ort). Die Beschriftung („Nächstes Spiel · 1. Mannschaft")
* kommt fertig aus dem Helper; die Meta-Zeile von match-feature bleibt in der
* Karte per CSS aus, damit Mannschaft/Liga nicht zweimal dastehen.
*
* Antrieb ist der generische assets/js/carousel.js (dieselben data-Hooks wie
* banner-slider): Crossfade, Fortschrittsring im aktiven Indikator, Pause-Taste
* (WCAG 2.2.2), Pause bei Hover/Fokus, kein Autoplay bei prefers-reduced-motion.
* Ohne JS ist nur der nächste Termin sichtbar und verlinkt (die weiteren Folien
* tragen hidden, die Steuerung bleibt verborgen, weil sie funktionslos wäre).
*
* Die Karten-Wurzel ist bewusst ein div und nicht selbst der Link: die
* Steuer-Buttons liegen in der Karte, verschachtelte interaktive Elemente wären
* ungültig. Verlinkt ist deshalb der Inhalt jeder Folie.
*/
$items = $items ?? [];
if ($items === []) {
return;
}
$total = count($items);
?>
<div class="hero__upcoming"<?= $total > 1 ? ' data-carousel aria-roledescription="Karussell" aria-label="Nächste Termine"' : '' ?>>
<ul class="hero__upcoming-track" id="hero-upcoming-track" data-carousel-track aria-live="off">
<?php foreach ($items as $i => $item): ?>
<li class="hero__upcoming-slide"<?= $total > 1 ? ' data-carousel-slide role="group" aria-roledescription="Folie" aria-label="' . ($i + 1) . ' von ' . $total . '"' : '' ?><?= $i === 0 ? '' : ' hidden' ?>>
<a class="hero__upcoming-link" href="<?= e($item['href']) ?>">
<p class="hero__upcoming-label"><?= e($item['label']) ?></p>
<?php if ($item['type'] === 'spiel'): ?>
<?php component('match-feature', [
'match' => $item['match'],
'team_name' => $item['team_name'],
'league' => $item['league'],
]); ?>
<?php else: ?>
<?php component('event-feature', ['event' => $item['event']]); ?>
<?php endif; ?>
<span class="visually-hidden"><?= e($item['link_text']) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php if ($total > 1): ?>
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
<div class="hero__upcoming-controls" data-carousel-controls hidden>
<div class="hero__upcoming-indicators" role="group" aria-label="Termin wählen">
<?php foreach ($items as $i => $item): ?>
<button class="hero__upcoming-dot" type="button" data-carousel-dot aria-controls="hero-upcoming-track" aria-label="Termin <?= ($i + 1) ?> von <?= $total ?>: <?= e($item['label']) ?>">
<span class="hero__upcoming-progress" data-carousel-progress aria-hidden="true"></span>
</button>
<?php endforeach; ?>
</div>
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
<?php /* Kein btn--sm: dessen 44px kommen über ::after, die Fläche würde
in dieser Reihe den letzten Indikator überlappen (siehe CLAUDE.md). */ ?>
<button class="btn btn--icon hero__upcoming-toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="hero-upcoming-track" hidden>
<?= icon('pause-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--pause') ?>
<?= icon('play-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
</button>
</div>
<?php endif; ?>
</div>

View File

@@ -5,21 +5,30 @@ declare(strict_types=1);
/**
* Vollflächiger Hero mit Hintergrundbild/-video, Titel, Intro und CTAs.
* Props: $hero (array aus home.json: title, text, image, video?, ctas, align?, compact?,
* display?, kicker?, scroll_target?)
* align-Varianten: 'center' (mittig zentriert), 'bottom-left' (Default).
* display?, kicker?, scroll_target?), $upcoming (optional, s. home.php:
* die nächsten Termine aus upcoming_highlights() — Spiele und Vereins-Events
* gemischt, gerendert über die hero-upcoming-Komponente)
* align-Varianten: 'center' (mittig zentriert, Konvention für alle Unterseiten seit 27.07.2026),
* 'bottom-left' (ältere Variante, technischer Fallback — für neue Seiten nicht mehr verwenden).
* compact: true → niedrigere Variante für Unterseiten-Header.
* display: true → großer Editorial-Look (Riesen-Titel, Outline-Tagline, Kicker, Scroll-Cue).
*
* KONVENTION (siehe CLAUDE.md → Hero-Regel): display + kicker + tagline + video sind der
* Startseite vorbehalten. Unterseiten-Heros sind einheitlich „Überschrift + Teaser":
* nur title + text, dazu compact: true und align: 'bottom-left'. Kein kicker/tagline,
* nur title + text, dazu compact: true und align: 'center'. Kein kicker/tagline,
* ctas nur als Ausnahme.
*
* Liegt mindestens ein Termin in $upcoming, läuft der Display-Hero zweispaltig statt
* zentriert („hero--duo"): links Titel/Text/CTAs linksbündig, rechts die Termin-Karte als
* Live-Aufhänger. Ohne Termine (Sommerpause) bleibt es beim zentrierten Hero ohne Karte.
*/
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'bottom-left';
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'center';
$compact = !empty($hero['compact']);
$display = !empty($hero['display']);
$upcoming = $upcoming ?? [];
$duo = $display && $upcoming !== [];
?>
<section class="hero<?= $align === 'center' ? ' hero--center' : '' ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?>">
<section class="hero<?= $duo ? '' : ($align === 'center' ? ' hero--center' : '') ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?><?= $duo ? ' hero--duo' : '' ?>">
<div class="hero__media" aria-hidden="true">
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
<?php if (!empty($hero['video'])): ?>
@@ -27,7 +36,9 @@ $display = !empty($hero['display']);
<video class="hero__video" src="<?= e(asset($hero['video'])) ?>" autoplay muted loop playsinline preload="none" tabindex="-1"></video>
<?php endif; ?>
</div>
<div class="container hero__content">
<div class="container hero__content<?= $duo ? ' hero__content--duo' : '' ?>">
<?php if ($duo): ?>
<div class="hero__primary">
<?php if (!empty($hero['kicker'])): ?>
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
<?php endif; ?>
@@ -41,6 +52,24 @@ $display = !empty($hero['display']);
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
<?php else: ?>
<?php if (!empty($hero['kicker'])): ?>
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
<?php endif; ?>
<h1 class="hero__title"><?= e($hero['title']) ?><?php if (!empty($hero['tagline'])): ?> <span class="hero__tagline"><?= e($hero['tagline']) ?></span><?php endif; ?></h1>
<?php if (!empty($hero['text'])): ?>
<p class="hero__text"><?= e($hero['text']) ?></p>
<?php endif; ?>
<?php if (!empty($hero['ctas'])): ?>
<div class="hero__actions">
<?php foreach ($hero['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($display): ?>

View File

@@ -16,7 +16,7 @@ $maxPosts = (int) config('instagram.max_posts', 9);
<div class="container">
<h2>Aktuelles aus dem Fußball</h2>
<?php if ($posts !== []): ?>
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?> folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?>. Folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
<ul class="instagram__grid">
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
<li class="instagram__item">
@@ -37,5 +37,6 @@ $maxPosts = (int) config('instagram.max_posts', 9);
<p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
</div>
<?php endif; ?>
<p class="instagram__more"><a class="btn btn--outline" href="<?= e(url('news')) ?>">Alle News ansehen</a></p>
</div>
</section>

View File

@@ -22,9 +22,11 @@ $hasReleg = in_array('relegation', $zones, true);
// Vor dem 1. Spieltag liefert der BFV für JEDE Mannschaft Platz 1 und dieselbe
// Auf-/Abstiegszone. Beides ist dann bedeutungslos und lässt die Tabelle kaputt
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken und
// stattdessen sagen, dass noch nichts gewertet ist. Testspiele zählen hier nicht
// mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also zu Recht nicht.
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken.
// Ein erklärender Hinweissatz stand hier mal und ist bewusst raus (Felix,
// 29.07.2026): eine Tabelle mit 0 Spielen erklärt sich selbst. Testspiele
// zählen nicht mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also
// zu Recht nicht.
$notStarted = array_sum(array_map(
static fn (array $r): int => (int) ($r['played'] ?? 0),
$table
@@ -33,9 +35,6 @@ if ($notStarted) {
$hasPromo = $hasPromoPlayoff = $hasRelegPlayoff = $hasReleg = false;
}
?>
<?php if ($notStarted): ?>
<p class="league-table__note">Die Liga hat noch nicht begonnen gewertet wird ab dem 1. Spieltag.</p>
<?php endif; ?>
<div class="table-scroll-frame">
<div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0">
<table class="league-table">

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Overlay für die Bild-Großansicht. Props:
* $label string Bezeichnung des Dialogs (Screenreader)
* $count int Anzahl der Bilder — bei genau einem gibt es weder
* Zähler noch Vor/Zurück (wäre beides sinnlos)
*
* Gehört IMMER in denselben [data-lightbox-gallery]-Container wie die
* zugehörigen Bildlinks (assets/js/lightbox.js sucht von dort aus). Steht
* `hidden` im Markup, bis JS es übernimmt — ohne JS bleiben die
* Vorschaubilder normale Links auf die Bilddatei.
*/
$label = $label ?? 'Bild in Großansicht';
$count = $count ?? 0;
$mehrere = $count > 1;
?>
<div class="lightbox" role="dialog" aria-modal="true" aria-label="<?= e($label) ?>" data-lightbox-dialog hidden>
<?php /* Keine sichtbare Bildunterschrift (Beschluss 30.07.2026): der Alt-Text
trägt die Beschreibung. sizes steht statisch, das srcset erbt das Bild
vom angeklickten Vorschaubild (lightbox.js). */ ?>
<img class="lightbox__img" src="" alt="" sizes="100vw" data-lightbox-img>
<?php if ($mehrere): ?>
<p class="lightbox__counter" aria-live="polite">
<span data-lightbox-current>1</span> von <?= (int) $count ?>
</p>
<?php endif; ?>
<button class="btn btn--icon lightbox__btn lightbox__close" type="button" data-lightbox-close aria-label="Ansicht schließen">
<?= icon('x-lg') ?>
</button>
<?php if ($mehrere): ?>
<button class="btn btn--icon lightbox__btn lightbox__prev" type="button" data-lightbox-prev aria-label="Vorheriges Bild">
<?= icon('chevron-left') ?>
</button>
<button class="btn btn--icon lightbox__btn lightbox__next" type="button" data-lightbox-next aria-label="Nächstes Bild">
<?= icon('chevron-right') ?>
</button>
<?php endif; ?>
</div>

View File

@@ -18,7 +18,7 @@ $fee_options = $fee_options ?? [];
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
'mail' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
@@ -50,7 +50,7 @@ $f = [
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
<?php if ($sent): ?>
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen. Wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
<?php elseif ($error !== null): ?>
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
<?php endif; ?>

View File

@@ -22,7 +22,10 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>">
<?php if ($current !== '404'): ?>
<?php /* noindex: 404-Seiten und alles, was eine Page ausdrücklich anfordert
($meta['noindex'], z. B. Stadionzeitungs-Entwürfe) — dann auch kein
Self-Canonical, das wäre ein Widerspruch zur Nicht-Indexierung. */ ?>
<?php if ($current !== '404' && empty($meta['noindex'])): ?>
<link rel="canonical" href="<?= e($canonical) ?>">
<?php else: ?>
<meta name="robots" content="noindex">
@@ -39,9 +42,12 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="<?= e(asset('img/logo.svg')) ?>" type="image/svg+xml">
<?php /* Preload ohne ?v= (asset(..., false)): base.css fordert dieselbe Datei per
@font-face ohne Query an, mit Version wären es für den Browser zwei URLs
und der Preload bliebe ungenutzt. */ ?>
<?php foreach (['fonts/coolvetica.woff2', 'fonts/abel.woff2'] as $font): ?>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $font)): ?>
<link rel="preload" href="<?= e(asset($font)) ?>" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="<?= e(asset($font, false)) ?>" as="font" type="font/woff2" crossorigin>
<?php endif; ?>
<?php endforeach; ?>
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>

View File

@@ -7,7 +7,10 @@ declare(strict_types=1);
* Burger-Toggle + Dropdowns übernimmt assets/js/nav.js.
* Dropdowns funktionieren auch ohne JS (CSS :hover / :focus-within); JS ergänzt
* nur aria-expanded, Escape und Klick-außerhalb-schließt.
* CTAs rendert der Header (site-header__actions) — nicht hier.
* CTAs: am Desktop rendert sie der Header (site-header__actions), im
* Burger-Modus stehen sie am Ende des Menü-Overlays (site-nav__ctas) —
* dieselbe Quelle (navigation.json → ctas), CSS blendet je Breakpoint
* genau eine der beiden Stellen ein.
*/
$nav = json_load('navigation');
@@ -50,5 +53,12 @@ $href = static function (string $slug): string {
</li>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!empty($nav['ctas'])): ?>
<li class="site-nav__ctas">
<?php foreach ($nav['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</li>
<?php endif; ?>
</ul>
</nav>

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* Artikelkörper einer News-Detailseite. Props:
* $image array (optional) — Aufmacher {base, widths, alt}, above the fold
* $blocks array — data/news.json → artikel[], Blöcke mit `typ`:
* text {absaetze: string[]} Fließtext
* zwischentitel {text} h2 (h1 steht im detail-head)
* bild {base, widths, alt, caption?}
* zitat {text, quelle?}
*
* Ein unbekannter `typ` wird stillschweigend übersprungen; gemeldet wird er von
* bin/preflight.php, damit ein Tippfehler vor dem Deploy auffällt und nicht erst
* durch einen fehlenden Absatz auf der Seite.
*
* Fließtext läuft durch inline_links_html(): interne Deeplinks [Label](slug)
* werden zu <a>, alles andere escaped. Kein rohes HTML in der JSON.
*
* Bilder sind wie bei den Veranstaltungen ohne JS normale Links auf die größte
* Variante; mit JS fängt assets/js/lightbox.js den Klick ab. Das Overlay kommt
* nur dazu, wenn der Artikel überhaupt Bilder hat.
*/
$image = $image ?? null;
$blocks = $blocks ?? [];
$bilder = array_values(array_filter(
$blocks,
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild' && !empty($b['base']) && !empty($b['widths'])
));
$bildAnzahl = count($bilder);
$bildIndex = 0;
?>
<article class="section news-article"<?= $bildAnzahl > 0 ? ' data-lightbox-gallery' : '' ?>>
<div class="container">
<?php if (!empty($image['base'])): ?>
<figure class="news-article__lead">
<?php component('img', [
'image' => $image,
'sizes' => '(max-width: 768px) 100vw, 72ch',
'class' => 'news-article__lead-img',
'eager' => true,
]); ?>
</figure>
<?php endif; ?>
<div class="news-article__body prose">
<?php foreach ($blocks as $block): ?>
<?php $typ = $block['typ'] ?? ''; ?>
<?php if ($typ === 'text'): ?>
<?php foreach ($block['absaetze'] ?? [] as $absatz): ?>
<p><?= inline_links_html((string) $absatz) ?></p>
<?php endforeach; ?>
<?php elseif ($typ === 'zwischentitel' && !empty($block['text'])): ?>
<h2 class="news-article__heading"><?= e($block['text']) ?></h2>
<?php elseif ($typ === 'bild' && !empty($block['base']) && !empty($block['widths'])): ?>
<figure class="news-article__figure">
<a class="news-article__figure-link" href="<?= e(asset($block['base'] . '-' . max($block['widths']) . '.jpg')) ?>" data-lightbox="<?= $bildIndex++ ?>">
<?php component('img', [
'image' => $block,
'sizes' => '(max-width: 768px) 100vw, 72ch',
'class' => 'news-article__img',
]); ?>
<span class="visually-hidden">(größer ansehen)</span>
</a>
<?php if (!empty($block['caption'])): ?>
<figcaption class="news-article__caption"><?= e($block['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<?php elseif ($typ === 'zitat' && !empty($block['text'])): ?>
<figure class="news-article__quote">
<blockquote><p><?= e($block['text']) ?></p></blockquote>
<?php if (!empty($block['quelle'])): ?>
<figcaption class="news-article__quote-source"><?= e($block['quelle']) ?></figcaption>
<?php endif; ?>
</figure>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php if ($bildAnzahl > 0): ?>
<?php component('lightbox-overlay', ['label' => 'Bild aus dem Artikel', 'count' => $bildAnzahl]); ?>
<?php endif; ?>
</article>

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
/**
* Zeilen-Liste für die News-Seite: mischt redaktionelle Meldungen, Instagram-
* Posts und Ergebnisse. Props:
* $items array{date, title, text, image?, href?, link_text?, source}
* `link_text` (optional) ist die sichtbare Weiterlesen-Zeile —
* gesetzt vom Normalizer, nicht hier abgeleitet: die Komponente
* soll die Quellen nicht kennen (gleiche Prop wie in
* upcoming_highlights()/event-feature)
* $empty_text string (optional)
* $featured bool (optional) — neuester Eintrag als großer Aufmacher über
* der Liste (gleiche Hierarchie-Idee wie das „Nächste Spiele"-
* Band: ein Aufmacher, darunter Zeilen)
* $filterable bool (optional) — Filter-Leiste nach Quelle (Alle/Meldungen/
* Ergebnisse/Instagram), gleiche Bausteine wie der Stellen-Filter
* auf /mitmachen (.filter-bar + .tab, assets/js/news-filter.js).
* Ohne JS sind alle Einträge sichtbar — reine Progressive
* Enhancement. Die Seite muss news-filter.js laden.
* Kennt nichts von den drei Quellstrukturen — bekommt nur die normalisierte
* Form (app/pages/news.php baut sie).
*
* Bewusst eine Zeilen-Liste statt eines Karten-Grids (wie termine-list.php):
* die Einträge sind zu unterschiedlich hoch (hohe Bild-Posts neben kurzen
* Text-Ergebnissen) — ein Grid reißt dabei Löcher, weil kurze Karten den
* Platz neben einer hohen Karte nicht auffüllen können. Eine einspaltige
* Liste hat dieses Problem nicht, jede Zeile ist einfach so hoch wie ihr
* eigener Inhalt.
*/
$items = $items ?? [];
$emptyText = $empty_text ?? 'Aktuell keine News.';
$featured = !empty($featured);
$filterable = !empty($filterable);
$sourceLabels = [
'news' => 'Meldung',
'instagram' => 'Instagram',
'ergebnis' => 'Ergebnis',
];
$filters = [
'all' => 'Alle',
'news' => 'Meldungen',
'ergebnis' => 'Ergebnisse',
'instagram' => 'Instagram',
];
?>
<?php if ($items === []): ?>
<p class="text-muted"><?= e($emptyText) ?></p>
<?php else: ?>
<?php if ($filterable): ?>
<div class="filter-bar" role="group" aria-label="News nach Quelle filtern" data-news-filter>
<?php $i = 0; foreach ($filters as $value => $label): ?>
<button type="button" class="tab filter-bar__tab" data-filter="<?= e($value) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($featured):
$feature = array_shift($items);
$featureTag = !empty($feature['href']) ? 'a' : 'div';
$featureExternal = $feature['source'] === 'instagram';
$featureAttrs = !empty($feature['href'])
? ' href="' . e($feature['href']) . '"' . ($featureExternal ? ' target="_blank" rel="noopener"' : '')
: '';
$featureHasMedia = !empty($feature['image']) || !empty($feature['crest']);
?>
<div class="news-feature" data-source="<?= e($feature['source']) ?>">
<<?= $featureTag ?> class="news-feature__link<?= $featureHasMedia ? ' news-feature__link--media' : '' ?>"<?= $featureAttrs ?>>
<?php if (!empty($feature['image'])): ?>
<div class="news-feature__media">
<?php component('img', ['image' => $feature['image'], 'sizes' => '(max-width: 768px) 100vw, 40vw', 'class' => 'news-feature__img', 'eager' => true]); ?>
</div>
<?php elseif (!empty($feature['crest'])): ?>
<div class="news-feature__media news-feature__media--crest">
<?php component('crest', ['src' => $feature['crest']['src'], 'name' => $feature['crest']['name']]); ?>
</div>
<?php endif; ?>
<div class="news-feature__body">
<p class="news-row__meta">
<span class="news-row__badge news-row__badge--<?= e($feature['source']) ?>"><?= e($sourceLabels[$feature['source']] ?? $feature['source']) ?></span>
<?php if ($feature['date'] !== ''): ?>
<time datetime="<?= e($feature['date']) ?>"><?= e(german_date($feature['date'])) ?></time>
<?php endif; ?>
<?php if ($featureExternal): ?>
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
<?php endif; ?>
</p>
<p class="news-feature__title"><?= e($feature['title']) ?></p>
<?php if (!empty($feature['text']) && $feature['text'] !== $feature['title']): ?>
<p class="news-feature__text"><?= e($feature['text']) ?></p>
<?php endif; ?>
<?php if (!empty($feature['link_text'])): ?>
<p class="news-feature__more"><?= e($feature['link_text']) ?> <?= icon('arrow-right') ?></p>
<?php endif; ?>
</div>
</<?= $featureTag ?>>
</div>
<?php endif; ?>
<ul class="news-list">
<?php foreach ($items as $item): ?>
<?php
$tag = !empty($item['href']) ? 'a' : 'div';
$external = $item['source'] === 'instagram';
$attrs = '';
if (!empty($item['href'])) {
$attrs = ' href="' . e($item['href']) . '"' . ($external ? ' target="_blank" rel="noopener"' : '');
}
?>
<li class="news-row" data-source="<?= e($item['source']) ?>">
<<?= $tag ?> class="news-row__link"<?= $attrs ?>>
<?php if (!empty($item['image'])): ?>
<div class="news-row__media">
<?php component('img', ['image' => $item['image'], 'sizes' => '96px', 'class' => 'news-row__img']); ?>
</div>
<?php elseif (!empty($item['crest'])): ?>
<div class="news-row__media news-row__media--crest">
<?php component('crest', ['src' => $item['crest']['src'], 'name' => $item['crest']['name']]); ?>
</div>
<?php endif; ?>
<div class="news-row__body">
<p class="news-row__meta">
<span class="news-row__badge news-row__badge--<?= e($item['source']) ?>"><?= e($sourceLabels[$item['source']] ?? $item['source']) ?></span>
<?php if ($item['date'] !== ''): ?>
<time datetime="<?= e($item['date']) ?>"><?= e(german_date($item['date'])) ?></time>
<?php endif; ?>
<?php if ($external): ?>
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
<?php endif; ?>
</p>
<p class="news-row__title"><?= e($item['title']) ?></p>
<?php if (!empty($item['text']) && $item['text'] !== $item['title']): ?>
<p class="news-row__text"><?= e($item['text']) ?></p>
<?php endif; ?>
<?php if (!empty($item['link_text'])): ?>
<p class="news-row__more"><?= e($item['link_text']) ?> <?= icon('arrow-right') ?></p>
<?php endif; ?>
</div>
</<?= $tag ?>>
</li>
<?php endforeach; ?>
</ul>
<?php if ($filterable): ?>
<p class="text-muted" data-news-empty hidden>In dieser Kategorie gibt es gerade nichts Neues.</p>
<?php endif; ?>
<?php endif; ?>

View File

@@ -4,12 +4,31 @@ declare(strict_types=1);
/**
* Sponsoren/Partner-Logogrid. Props:
* $intro array{title, text, cta?} (aus home.json)
* $intro array{title, text, cta?, only?} (aus home.json bzw. dem
* partner-Block einer Veranstaltung)
* $anchor string (optional, Default 'partner') — Sektions-ID
* Logos kommen aus data/partners.json (Single Source of Truth).
*
* only: Liste von Partnernamen (exakt wie in partners.json) — für Seiten, die
* nur einen Teil der Partner zeigen, etwa die Unterstützer eines einzelnen
* Fests. Fehlt only, stehen alle da (Verhalten der Startseite). Passt kein
* Name (Tippfehler, umbenannter Partner), stehen bewusst wieder ALLE da statt
* einer leeren Logo-Wand.
*/
$anchor = $anchor ?? 'partner';
$partners = json_load('partners')['partners'] ?? [];
if (!empty($intro['only'])) {
$auswahl = array_values(array_filter(
$partners,
static fn (array $p): bool => in_array($p['name'] ?? '', $intro['only'], true)
));
if ($auswahl !== []) {
$partners = $auswahl;
}
}
?>
<section class="section partners" id="partner">
<section class="section partners" id="<?= e($anchor) ?>">
<div class="container">
<h2><?= e($intro['title']) ?></h2>
<p class="partners__intro text-muted"><?= e($intro['text']) ?></p>

View File

@@ -5,54 +5,83 @@ declare(strict_types=1);
/**
* Visitenkarte für eine Person. Props:
* $person array{department, name, phone?, email?, image?, whatsapp?}
* $level string — Überschriften-Ebene des department-Headings (Default 'h3').
* $level string — Überschriften-Ebene des NAMENS (Default 'h3'; der Name ist
* das Heading der Karte, die Abteilung nur eine Kicker-Zeile darüber).
* Eingebettet (z. B. in position-card unter einem h3) auf 'h4' setzen,
* damit die Heading-Outline nicht abflacht. Styling bleibt klassenbasiert.
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg).
* Kontakt-Link: phone → tel:, sonst email → mailto:.
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person).
* $hide_department bool — Kicker ausblenden, wenn der Kontext die Abteilung
* schon trägt (person-grid mit group_by_department).
* Ohne image rendert ein Initialen-Avatar im Vereins-Look (dunkle Fläche,
* große Initialen — dasselbe Fallback-Prinzip wie crest.php), kein generisches
* Silhouetten-Bild.
* Kontaktwege als Buttons (mobil sofort tippbar): phone → Anrufen (+ WhatsApp
* bei whatsapp=true), sonst email → E-Mail. Nummer bzw. Adresse stehen
* zusätzlich als Textzeile (ablesbar).
* Es sind reine Icon-Knöpfe im globalen .btn--icon-Format (44px, rund) und sie
* sitzen rechts NEBEN dem Text, nicht darunter — das spart pro Karte eine ganze
* Button-Zeile Höhe. Weil das Label wegfällt, trägt jeder Knopf den
* Personennamen im aria-label: im Ansprechpartner-Grid stünden sonst zwei
* Dutzend Links namens „Anrufen" untereinander.
*/
$level = $level ?? 'h3';
if (!in_array($level, ['h2', 'h3', 'h4', 'h5'], true)) {
$level = 'h3';
}
$hideDepartment = !empty($hide_department);
$image = $person['image'] ?? null;
$src = $image ?: 'img/persons/platzhalter.jpg';
if (!empty($person['phone'])) {
$contactHref = 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']);
$contactLabel = $person['phone'];
$contactIcon = 'telephone';
} elseif (!empty($person['email'])) {
$contactHref = 'mailto:' . $person['email'];
$contactLabel = $person['email'];
$contactIcon = 'envelope';
} else {
$contactHref = null;
$initials = '';
foreach (preg_split('/[\s.-]+/', trim((string) ($person['name'] ?? ''))) ?: [] as $word) {
if ($word !== '') {
$initials .= mb_substr($word, 0, 1);
}
if (mb_strlen($initials) >= 2) {
break;
}
}
$initials = mb_strtoupper($initials !== '' ? $initials : '?');
$telHref = !empty($person['phone']) ? 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']) : null;
$mailHref = ($telHref === null && !empty($person['email'])) ? 'mailto:' . $person['email'] : null;
$waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
: null;
$detail = $person['phone'] ?? $person['email'] ?? '';
?>
<article class="person-card">
<?php if ($image): ?>
<div class="person-card__media">
<img src="<?= e(asset($src)) ?>"
alt="<?= $image ? e($person['name']) : '' ?>"
<img src="<?= e(asset($image)) ?>"
alt="<?= e($person['name']) ?>"
width="480" height="600" loading="lazy" decoding="async">
</div>
<?php else: ?>
<div class="person-card__media person-card__media--initials" aria-hidden="true">
<span class="person-card__initials"><?= e($initials) ?></span>
</div>
<?php endif; ?>
<div class="person-card__body">
<<?= $level ?> class="person-card__department"><?= e($person['department']) ?></<?= $level ?>>
<p class="person-card__name"><?= e($person['name']) ?></p>
<?php if ($contactHref !== null): ?>
<a class="person-card__contact" href="<?= e($contactHref) ?>">
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span>
</a>
<div class="person-card__text">
<?php if (!$hideDepartment): ?>
<p class="person-card__department"><?= e($person['department']) ?></p>
<?php endif; ?>
<<?= $level ?> class="person-card__name"><?= e($person['name']) ?></<?= $level ?>>
<?php if ($detail !== ''): ?>
<p class="person-card__detail"><?= e($detail) ?></p>
<?php endif; ?>
</div>
<?php if ($telHref !== null || $mailHref !== null): ?>
<div class="person-card__actions">
<?php if ($telHref !== null): ?>
<a class="btn btn--icon" href="<?= e($telHref) ?>" aria-label="<?= e($person['name']) ?> anrufen"><?= icon('telephone') ?></a>
<?php endif; ?>
<?php if ($waHref !== null): ?>
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener">
<?= icon('whatsapp') ?><span>WhatsApp</span>
</a>
<a class="btn btn--outline btn--icon" href="<?= e($waHref) ?>" target="_blank" rel="noopener" aria-label="<?= e($person['name']) ?> per WhatsApp schreiben"><?= icon('whatsapp') ?></a>
<?php endif; ?>
<?php if ($mailHref !== null): ?>
<a class="btn btn--icon" href="<?= e($mailHref) ?>" aria-label="<?= e($person['name']) ?> eine E-Mail schreiben"><?= icon('envelope') ?></a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</article>

View File

@@ -11,12 +11,18 @@ declare(strict_types=1);
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
* $anchor string — Sektions-ID (Default 'ansprechpartner').
* $tinted bool — aufgehelltes Vollbreite-Band (Default true).
* $columns int — Spaltenanzahl auf Desktop (null = automatisch bis 5).
* $group_by_department bool — Karten nach Abteilung untergruppieren
* (Zwischenüberschrift je Abteilung, Karten dann ohne eigenen
* Abteilungs-Kicker; löst die Waisen-Optik bei 5 Karten im
* 4er-Raster). Reihenfolge = JSON-Reihenfolge.
* $embedded bool — nur Panel (div + h2 + Grid) statt eigener section,
* für Seiten, die mehrere Grids in EINER Sektion bündeln
* (/ansprechpartner mit Bereichs-Tabs). tinted/anchor der
* Sektion liefert dann die Seite.
*/
$data = json_load('ansprechpartner');
$anchor = $anchor ?? 'ansprechpartner';
$tinted = $tinted ?? true;
$columns = $columns ?? null;
$title = $title ?? $data['title'];
$intro = $intro ?? ($data['intro'] ?? '');
@@ -37,14 +43,11 @@ if (!empty($names)) {
if ($persons === []) {
return;
}
?>
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
<div class="container">
if (!empty($embedded)): ?>
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
<h2><?= e($title) ?></h2>
<?php if ($intro !== ''): ?>
<p class="persons__intro"><?= e($intro) ?></p>
<?php endif; ?>
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>">
<ul class="persons__grid">
<?php foreach ($persons as $person): ?>
<li>
<?php component('person-card', ['person' => $person]); ?>
@@ -52,4 +55,38 @@ if ($persons === []) {
<?php endforeach; ?>
</ul>
</div>
<?php return; endif; ?>
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
<div class="container">
<h2><?= e($title) ?></h2>
<?php if ($intro !== ''): ?>
<p class="persons__intro"><?= e($intro) ?></p>
<?php endif; ?>
<?php if (!empty($group_by_department)): ?>
<?php
$byDepartment = [];
foreach ($persons as $person) {
$byDepartment[$person['department'] ?? ''][] = $person;
}
?>
<?php foreach ($byDepartment as $departmentLabel => $departmentPersons): ?>
<h3 class="persons__group-title"><?= e($departmentLabel) ?></h3>
<ul class="persons__grid persons__grid--grouped">
<?php foreach ($departmentPersons as $person): ?>
<li>
<?php component('person-card', ['person' => $person, 'level' => 'h4', 'hide_department' => true]); ?>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
<?php else: ?>
<ul class="persons__grid">
<?php foreach ($persons as $person): ?>
<li>
<?php component('person-card', ['person' => $person]); ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</section>

View File

@@ -4,25 +4,68 @@ declare(strict_types=1);
/**
* Alternierende Bild/Text-Sektion. Props:
* $section array{id, title, text, image, flip?, tinted?, cta?} (aus home.json o.ä.)
* $section array{id, title, text, image?, gallery?, flip?, tinted?, polaroid?, cta?} (aus home.json o.ä.)
* $level int Überschriften-Ebene (default 2)
* tinted: sanft aufgehelltes Panel hinter dem Inhalt (Abgrenzung vom Seitenhintergrund).
* polaroid: Bild als Polaroid (dicker heller Rahmen, leicht gekippt; flip kippt gegenläufig).
* Trägt ein Bild ein optionales caption-Feld, erscheint es als Bildunterschrift
* auf dem weißen Unterrand des Rahmens (Abel-Versalien, wie Historie-Captions).
* gallery: Array von Bildern statt image — erstes Bild groß, weitere darunter
* halbbreit nebeneinander (Collage). Mit polaroid kippen die Rahmen abwechselnd.
* text: String oder Liste von Absätzen (mehr als zwei sollten hier nicht stehen —
* dann ist es keine Bild/Text-Sektion mehr).
*/
$level = $level ?? 2;
$flip = !empty($section['flip']);
$tinted = !empty($section['tinted']);
$polaroid = !empty($section['polaroid']);
// Ohne image UND ohne gallery läuft die Sektion einspaltig („split--plain") —
// für Textabschnitte, für die es (noch) kein Foto gibt, z. B. „Anfahrt und
// Parken" auf einer Veranstaltungs-Seite. Ohne diesen Zweig liefe img.php
// unten auf ein fehlendes Bild.
$hasMedia = !empty($section['image']) || !empty($section['gallery']);
$paragraphs = is_array($section['text']) ? $section['text'] : [$section['text']];
?>
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($section['id']) ?>">
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?><?= $polaroid ? ' split--polaroid' : '' ?><?= $hasMedia ? '' : ' split--plain' ?>" id="<?= e($section['id']) ?>">
<div class="container split__inner">
<div class="split__text">
<h<?= $level ?>><?= e($section['title']) ?></h<?= $level ?>>
<p><?= e($section['text']) ?></p>
<?php foreach ($paragraphs as $paragraph): ?>
<p><?= e($paragraph) ?></p>
<?php endforeach; ?>
<?php if (!empty($section['cta'])): ?>
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
<?php endif; ?>
</div>
<?php if ($hasMedia): ?>
<div class="split__media">
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
<?php if (!empty($section['gallery'])): ?>
<div class="split__gallery">
<?php foreach ($section['gallery'] as $i => $galleryImage): ?>
<?php $sizes = $i === 0 ? '(max-width: 992px) 100vw, 50vw' : '(max-width: 992px) 50vw, 25vw'; ?>
<?php if ($polaroid): ?>
<figure class="polaroid">
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
<?php if (!empty($galleryImage['caption'])): ?>
<figcaption class="polaroid__caption"><?= e($galleryImage['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<?php else: ?>
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php elseif ($polaroid): ?>
<figure class="polaroid">
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
<?php if (!empty($section['image']['caption'])): ?>
<figcaption class="polaroid__caption"><?= e($section['image']['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<?php else: ?>
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</section>

View File

@@ -15,7 +15,7 @@ $club = json_load('club');
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
'mail' => 'Deine Anfrage konnte gerade nicht gesendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* Ausgaben-Archiv der Stadionzeitung, ganze Karte verlinkt auf die Ausgabe.
* Props: $issues array aus data/stadionzeitung.json (bereits neueste zuerst).
* Leer → freundlicher Hinweis statt leerer Sektion (nie brechen).
*/
$issues = $issues ?? [];
?>
<section class="section stadionzeitung-cards">
<div class="container">
<h2 class="visually-hidden">Ausgaben der Stadionzeitung</h2>
<?php if ($issues === []): ?>
<p class="text-muted">Noch keine Ausgabe digital verfügbar — zur nächsten Heimspiel-Stadionzeitung schauen wir wieder vorbei.</p>
<?php else: ?>
<ul class="stadionzeitung-cards__grid">
<?php foreach ($issues as $issue): ?>
<li>
<a class="stadionzeitung-card" href="<?= e(url('stadionzeitung/' . $issue['slug'])) ?>">
<div class="stadionzeitung-card__media">
<?php $firstPage = $issue['images'][0] ?? null; ?>
<?php if ($firstPage !== null && ($firstPage['type'] ?? 'image') === 'live' && ($firstPage['page'] ?? '') === 'cover'): ?>
<?php /* Seit dem automatischen Cover (Phase 3) beginnt jede Ausgabe mit der
echten, live gerenderten Titelseite statt einem Canva-Bild — die
dient hier direkt als Karten-Vorschau, keine zweite Bildquelle. */ ?>
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
<?php elseif ($firstPage !== null): ?>
<?php component('img', ['image' => $firstPage, 'sizes' => '(max-width: 600px) 100vw, 360px', 'class' => 'stadionzeitung-card__img']); ?>
<?php endif; ?>
</div>
<div class="stadionzeitung-card__body">
<h3 class="stadionzeitung-card__name">Gegen <?= e($issue['opponent']) ?></h3>
<p class="stadionzeitung-card__meta">
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
· <?= (int) $issue['pages'] ?> Seiten
</p>
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
</div>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/**
* Automatisch generiertes Stadionzeitung-Cover (Phase 3) — ersetzt das bisherige
* Canva-Titelbild. Einzige fotografische Stadionzeitungs-Seite: Vereins-Hero-Bild
* als Hintergrund (dieselbe Quelle wie die Startseite, data/home.json — nie
* duplizieren) mit Abdunklungs-Verlauf, das freigestellte Spielerfoto ist der
* Star (sehr großformatig, rechts randabfallend). Kopf wie ein echtes
* Zeitschriften-Titelblatt: kleine rote Kicker-Zeile (Saison/Datum), riesiger
* Masthead mit Logo auf gleicher Höhe, dezente Tagline darunter. Unten links
* gestapelt: Begegnung als große freie Typografie (kein Kasten) über einer
* roten Trennlinie und Slogan + QR-Plakette. Dünne Passepartout-Rahmenlinie
* gibt den "druckfertig"-Effekt.
*
* Selbst-Containment: alle Maße hier laufen über CSS Container Queries (cqi,
* relativ zur eigenen Breite von .stadionzeitung-cover), NICHT über die
* Website-Schriftgrößen-Tokens aus tokens.css — das Cover sieht dadurch in
* jedem Kontext (Karten-Vorschau, Swipe-Viewer, Druck) proportional exakt
* gleich aus, unabhängig von Viewport/Root-Schriftgröße. Einzige Ausnahme
* ganz bewusst: die crest-Komponente (geteiltes Bauteil, bekommt ihre Größe
* über --crest-size lokal in cqi mitgegeben, keine eigene Kopie nötig).
*
* Props: $issue (Eintrag aus data/stadionzeitung.json: opponent, date, team_key,
* player_image?, qr_image?)
*
* Gegner-Wappen wird zur Laufzeit über den Anstoß-Tag in data/matchcenter.json
* gesucht (next_matches/last_results des Teams) — nie im JSON dupliziert. Kein
* Treffer (z. B. Freundschaftsspiel gegen eine Spielgemeinschaft, die der BFV
* nicht führt) → das eigene Wappen kommt trotzdem aus dem lokalen Vereinslogo,
* der Gegner läuft über den Initialen-Fallback der crest-Komponente.
*/
$issue = $issue ?? [];
$opponent = (string) ($issue['opponent'] ?? '');
$date = (string) ($issue['date'] ?? '');
$teamKey = (string) ($issue['team_key'] ?? '');
$playerImage = $issue['player_image'] ?? null;
$qrImage = $issue['qr_image'] ?? null;
$club = json_load('club');
$heroImage = json_load('home')['hero']['image'] ?? null;
$team = null;
foreach (json_load('matchcenter')['teams'] ?? [] as $t) {
if (($t['key'] ?? '') === $teamKey) {
$team = $t;
break;
}
}
$teamName = (string) ($team['name'] ?? '');
$league = (string) ($team['league'] ?? '');
$season = (string) ($team['season'] ?? '');
$match = null;
foreach (array_merge($team['next_matches'] ?? [], $team['last_results'] ?? []) as $candidate) {
if (str_starts_with((string) ($candidate['kickoff'] ?? ''), $date)) {
$match = $candidate;
break;
}
}
$ownCrest = 'img/logo.svg';
$opponentCrest = null;
if ($match !== null) {
$ownCrest = $match['is_home'] ? $match['home_crest'] : $match['away_crest'];
$opponentCrest = $match['is_home'] ? $match['away_crest'] : $match['home_crest'];
}
$linkText = preg_replace('~^https?://(www\.)?~', '', rtrim((string) config('base_url'), '/'));
$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : ''));
// Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —
// erscheint automatisch, sobald die Ausgabe ein Interview trägt
// (interview-Feld, chat-gepflegt wie das Vorwort).
$teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
?>
<div class="stadionzeitung-cover">
<?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
<?php endif; ?>
<?php if (!empty($playerImage)): ?>
<img class="stadionzeitung-cover__player" src="<?= e(asset($playerImage)) ?>" alt="" loading="lazy">
<?php endif; ?>
<div class="stadionzeitung-cover__inner">
<header class="stadionzeitung-cover__masthead">
<?php if ($kicker !== ''): ?>
<p class="stadionzeitung-cover__kicker"><?= e($kicker) ?></p>
<?php endif; ?>
<div class="stadionzeitung-cover__masthead-row">
<img class="stadionzeitung-cover__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<p class="stadionzeitung-cover__wordmark">Stadionzeitung</p>
</div>
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
</header>
<div class="stadionzeitung-cover__corner">
<div class="stadionzeitung-cover__headline">
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
ruhiger) — die beiden Logos sprechen für sich. */ ?>
<div class="stadionzeitung-cover__crests">
<?php component('crest', ['src' => $ownCrest, 'name' => $club['name'] ?? '']); ?>
<?php component('crest', ['src' => $opponentCrest, 'name' => $opponent]); ?>
</div>
<p class="stadionzeitung-cover__matchup">Heimspiel gegen <?= e($opponent) ?></p>
<?php if ($teamName !== '' || $league !== ''): ?>
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
<?php endif; ?>
</div>
<?php if ($teaser !== ''): ?>
<p class="stadionzeitung-cover__teaser">
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>
<?= e($teaser) ?>
</p>
<?php endif; ?>
<?php /* Bewusst ohne Vereins-Slogan (Felix, 29.07.2026: ruhiger) — der
Slogan bleibt Footer/JSON-LD der Website vorbehalten. */ ?>
<footer class="stadionzeitung-cover__footer">
<?php /* QR bewusst minimal (Felix, 29.07.2026): Code + eine Zeile,
keine Plakette/kein Rahmen mehr. */ ?>
<?php if (!empty($qrImage)): ?>
<div class="stadionzeitung-cover__digital">
<img class="stadionzeitung-cover__qr" src="<?= e(asset($qrImage)) ?>" alt="" width="60" height="60" loading="lazy">
<span class="stadionzeitung-cover__link">Digital lesen · <?= e($linkText) ?></span>
</div>
<?php endif; ?>
</footer>
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* Druckfassung einer Stadionzeitung-Ausgabe (Route ?druck=1, siehe
* app/pages/stadionzeitung-ausgabe.php) — die Seiten in BROSCHÜREN-Reihenfolge
* statt Lesereihenfolge: das Heft wird als A4 quer beidseitig gedruckt, die
* Blätter werden gestapelt und mittig gefaltet. Damit dabei vorn das Cover
* und innen die richtige Abfolge liegt, trägt Blatt 1 vorne [letzte Seite |
* Seite 1], hinten [Seite 2 | vorletzte Seite] usw. — die klassische
* Sattelheftungs-Montage. Deshalb liegt die Montage HIER im PHP (die
* DOM-Reihenfolge selbst ist die Druckreihenfolge), nicht in Druck-CSS:
* CSS kann Seiten nicht über Blattgrenzen hinweg umsortieren.
*
* Die Seitenzahl wird dafür auf ein Vielfaches von 4 gebracht: fehlende
* Seiten füllt eine weiße Leerseite VOR der Rückseite auf (die Rückseite
* bleibt immer außen). Die Regel fürs Heft bleibt trotzdem: Lücken beim
* Erzeugen gemeinsam sauber lösen, Leerseiten sind der Notnagel.
*
* Auf dem Bildschirm zeigt die Route eine Blatt-Vorschau (jedes Blatt als
* A4-quer-Fläche mit Beschriftung „Blatt 1 · Vorderseite") plus
* Druck-Anleitung; assets/js/stadionzeitung-druck.js liefert den
* „Jetzt drucken"-Button (progressive enhancement — ohne JS druckt der
* Browser-Dialog dieselbe Ansicht).
*
* Props: $issue Eintrag aus data/stadionzeitung.json
*/
$issue = $issue ?? [];
$pages = $issue['images'] ?? [];
if ($pages === []) {
return;
}
$hasLive = false;
foreach ($pages as $entry) {
if (($entry['type'] ?? 'image') === 'live') {
$hasLive = true;
break;
}
}
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
// Auf Vielfaches von 4 auffüllen — Leerseiten direkt vor der letzten Seite,
// damit die gelieferte Rückseite die äußere Rückseite des Hefts bleibt.
$fill = (4 - count($pages) % 4) % 4;
if ($fill > 0) {
array_splice($pages, count($pages) - 1, 0, array_fill(0, $fill, ['type' => 'leer']));
}
$n = count($pages);
// Sattelheftung: Blatt k (0-basiert) trägt vorne [n-1-2k | 2k] und hinten
// [2k+1 | n-2-2k] (0-basierte Seitenindizes). Beim beidseitigen Druck an der
// KURZEN Kante spiegeln, sonst steht jede Rückseite kopf.
$sides = [];
for ($k = 0; $k < intdiv($n, 4); $k++) {
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Vorderseite', 'pages' => [$n - 1 - 2 * $k, 2 * $k]];
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Rückseite', 'pages' => [2 * $k + 1, $n - 2 - 2 * $k]];
}
?>
<section class="stadionzeitung-druck">
<ol class="stadionzeitung-druck__blaetter">
<?php foreach ($sides as $side): ?>
<li class="stadionzeitung-druck__blatt">
<p class="stadionzeitung-druck__blatt-label" aria-hidden="true"><?= e($side['label']) ?></p>
<div class="stadionzeitung-druck__flaeche">
<?php foreach ($side['pages'] as $pageIndex): ?>
<div class="stadionzeitung-druck__slot">
<?php component('stadionzeitung-seite', [
'entry' => $pages[$pageIndex],
'issue' => $issue,
'data' => $liveData,
// In der Druckfassung wird JEDE Seite sofort geladen:
// lazy geladene Bilder, die nie im Sichtfeld waren,
// druckt Chrome sonst als leere Flächen.
'eager' => true,
]); ?>
</div>
<?php endforeach; ?>
</div>
</li>
<?php endforeach; ?>
</ol>
<?php /* Anleitung + Druck-Button bewusst UNTER den Blättern (Wunsch Felix,
29.07.2026): erst die Vorschau komplett durchsehen, ganz unten
drucken. */ ?>
<div class="stadionzeitung-druck__hilfe">
<h2 class="stadionzeitung-druck__hilfe-title">Drucken</h2>
<p>
<?= intdiv($n, 4) ?> Blätter A4 quer<?= $fill > 0 ? ', mit ' . $fill . ' weißen Füllseiten auf ' . $n . ' Seiten aufgefüllt' : ' (' . $n . ' Seiten)' ?>.
Beidseitig drucken und dabei <strong>an der kurzen Kante spiegeln</strong>,
danach alle Blätter aufeinanderlegen und mittig falten — fertig ist das Heft.
</p>
<button class="btn stadionzeitung-druck__print-btn" type="button" data-druck-print hidden>
<?= icon('printer') ?> Jetzt drucken
</button>
</div>
</section>

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* Ergebnis-Rückblick der Stadionzeitung (Live-Seite) — die letzten Ergebnisse
* aller Mannschaften als gestaltete Seite (Wappen + Ergebnis aus
* data/matchcenter.json), bewusst KEINE Instagram-Post-Bilder: die Seite
* funktioniert damit für jedes Spiel, auch ohne passenden Post.
*
* Zwei Zeilen pro Spiel (Heim über Gast) statt einer langen Einzelzeile —
* Vereinsnamen wie „(SG) 1.FC Michelau / 1.FC Fortuna Roth" passen auf der
* schmalen A5-Seite nie nebeneinander. Dasselbe Bauprinzip wie match-feature
* im „Nächste Spiele"-Band.
*
* Props: $groups stadionzeitung_live_data()['ergebnisse'] — je Team:
* team_name/league/matches[] (Matchcenter-Rohform inkl.
* goals_home/goals_away, nur Spiele mit erfasstem Ergebnis).
*/
$groups = $groups ?? [];
// Matchcenter-Ticket wohnt hier (nicht auf der volleren Vorschau-Seite):
// geteilter QR, von bin/stadionzeitung-add.php mit der aktuellen base_url
// erzeugt — fehlt er, erscheint nur der Text-Link.
$matchcenterQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-matchcenter.png')
? 'img/stadionzeitung/qr-matchcenter.png'
: null;
$matchcenterLink = preg_replace('~^https?://(www\.)?~', '', abs_url('matchcenter'));
?>
<div class="stadionzeitung-ergebnisse">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Rückblick</p>
<h2 class="stadionzeitung-page__heading">Letzte Ergebnisse</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($groups === []): ?>
<p class="text-muted">Aktuell keine Ergebnisse.</p>
<?php endif; ?>
<?php foreach ($groups as $group): ?>
<section class="stadionzeitung-ergebnisse__team">
<h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3>
<?php foreach ($group['matches'] ?? [] as $match): ?>
<div class="stadionzeitung-ergebnisse__match">
<p class="stadionzeitung-ergebnisse__meta">
<?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?>
</p>
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null],
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null]] as $side): ?>
<div class="stadionzeitung-ergebnisse__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__row--own' : '' ?>">
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
<span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span>
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $side['goals']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Alle Spiele, Ergebnisse und Tabellen im Matchcenter</p>
<p class="stadionzeitung-page__cta-link"><?= e($matchcenterLink) ?></p>
</div>
<?php if ($matchcenterQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($matchcenterQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* Historie-Seite der Stadionzeitung (Live-Seite) — teasert die neu auf der
* Website veröffentlichte Vereinsgeschichte (/historie) an und ruft dazu auf,
* Material an die Historie-Mailadresse zu schicken (Zulauf für die Chronik
* zum 120-jährigen Bestehen 2028, siehe CLAUDE.md → Chronik). Fester
* Baustein jeder Ausgabe (Felix, 29.07.2026).
*
* Bilder und Mailadresse kommen aus den geteilten Quellen (data/historie.json
* liegt dasselbe Luftbild zugrunde, club.json → email_historie) — nie
* duplizieren. QR: geteilter qr-historie.png (bin/stadionzeitung-add.php).
*/
$emailHistorie = (string) (json_load('club')['email_historie'] ?? '');
$historieQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-historie.png')
? 'img/stadionzeitung/qr-historie.png'
: null;
$historieLink = preg_replace('~^https?://(www\.)?~', '', abs_url('historie'));
?>
<div class="stadionzeitung-historie">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Historie</p>
<h2 class="stadionzeitung-page__heading">Unsere Geschichte lebt</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<p class="stadionzeitung-historie__text">Von der Gründung 1908 bis heute: Wir haben unsere Vereinsgeschichte neu aufgearbeitet und erzählen sie jetzt auf unserer Website, mit Fotos und Geschichten aus fast 120 Jahren.</p>
<?php /* Archiv-Anmutung passend zum Aufruf („Fotoalben und Schuhkartons"):
die Fotos als leicht gedrehte Polaroids, der Aufruf als Notizzettel. */ ?>
<div class="stadionzeitung-historie__bilder">
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--links">
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/luftbild-historisch.jpg')) ?>" alt="Historische Luftaufnahme unserer Sportanlage" loading="lazy" decoding="async">
<figcaption class="stadionzeitung-historie__caption">Unsere Sportanlage von oben</figcaption>
</figure>
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--rechts">
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/1972-fcbayern.png')) ?>" alt="Unser Freundschaftsspiel gegen den FC Bayern München 1972" loading="lazy" decoding="async">
<figcaption class="stadionzeitung-historie__caption">1972: Zu Gast bei uns, der FC Bayern</figcaption>
</figure>
</div>
<p class="stadionzeitung-historie__aufruf">
<strong>Und jetzt bist du dran:</strong> Ein großer Teil unserer Geschichte steckt noch in Fotoalben und Schuhkartons. Wenn du Fotos, Zeitungsausschnitte oder Erinnerungen hast, schreib uns an
<span class="stadionzeitung-historie__mail"><?= e($emailHistorie) ?></span>
</p>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Die ganze Geschichte auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($historieLink) ?></p>
</div>
<?php if ($historieQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($historieQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* Impressums-Seite der Stadionzeitung (Live-Seite) — ersetzt das bisherige
* Bild aus dem Canva-Bestand. Alle Angaben kommen aus data/club.json
* (legal_name, address, email, board — dieselbe Quelle wie das
* Website-Impressum, nie duplizieren): ändert sich der Vorstand, stimmt die
* nächste Ausgabe automatisch. Zentriertes Layout mit Logo und dünner
* Rahmenlinie, wie das Mitglied-Poster. Juristische Seite, deshalb der
* ausgeschriebene Vereinsname statt Wir-Form (Sprachregel-Ausnahme).
*/
$club = json_load('club');
$legalName = (string) ($club['legal_name'] ?? $club['name'] ?? '');
$address = $club['address'] ?? [];
$email = (string) ($club['email'] ?? '');
$domain = preg_replace('~^https?://(www\.)?~', '', rtrim((string) config('base_url'), '/'));
$board = $club['board']['members'] ?? [];
$boardNote = (string) ($club['board']['note'] ?? '');
?>
<div class="stadionzeitung-impressum">
<img class="stadionzeitung-impressum__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<p class="stadionzeitung-page__kicker">Impressum</p>
<h2 class="stadionzeitung-impressum__name"><?= e($legalName) ?></h2>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Herausgeber</h3>
<p><?= e($legalName) ?></p>
<p><?= e((string) ($address['street'] ?? '')) ?></p>
<p><?= e(trim(($address['zip'] ?? '') . ' ' . ($address['city'] ?? ''))) ?></p>
</div>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Kontakt</h3>
<p>E-Mail: <?= e($email) ?></p>
<p>Internet: <?= e($domain) ?></p>
</div>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Inhaltlich verantwortlich</h3>
<p><?= e($legalName) ?></p>
</div>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Design und Layout</h3>
<p>Felix Schneider</p>
</div>
<?php if ($board !== []): ?>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Vorstand</h3>
<?php if ($boardNote !== ''): ?>
<p class="stadionzeitung-impressum__note"><?= e($boardNote) ?></p>
<?php endif; ?>
<p><?= e(implode(' · ', array_map(
static fn (array $m): string => trim((string) ($m['name'] ?? '') . (!empty($m['role']) ? ' (' . $m['role'] . ')' : '')),
$board
))) ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* Interview-Seite der Stadionzeitung (Live-Seite) — das Gespräch, auf das die
* Teaser-Zeile des Covers verweist (z. B. mit dem Trainer über die Neuzugänge).
* Der Inhalt ist KEIN Skript-Input: er wird wie das Vorwort im Chat gepflegt —
* Felix schickt Fragen/Antworten, Claude trägt sie ins `interview`-Feld der
* Ausgabe in data/stadionzeitung.json ein:
* teaser Coverzeile („Unser Trainer … über die Neuzugänge")
* heading Überschrift der Seite
* person_name / person_role wer spricht
* intro optionaler Vorspann (kursiv, Serif)
* fragen[] { frage, antwort }
* photo optional, Porträt — entweder ein direkter Asset-Pfad
* (z. B. "img/persons/ali-kilic.jpg", die geteilten
* Personenfotos haben keine Breiten-Varianten) oder
* base/widths wie verfasser_photo
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe hinweg weiter
* (wie `vorwort`) und plant die Seite nur ein, wenn das Feld existiert.
*
* Fragen in Abel fett, Antworten in der Lese-Serif (--ff-serif, PT Serif) —
* dieselbe Begründung wie beim Vorwort: redaktioneller Text, keine UI-Fläche.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json)
*/
$issue = $issue ?? [];
$interview = $issue['interview'] ?? null;
if (!is_array($interview)) {
return;
}
$heading = trim((string) ($interview['heading'] ?? $interview['teaser'] ?? 'Interview'));
$person = trim(implode(' · ', array_filter([
(string) ($interview['person_name'] ?? ''),
(string) ($interview['person_role'] ?? ''),
])));
$photo = $interview['photo'] ?? null;
$photoSrc = null;
if (is_string($photo) && $photo !== '') {
$photoSrc = $photo;
} elseif (is_array($photo) && !empty($photo['base']) && !empty($photo['widths'])) {
$photoSrc = $photo['base'] . '-' . min($photo['widths']) . '.jpg';
}
?>
<div class="stadionzeitung-interview">
<div class="stadionzeitung-interview__head-row">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Interview</p>
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
<?php if ($person !== ''): ?>
<p class="stadionzeitung-page__sub"><?= e($person) ?></p>
<?php endif; ?>
</header>
<?php if ($photoSrc !== null): ?>
<img class="stadionzeitung-interview__portrait" src="<?= e(asset($photoSrc)) ?>" alt="" loading="lazy" decoding="async">
<?php endif; ?>
</div>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if (!empty($interview['intro'])): ?>
<p class="stadionzeitung-interview__intro"><?= e((string) $interview['intro']) ?></p>
<?php endif; ?>
<?php foreach ($interview['fragen'] ?? [] as $qa): ?>
<div class="stadionzeitung-interview__qa">
<p class="stadionzeitung-interview__frage"><?= e((string) ($qa['frage'] ?? '')) ?></p>
<p class="stadionzeitung-interview__antwort"><?= e((string) ($qa['antwort'] ?? '')) ?></p>
</div>
<?php endforeach; ?>
</div>

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* Ansprechpartner-Seite der Stadionzeitung (Live-Seite) — die Fußball-
* Ansprechpartner aus data/ansprechpartner.json (geteilte Quelle mit
* /ansprechpartner, nie Namen/Nummern duplizieren), wie die „KONTAKT"-Seite
* der Canva-Vorlage: Abteilung + Name + Telefonnummer, rechts das runde
* Porträt. Keine Props — die Seite zeigt immer den aktuellen Stand der
* geteilten Quelle (Personen ändern sich zwischen Ausgaben praktisch nie;
* ein Snapshot würde hier nur veraltete Nummern konservieren).
*/
$persons = array_values(array_filter(
json_load('ansprechpartner')['persons'] ?? [],
static fn (array $p): bool => ($p['group'] ?? '') === 'Fußball'
));
// CTA unter der Liste: QR + Link auf /mitmachen (Wunsch Felix, 29.07.2026).
// Der QR wird von bin/stadionzeitung-add.php miterzeugt (qr-mitmachen.png,
// geteilt über alle Ausgaben — die Ziel-URL ist immer dieselbe); fehlt er
// (noch kein Lauf seit Einführung), erscheint nur der Text-Link.
$mitmachenQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitmachen.png')
? 'img/stadionzeitung/qr-mitmachen.png'
: null;
$mitmachenLink = preg_replace('~^https?://(www\.)?~', '', abs_url('mitmachen'));
?>
<div class="stadionzeitung-kontakt">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Kontakt</p>
<h2 class="stadionzeitung-page__heading">Ansprechpartner im Verein</h2>
</header>
<?php /* Bewusst ohne die Trennlinie der Geschwister-Seiten: die Liste + CTA
brauchen die volle Seitenhöhe (Felix, 29.07.2026). Die erste
Listenzeile bringt ihre eigene Unterkante mit, das trennt genug. */ ?>
<ul class="stadionzeitung-kontakt__list">
<?php foreach ($persons as $person): ?>
<li class="stadionzeitung-kontakt__item">
<div class="stadionzeitung-kontakt__body">
<p class="stadionzeitung-kontakt__department"><?= e((string) ($person['department'] ?? '')) ?></p>
<p class="stadionzeitung-kontakt__name"><?= e((string) ($person['name'] ?? '')) ?></p>
<?php if (!empty($person['phone'])): ?>
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['phone']) ?></p>
<?php elseif (!empty($person['email'])): ?>
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['email']) ?></p>
<?php endif; ?>
</div>
<?php if (!empty($person['image'])): ?>
<img class="stadionzeitung-kontakt__portrait" src="<?= e(asset((string) $person['image'])) ?>" alt="" width="56" height="56" loading="lazy" decoding="async">
<?php else: ?>
<span class="stadionzeitung-kontakt__portrait stadionzeitung-kontakt__portrait--icon" aria-hidden="true"><?= icon('person') ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Du möchtest selbst aktiv werden?</p>
<p class="stadionzeitung-page__cta-link"><?= e($mitmachenLink) ?></p>
</div>
<?php if ($mitmachenQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($mitmachenQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste:
* riesige zentrierte Coolvetica-Headline („Werde Teil des Nullachters", das
* letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile,
* unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein
* jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den
* Ansprechpartnern ein) — Look bewusst weiß mit Rot-Akzent, damit die Seite
* heraussticht, ohne die helle Heft-Optik zu brechen (Felix, 29.07.2026).
*
* Keine Props — die Seite ist statisch bis auf den geteilten QR
* (qr-mitglied-werden.png, von bin/stadionzeitung-add.php mit der aktuellen
* base_url erzeugt; fehlt er, erscheint nur der Text-Link).
*/
$mitgliedQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitglied-werden.png')
? 'img/stadionzeitung/qr-mitglied-werden.png'
: null;
$mitgliedLink = preg_replace('~^https?://(www\.)?~', '', abs_url('mitglied-werden'));
?>
<div class="stadionzeitung-mitglied">
<div class="stadionzeitung-mitglied__poster">
<img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<h2 class="stadionzeitung-mitglied__headline">
<span class="stadionzeitung-mitglied__line">Werde Teil</span>
<span class="stadionzeitung-mitglied__line">des</span>
<span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Nullachters</span>
</h2>
<p class="stadionzeitung-mitglied__claim">Seit 1908 in Kulmbach zuhause. Egal ob auf dem Platz, in der Halle oder am Spielfeldrand: bei uns ist Platz für dich.</p>
<p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p>
<?php /* Bewusst KEIN generischer CTA-Fuß wie auf den Listen-Seiten: das
Poster endet mit dem großen zentrierten QR (Felix, 29.07.2026) —
die Link-Zeile darunter bleibt für alle, die nicht scannen. */ ?>
<div class="stadionzeitung-mitglied__qr-block">
<?php if ($mitgliedQr !== null): ?>
<img class="stadionzeitung-mitglied__qr" src="<?= e(asset($mitgliedQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
<?php endif; ?>
<span class="stadionzeitung-mitglied__link"><?= e($mitgliedLink) ?></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* Momente-Seite der Stadionzeitung (Live-Seite, Heft-Abschluss) — die vier
* neuesten Fotos aus unserem Instagram-Cache als 2×2-Galerie plus
* „Folge uns"-Ticket mit QR aufs Profil. Pflegt sich selbst: im Entwurf
* immer die aktuellsten Bilder, beim Veröffentlichen eingefroren
* (stadionzeitung_build_snapshot(), app/helpers.php).
*
* Props: $items stadionzeitung_live_data()['momente'] — image/alt je Foto.
* Profil-URL aus club.json → social.instagram (geteilte Quelle), QR:
* geteilter qr-instagram.png (bin/stadionzeitung-add.php).
*/
$items = $items ?? [];
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
$instagramHandle = '@' . trim((string) parse_url($instagramUrl, PHP_URL_PATH), '/');
$momenteQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')
? 'img/stadionzeitung/qr-instagram.png'
: null;
?>
<div class="stadionzeitung-momente">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Instagram</p>
<h2 class="stadionzeitung-page__heading">Aktuelles von Instagram</h2>
</header>
<?php /* Bewusst ohne Trennlinie und mit verdichteten Abständen: jeder
gewonnene Millimeter geht in die Fotogröße (Felix, 29.07.2026). */ ?>
<?php if ($items === []): ?>
<p class="text-muted">Folge uns auf Instagram für alle Momente rund um den Verein.</p>
<?php else: ?>
<div class="stadionzeitung-momente__galerie">
<?php foreach ($items as $item): ?>
<img class="stadionzeitung-momente__foto" src="<?= e(asset((string) $item['image'])) ?>" alt="<?= e((string) $item['alt']) ?>" loading="lazy" decoding="async">
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Folge uns auf Instagram</p>
<p class="stadionzeitung-page__cta-link"><?= e($instagramHandle) ?></p>
</div>
<?php if ($momenteQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($momenteQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/**
* News-Seite der Stadionzeitung (Live-Seite) — eigene, einheitliche Liste im
* Kopf-Muster der Geschwister-Seiten statt des geteilten news-feed der
* /news-Seite: dort variieren die Medienspalten (Bild, Wappen, nichts) und
* die Titel springen in der Einrückung — auf der schmalen A5-Seite braucht
* jede Zeile dieselbe feste Medienspalte (Wappen, Foto-Thumbnail oder
* Icon-Badge als Fallback), damit alle Titel bündig stehen.
*
* Props: $items stadionzeitung_live_data()['news'] — normalisierte Einträge
* aus news_build_feed() (date/title/text/image/crest/source).
*/
$items = $items ?? [];
$sourceLabel = static fn (string $source): string => match ($source) {
'ergebnis' => 'Ergebnis',
'instagram' => 'Instagram',
default => 'Meldung',
};
// CTA unter der Liste: QR + Link auf /news (geteilter QR wie bei der
// Kontakt-Seite, von bin/stadionzeitung-add.php mit der aktuellen base_url
// erzeugt — fehlt er, erscheint nur der Text-Link).
$newsQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-news.png')
? 'img/stadionzeitung/qr-news.png'
: null;
$newsLink = preg_replace('~^https?://(www\.)?~', '', abs_url('news'));
?>
<div class="stadionzeitung-news">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">News</p>
<h2 class="stadionzeitung-page__heading">Aktuelles aus dem Katzbachtal</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($items === []): ?>
<p class="text-muted">Aktuell keine News.</p>
<?php endif; ?>
<ul class="stadionzeitung-news__list">
<?php foreach ($items as $item): ?>
<?php
$source = (string) ($item['source'] ?? 'news');
// Instagram-Titel ist die erste Zeile der Caption — der Text darunter
// wäre dieselbe Caption noch einmal, deshalb dort nur der Titel.
$text = $source === 'instagram' ? '' : trim((string) ($item['text'] ?? ''));
$image = $item['image'] ?? null;
$imageSrc = null;
if (is_array($image)) {
// Beide Bildformen aus news_build_feed(): Instagram liefert src,
// redaktionelle Meldungen base/widths (kleinste Variante reicht
// für das winzige Thumbnail).
$imageSrc = $image['src'] ?? (isset($image['base'], $image['widths']) ? $image['base'] . '-' . min($image['widths']) . '.jpg' : null);
}
?>
<li class="stadionzeitung-news__item">
<?php if (!empty($item['crest']['src']) || (!empty($item['crest']['name']) && $source === 'ergebnis')): ?>
<span class="stadionzeitung-news__media stadionzeitung-news__media--crest">
<?php component('crest', ['src' => $item['crest']['src'] ?? null, 'name' => (string) ($item['crest']['name'] ?? '')]); ?>
</span>
<?php elseif ($imageSrc !== null): ?>
<img class="stadionzeitung-news__media stadionzeitung-news__media--img" src="<?= e(asset($imageSrc)) ?>" alt="" loading="lazy" decoding="async">
<?php else: ?>
<span class="stadionzeitung-news__media stadionzeitung-news__media--icon" aria-hidden="true"><?= icon('newspaper') ?></span>
<?php endif; ?>
<div class="stadionzeitung-news__body">
<p class="stadionzeitung-news__meta"><?= e($sourceLabel($source)) ?><?php if (!empty($item['date'])): ?> · <time datetime="<?= e((string) $item['date']) ?>"><?= e(german_date((string) $item['date'])) ?></time><?php endif; ?></p>
<p class="stadionzeitung-news__title"><?= e((string) ($item['title'] ?? '')) ?></p>
<?php if ($text !== ''): ?>
<p class="stadionzeitung-news__text"><?= e($text) ?></p>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Mehr News findest du auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($newsLink) ?></p>
</div>
<?php if ($newsQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($newsQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* Partner-werden-Seite der Stadionzeitung (Live-Seite) — der Selbstbeweis:
* die Seite wirbt für Anzeigen in genau dem Heft, das der Leser gerade in
* der Hand hält. Fester Baustein jeder Ausgabe (Felix, 29.07.2026).
*
* Sie-Form mit Absicht: /partner-werden spricht Unternehmen an — die eine
* benannte Ausnahme der Wir/Du-Sprachregel (CLAUDE.md). Die drei Pakete
* kommen aus data/partner-werden.json (dieselbe Quelle wie die Website),
* der Sponsoring-Ansprechpartner (2. Vorsitzender) aus club.json → board.
* QR: geteilter qr-partner-werden.png (bin/stadionzeitung-add.php).
*/
$channels = json_load('partner-werden')['channels']['items'] ?? [];
$sponsoringKontakt = null;
foreach (json_load('club')['board']['members'] ?? [] as $member) {
if (($member['role'] ?? '') === '2. Vorsitzender') {
$sponsoringKontakt = (string) $member['name'];
break;
}
}
$partnerQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
? 'img/stadionzeitung/qr-partner-werden.png'
: null;
$partnerLink = preg_replace('~^https?://(www\.)?~', '', abs_url('partner-werden'));
?>
<div class="stadionzeitung-partner">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Partner werden</p>
<h2 class="stadionzeitung-page__heading">Hier könnte Ihre Anzeige stehen</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<p class="stadionzeitung-partner__intro">Die Stadionzeitung liegt bei jedem Heimspiel im Katzbachtal aus, unsere Website erreicht Kulmbach jeden Tag. Bringen Sie Ihr Unternehmen dorthin, wo die Region zuhause ist.</p>
<ul class="stadionzeitung-partner__pakete">
<?php foreach ($channels as $channel): ?>
<li class="stadionzeitung-partner__paket">
<span class="stadionzeitung-partner__icon" aria-hidden="true"><?= icon((string) ($channel['icon'] ?? 'geo-alt-fill')) ?></span>
<div class="stadionzeitung-partner__paket-body">
<p class="stadionzeitung-partner__paket-titel"><?= e((string) ($channel['title'] ?? '')) ?></p>
<p class="stadionzeitung-partner__paket-text"><?= e((string) ($channel['text'] ?? '')) ?></p>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php if ($sponsoringKontakt !== null): ?>
<p class="stadionzeitung-partner__kontakt">Ihr Ansprechpartner: <strong><?= e($sponsoringKontakt) ?></strong> · 2. Vorsitzender</p>
<?php endif; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Alle Pakete auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($partnerLink) ?></p>
</div>
<?php if ($partnerQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($partnerQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* EINE Seite einer Stadionzeitung-Ausgabe — die gemeinsame Render-Stelle für
* den Swipe-Viewer (stadionzeitung-viewer.php) und die Druckfassung
* (stadionzeitung-druck.php): beide zeigen exakt dieselben Seiten, nur in
* anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
* $entry Eintrag aus issue.images[]:
* type 'image' (base/widths/alt),
* type 'live' (page: cover|vorwort|interview|tabelle|termine|
* news|ergebnisse|vorschau|kontakt; tabelle optional
* mit team: Team-Key aus matchcenter.json),
* type 'leer' (weiße Füllseite, nur Druckfassung — hält die
* Seitenzahl auf einem Vielfachen von 4)
* $issue Eintrag aus data/stadionzeitung.json
* $data stadionzeitung_live_data($issue) — vom Aufrufer EINMAL berechnet
* und an jede Seite durchgereicht (nicht pro Seite neu laden)
* $eager bool, Bild ohne lazy-loading laden (erste Seite im Viewer)
*/
$entry = $entry ?? [];
$issue = $issue ?? [];
$data = $data ?? [];
$eager = $eager ?? false;
$type = $entry['type'] ?? 'image';
?>
<?php if ($type === 'leer'): ?>
<div class="stadionzeitung-page stadionzeitung-page--leer" aria-hidden="true"></div>
<?php elseif ($type === 'live' && ($entry['page'] ?? '') === 'cover'): ?>
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
<?php elseif ($type === 'live'): ?>
<div class="stadionzeitung-page">
<?php switch ($entry['page'] ?? ''):
case 'vorwort': ?>
<?php component('stadionzeitung-vorwort', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'tabelle': ?>
<?php
// Ohne team-Feld (Ausgaben vor der Mehr-Tabellen-Vorlage) zeigt die
// Seite wie bisher die Tabelle der Mannschaft der Ausgabe.
$tabelleTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
component('stadionzeitung-tabelle', ['info' => $data['tabellen'][$tabelleTeam] ?? null]);
?>
<?php break; ?>
<?php case 'interview': ?>
<?php component('stadionzeitung-interview', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'termine': ?>
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'ergebnisse': ?>
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
<?php break; ?>
<?php case 'vorschau': ?>
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
<?php break; ?>
<?php case 'kontakt': ?>
<?php component('stadionzeitung-kontakt', []); ?>
<?php break; ?>
<?php case 'mitglied': ?>
<?php component('stadionzeitung-mitglied', []); ?>
<?php break; ?>
<?php case 'historie': ?>
<?php component('stadionzeitung-historie', []); ?>
<?php break; ?>
<?php case 'sportheim': ?>
<?php component('stadionzeitung-sportheim', []); ?>
<?php break; ?>
<?php case 'partner': ?>
<?php component('stadionzeitung-partner', []); ?>
<?php break; ?>
<?php case 'momente': ?>
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
<?php break; ?>
<?php case 'impressum': ?>
<?php component('stadionzeitung-impressum', []); ?>
<?php break; ?>
<?php case 'news': ?>
<?php component('stadionzeitung-news', ['items' => $data['news'] ?? []]); ?>
<?php break; ?>
<?php endswitch; ?>
</div>
<?php else: ?>
<?php component('img', [
'image' => $entry,
'sizes' => '100vw',
'eager' => $eager,
'class' => 'stadionzeitung-page__img',
]); ?>
<?php endif; ?>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Sportheim-Seite der Stadionzeitung (Live-Seite) — Nachbau der gelungenen
* Canva-Seite „Feiern Sie Veranstaltungen im Katzbachtal": Kicker + große
* Headline, Lese-Serif-Text, unten das einblendende Gastraum-Foto mit
* QR-Karte darauf, rote Pinselstrich-Ecken als Gestaltungselement (hier als
* CSS-Näherung, keine Bild-Assets). Fester Baustein jeder Ausgabe
* (Felix, 29.07.2026).
*
* Text kommt aus data/sportheimbuchung.json (hero.text — dieselbe Quelle wie
* /sportheimbuchung, nie duplizieren), Foto aus dem geteilten Bestand,
* QR: geteilter qr-sportheimbuchung.png (bin/stadionzeitung-add.php).
*/
$heroText = (string) (json_load('sportheimbuchung')['hero']['text'] ?? '');
$sportheimQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-sportheimbuchung.png')
? 'img/stadionzeitung/qr-sportheimbuchung.png'
: null;
$sportheimLink = preg_replace('~^https?://(www\.)?~', '', abs_url('sportheimbuchung'));
?>
<?php /* Pinselstrich-Ecken bewusst entfernt (Felix, 29.07.2026) — kommen
ggf. später als saubere Bild-Assets zurück. */ ?>
<div class="stadionzeitung-sportheim">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Veranstaltung</p>
<h2 class="stadionzeitung-page__heading">Feiern im Katzbachtal</h2>
</header>
<p class="stadionzeitung-sportheim__text"><?= e($heroText) ?></p>
<div class="stadionzeitung-sportheim__foto-bereich">
<img class="stadionzeitung-sportheim__foto" src="<?= e(asset('img/pages/sportheim-1000.jpg')) ?>" alt="Der Gastraum unseres Sportheims" loading="lazy" decoding="async">
<div class="stadionzeitung-sportheim__foto-fade" aria-hidden="true"></div>
<div class="stadionzeitung-sportheim__qr-karte">
<?php if ($sportheimQr !== null): ?>
<img class="stadionzeitung-sportheim__qr" src="<?= e(asset($sportheimQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
<?php endif; ?>
<span class="stadionzeitung-sportheim__link"><?= e($sportheimLink) ?></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* Tabellen-Seite der Stadionzeitung (Live-Seite) — Kopf im Muster der
* Geschwister-Seiten (Kicker + Überschrift, siehe stadionzeitung-termine.php),
* darunter die geteilte league-table-Komponente (dieselbe wie /fussball,
* nie eine zweite Tabellen-Implementierung).
*
* Props: $info Eintrag aus stadionzeitung_live_data()['tabellen'][team]:
* name/league/season/table — null, wenn das Team (noch) nicht
* in data/matchcenter.json gepflegt ist.
*/
$info = $info ?? null;
?>
<div class="stadionzeitung-tabelle">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Tabelle</p>
<h2 class="stadionzeitung-page__heading"><?= e(trim((string) ($info['name'] ?? 'Tabelle'))) ?></h2>
<?php if (!empty($info['league']) || !empty($info['season'])): ?>
<p class="stadionzeitung-page__sub"><?= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?></p>
<?php endif; ?>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if (!empty($info['table'])): ?>
<?php component('league-table', ['table' => $info['table'], 'caption' => 'Tabelle ' . (string) ($info['league'] ?? '')]); ?>
<?php else: ?>
<p class="text-muted">Tabelle aktuell nicht verfügbar.</p>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/**
* Termine-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Vorwort/News) —
* schlichte weiße Content-Seite mit Foto-Kopf, angelehnt ans Vorwort-Layout
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
* Icon-Liste statt Fließtext.
*
* Die drei Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert
* sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json: termine_snapshot?)
*/
$issue = $issue ?? [];
$items = $issue['termine_snapshot'] ?? [];
if ($items === []) {
return;
}
$heroImage = json_load('home')['hero']['image'] ?? null;
// CTA-Fuß wie News/Kontakt: geteilter QR auf /termine (bin/stadionzeitung-add.php
// erzeugt ihn mit der aktuellen base_url — fehlt er, nur der Text-Link).
$termineQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-termine.png')
? 'img/stadionzeitung/qr-termine.png'
: null;
$termineLink = preg_replace('~^https?://(www\.)?~', '', abs_url('termine'));
?>
<div class="stadionzeitung-termine">
<?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-termine__bg']); ?>
<div class="stadionzeitung-termine__bg-fade" aria-hidden="true"></div>
<?php endif; ?>
<div class="stadionzeitung-termine__inner">
<header class="stadionzeitung-termine__head">
<p class="stadionzeitung-termine__kicker">Aktuelles</p>
<h2 class="stadionzeitung-termine__heading">Kommende Termine</h2>
</header>
<span class="stadionzeitung-termine__rule" aria-hidden="true"></span>
<ul class="stadionzeitung-termine__list">
<?php foreach ($items as $item): ?>
<?php
$isMatch = ($item['type'] ?? '') === 'spiel';
$parts = [german_date_range((string) ($item['date'] ?? ''), $item['date_end'] ?? null)];
$time = '';
if ($isMatch && !empty($item['kickoff'])) {
$t = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $item['kickoff']);
$time = $t !== false ? $t->format('H:i') : '';
} elseif (!$isMatch) {
$time = (string) ($item['time'] ?? '');
}
if ($time !== '') {
$parts[] = $time . ' Uhr';
}
if ($isMatch && !empty($item['meta'])) {
$parts[] = (string) $item['meta'];
} elseif (!$isMatch && !empty($item['location'])) {
$parts[] = (string) $item['location'];
}
?>
<li class="stadionzeitung-termine__item">
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
Trophäe, Vereins-Termine den Kalender — macht die Liste auf einen
Blick unterscheidbar. */ ?>
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
<div class="stadionzeitung-termine__body">
<p class="stadionzeitung-termine__title"><?= e((string) ($item['title'] ?? '')) ?></p>
<p class="stadionzeitung-termine__meta"><?= e(implode(' · ', $parts)) ?></p>
</div>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Alle Termine findest du auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($termineLink) ?></p>
</div>
<?php if ($termineQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($termineQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props:
* $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status,
* images[]{type: 'image'{base,widths,alt} | 'live'{page[,team]}})
*
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
* voll lesbar, sequentiell auch per Screenreader. Mit JS
* (stadionzeitung-viewer.js) wird der Track zum horizontalen Scroll-Snap-
* Swiper, Zähler + Pfeil-Buttons kommen dazu (vorher [hidden], da ohne JS
* funktionslos).
*
* Das Rendern der einzelnen Seiten (Bild wie Live) liegt in
* stadionzeitung-seite.php — geteilt mit der Druckfassung
* (stadionzeitung-druck.php), damit Bildschirm und Druck nie
* auseinanderlaufen. Die Daten der Live-Seiten kommen EINMAL aus
* stadionzeitung_live_data() (app/helpers.php): Entwürfe rechnen live,
* veröffentlichte Ausgaben lesen ihren eingefrorenen snapshot.
*/
$issue = $issue ?? [];
$images = $issue['images'] ?? [];
$total = count($images);
if ($images === []) {
return;
}
$opponent = (string) ($issue['opponent'] ?? '');
// Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält.
$hasLive = false;
foreach ($images as $entry) {
if (($entry['type'] ?? 'image') === 'live') {
$hasLive = true;
break;
}
}
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
?>
<section class="stadionzeitung-viewer" data-stadionzeitung-viewer aria-roledescription="Bildergalerie" aria-label="<?= e('Stadionzeitung gegen ' . $opponent . ', Seite für Seite') ?>">
<ul class="stadionzeitung-viewer__track" data-viewer-track>
<?php foreach ($images as $i => $entry): ?>
<li class="stadionzeitung-viewer__page" data-viewer-page>
<?php component('stadionzeitung-seite', [
'entry' => $entry,
'issue' => $issue,
'data' => $liveData,
'eager' => $i === 0,
]); ?>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-viewer__controls" data-viewer-controls hidden>
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-prev aria-label="Vorherige Seite">
<?= icon('chevron-left') ?>
</button>
<p class="stadionzeitung-viewer__counter" data-viewer-counter aria-live="polite">
Seite <span data-viewer-current>1</span> von <?= $total ?>
</p>
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-next aria-label="Nächste Seite">
<?= icon('chevron-right') ?>
</button>
<?php /* Vollbild-Lesemodus: am Handy fressen Kopfleiste, Navigation und Fußzeile
mehr Platz als die Seite selbst. Beide Icons stehen im Markup, welches
sichtbar ist entscheidet CSS am Zustand — so muss das JS kein Markup
erzeugen. Zustand über aria-pressed, deshalb bleibt das aria-label
unverändert. */ ?>
<button class="btn btn--icon stadionzeitung-viewer__full-btn" type="button" data-viewer-full aria-pressed="false" aria-label="Vollbild">
<span class="stadionzeitung-viewer__icon--open"><?= icon('arrows-fullscreen') ?></span>
<span class="stadionzeitung-viewer__icon--close"><?= icon('x-lg') ?></span>
</button>
</div>
<a class="btn btn--outline stadionzeitung-viewer__print" href="<?= e(url('stadionzeitung/' . ($issue['slug'] ?? '')) . '?druck=1') ?>">
<?= icon('printer') ?> Druckfassung
</a>
</section>

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Vorschau-Seite der Stadionzeitung (Live-Seite) — das nächste Spiel jeder
* Mannschaft NACH dem Spieltag der Ausgabe (berechnet bzw. eingefroren in
* stadionzeitung_build_snapshot(), app/helpers.php). Aufbau je Spiel wie die
* „VORSCHAU"-Seite der Canva-Vorlage: Meta-Zeile (Mannschaft · Liga), darunter
* die Begegnung mit Wappen, rechts Datum/Anstoß.
*
* Props: $items stadionzeitung_live_data()['vorschau'] — je Team:
* team_name/league/match (Matchcenter-Rohform).
*/
$items = $items ?? [];
// Bewusst OHNE CTA-Fuß (Felix, 29.07.2026): mit drei Mannschaften füllt die
// Liste die Seite — das Matchcenter-Ticket wohnt auf der Ergebnis-Seite.
?>
<div class="stadionzeitung-vorschau">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Vorschau</p>
<h2 class="stadionzeitung-page__heading">Kommende Spiele</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($items === []): ?>
<p class="text-muted">Aktuell stehen keine weiteren Spiele fest.</p>
<?php endif; ?>
<?php foreach ($items as $item): ?>
<?php
$match = $item['match'] ?? [];
$when = array_filter([
trim((string) ($match['date_display'] ?? '')),
trim((string) ($match['time_display'] ?? '')),
]);
$place = !empty($match['is_home']) ? 'im Katzbachtal' : 'auswärts';
?>
<section class="stadionzeitung-vorschau__match">
<p class="stadionzeitung-vorschau__meta"><?= e(trim(implode(' · ', array_filter([(string) ($item['team_name'] ?? ''), (string) ($item['league'] ?? '')])))) ?></p>
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null],
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null]] as $side): ?>
<div class="stadionzeitung-vorschau__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-vorschau__row--own' : '' ?>">
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
<span class="stadionzeitung-vorschau__club"><?= e((string) $side['club']) ?></span>
</div>
<?php endforeach; ?>
<p class="stadionzeitung-vorschau__when"><?= e(implode(' · ', $when)) ?><?= $when !== [] ? ' · ' : '' ?><?= e($place) ?></p>
</section>
<?php endforeach; ?>
</div>

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Vorwort-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Termine/News) —
* schlichte weiße Content-Seite, keine fotografische Ausnahme wie das Cover.
* Der Text selbst ist KEIN Skript-Input: er wird im Chat gepflegt (wie
* data/events.json/news.json) und lebt im `vorwort`-Feld der Ausgabe in
* data/stadionzeitung.json (siehe bin/stadionzeitung-add.php).
*
* Fließtext bewusst in einer Serifenschrift (--ff-serif, PT Serif) statt
* --ff-body — einzige Stelle im Projekt mit einer dritten Schriftart: ein
* Vorwort ist ein redaktioneller Brief, keine UI, eine Lese-Serif passt hier
* besser als die Website-Grotesk. Überschrift bewusst NICHT in Versalien
* (anders als der Cover-Masthead) — ein Begrüßungssatz in Großbuchstaben
* wirkt geschrien, hier soll es wie eine echte Zeitschriften-Headline lesen.
* Vereins-Hero-Bild (dieselbe Quelle wie Startseite/Cover) blendet unten ein,
* wie im ursprünglichen Canva-Vorwort — füllt die sonst leere Fläche unter
* dem Fließtext, ohne die Lesbarkeit oben zu stören. Läuft über einen weißen
* Gradient-Layer (__bg-fade) statt mask-image auf dem Foto selbst — druckt
* zuverlässig mit (siehe CSS-Kommentar), erscheint also auch im PDF.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json: vorwort?, verfasser_photo?)
*/
$issue = $issue ?? [];
$vorwort = $issue['vorwort'] ?? null;
if (!$vorwort) {
return;
}
$heading = (string) ($vorwort['heading'] ?? '');
$paragraphs = $vorwort['paragraphs'] ?? [];
$closing = (string) ($vorwort['closing'] ?? '');
$signeeName = (string) ($vorwort['signee_name'] ?? '');
$signeeRole = (string) ($vorwort['signee_role'] ?? '');
$verfasserPhoto = $issue['verfasser_photo'] ?? null;
$heroImage = json_load('home')['hero']['image'] ?? null;
?>
<div class="stadionzeitung-vorwort">
<?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-vorwort__bg']); ?>
<div class="stadionzeitung-vorwort__bg-fade" aria-hidden="true"></div>
<?php endif; ?>
<header class="stadionzeitung-vorwort__head">
<p class="stadionzeitung-vorwort__kicker">Vorwort</p>
<img class="stadionzeitung-vorwort__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
</header>
<?php if ($heading !== ''): ?>
<h2 class="stadionzeitung-vorwort__heading"><?= e($heading) ?></h2>
<?php endif; ?>
<?php foreach ($paragraphs as $paragraph): ?>
<p class="stadionzeitung-vorwort__text"><?= e($paragraph) ?></p>
<?php endforeach; ?>
<?php if ($signeeName !== ''): ?>
<footer class="stadionzeitung-vorwort__signature">
<?php if ($verfasserPhoto): ?>
<?php component('img', ['image' => $verfasserPhoto, 'sizes' => '80px', 'class' => 'stadionzeitung-vorwort__portrait']); ?>
<?php endif; ?>
<div class="stadionzeitung-vorwort__signature-text">
<?php if ($closing !== ''): ?>
<p class="stadionzeitung-vorwort__closing"><?= e($closing) ?></p>
<?php endif; ?>
<p class="stadionzeitung-vorwort__signee"><?= e($signeeName) ?></p>
<?php if ($signeeRole !== ''): ?>
<p class="stadionzeitung-vorwort__role"><?= e($signeeRole) ?></p>
<?php endif; ?>
</div>
</footer>
<?php endif; ?>
</div>

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
/**
* Kennzahlen-Band über abgedunkeltem Foto (Vereinsgelände), Kanten verlaufen
* weich in den Seitenhintergrund. Props:
* $stats array{items: array{value, label}[], image?: array (img-Komponente)}
*/
$image = $stats['image'] ?? [
'base' => 'img/pages/vereinsgelaende',
'widths' => [1000, 1600],
'alt' => '',
];
?>
<section class="section stats" aria-label="Der Verein in Zahlen">
<div class="stats__media" aria-hidden="true">
<?php component('img', ['image' => $image, 'sizes' => '100vw', 'class' => 'stats__img']); ?>
</div>
<div class="container">
<ul class="stats__list">
<?php foreach ($stats['items'] as $item): ?>
<li class="stats__item">
<span class="stats__value"><?= e($item['value']) ?></span>
<span class="stats__label"><?= e($item['label']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>

View File

@@ -39,7 +39,7 @@ if (!empty($team['roster']['members']) || !empty($team['roster']['groups'])) {
component('faq-contact', [
'faq' => $team['faq'] ?? [],
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ' kurz beantwortet.',
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ', kurz beantwortet.',
'persons' => $contacts,
'contactTitle' => '',
'anchor' => 'kontakt',

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* Liste normalisierter Termine (Spiele + Vereins-Events gemischt). Props:
* $items array{date, date_end?, title, department, type, meta?, href?}
* $empty_text string (optional)
* Kennt selbst nichts von Matchcenter-/Events-Datenstruktur — bekommt nur die
* bereits gemergte, normalisierte Form (app/pages/termine.php baut sie).
*/
$items = $items ?? [];
$emptyText = $empty_text ?? 'Aktuell keine Termine.';
?>
<?php if ($items === []): ?>
<p class="text-muted"><?= e($emptyText) ?></p>
<?php else: ?>
<ul class="termine-list">
<?php foreach ($items as $item): ?>
<?php
$tag = !empty($item['href']) ? 'a' : 'div';
$chip = german_date_chip($item['date']);
$dateText = german_date_range($item['date'], $item['date_end'] ?? null);
?>
<li class="termine-list__item">
<<?= $tag ?> class="termine-list__link"<?= !empty($item['href']) ? ' href="' . e($item['href']) . '"' : '' ?>>
<span class="termine-list__date-chip" aria-hidden="true">
<span class="termine-list__date-day"><?= e($chip['day']) ?></span>
<span class="termine-list__date-month"><?= e($chip['month']) ?></span>
</span>
<span class="termine-list__body">
<span class="termine-list__title"><?= e($item['title']) ?></span>
<span class="termine-list__meta">
<time datetime="<?= e($item['date']) ?>"><?= e($dateText) ?></time><?php if (!empty($item['meta'])): ?> · <?= e($item['meta']) ?><?php endif; ?>
</span>
</span>
<span class="termine-list__badge termine-list__badge--<?= e($item['department']) ?>">
<?= e(department_label($item['department'])) ?>
</span>
</<?= $tag ?>>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>

View File

@@ -4,9 +4,13 @@ declare(strict_types=1);
/**
* Interaktive Vereins-Timeline (Museums-Look). Props:
* $events array — Stationen aus data/historie.json (year, text, image{src,width,height,alt})
* $events array — Stationen aus data/historie.json (year, text, image?{src,width,height,alt})
* Bilder werden bewusst in ORIGINALGRÖSSE eingebunden (keine Varianten) —
* historisches Material bleibt unangetastet.
* `image` ist optional: zu vielen belegten Ereignissen (Ligaereignisse, Chronik-Funde)
* gibt es kein Bild, das wir zeigen dürfen — Pressefotos gehören dem Verlag, die Fotos
* der alten Vereinsseiten sind verloren. Solche Stationen rendern als Notiz auf
* derselben Sepia-Fläche, ohne <figure>/<figcaption> (ohne Bild trägt das nichts).
* timeline.js ergänzt Scroll-Reveal + aktive Jahres-Navigation; ohne JS ist
* alles sichtbar (Initial-Versteckt-Zustand nur unter .js-reveal).
*/
@@ -42,6 +46,7 @@ foreach ($events as $event) {
<span class="timeline__marker" aria-hidden="true"></span>
<article class="timeline__card">
<h2 class="timeline__year"><?= e($event['year']) ?></h2>
<?php if (!empty($event['image']['src'])): ?>
<figure class="timeline__figure">
<img src="<?= e(asset($event['image']['src'])) ?>"
alt="<?= e($event['image']['alt']) ?>"
@@ -49,6 +54,11 @@ foreach ($events as $event) {
loading="<?= $i === 0 ? 'eager' : 'lazy' ?>" decoding="async">
<figcaption class="timeline__text"><?= e($event['text']) ?></figcaption>
</figure>
<?php else: ?>
<div class="timeline__figure timeline__figure--note">
<p class="timeline__text"><?= e($event['text']) ?></p>
</div>
<?php endif; ?>
</article>
</li>
<?php endforeach; ?>

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* Bildergalerie einer Veranstaltung: Raster aus Vorschaubildern, Klick öffnet
* das Bild groß. Props:
* $bilder array [{base, widths, alt, source?}, …] (script-verwaltet,
* bin/veranstaltung-bilder.php)
* $title string (optional, Default 'Bilder')
*
* Progressive enhancement: jedes Vorschaubild ist ein normaler Link auf die
* größte Variante der Datei — ohne JS führt der Klick einfach zum Bild. Mit JS
* fängt assets/js/lightbox.js den Klick ab und zeigt es im Overlay
* (Vor/Zurück, Pfeiltasten, Esc). Das Overlay-Markup steht am Ende und ist
* `hidden`, bis JS es übernimmt.
*
* Die Overlay-Grafik erbt ihr srcset vom angeklickten Vorschaubild (siehe
* lightbox.js) — deshalb braucht der Link hier keine zweite Bildliste.
*/
$bilder = $bilder ?? [];
$title = $title ?? 'Bilder';
if ($bilder === []) {
return;
}
$anzahl = count($bilder);
?>
<section class="section veranstaltung-galerie" id="bilder" data-lightbox-gallery>
<div class="container">
<h2><?= e($title) ?></h2>
<ul class="veranstaltung-galerie__grid">
<?php foreach ($bilder as $i => $bild): ?>
<?php
$gross = asset($bild['base'] . '-' . max($bild['widths']) . '.jpg');
?>
<li class="veranstaltung-galerie__item">
<a class="veranstaltung-galerie__link" href="<?= e($gross) ?>" data-lightbox="<?= $i ?>">
<?php component('img', [
'image' => $bild,
'sizes' => '(max-width: 600px) 45vw, (max-width: 992px) 30vw, 22vw',
'class' => 'veranstaltung-galerie__img',
]); ?>
<span class="visually-hidden">(größer ansehen)</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php component('lightbox-overlay', ['label' => $title, 'count' => $anzahl]); ?>
</section>

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* Infoblock einer Veranstaltung: links die harten Angaben (Zeitraum, Beginn,
* Adresse, Hinweis zum Parken), rechts die Karte. Props:
* $date_text string fertig formatierter Zeitraum (german_date_range())
* $date_iso string ISO-Startdatum für <time datetime>
* $time string 'HH:MM' oder leer
* $location string Ortsname der Veranstaltung
* $karte string 'club' = auf unserem eigenen Gelände: Adresse kommt aus
* data/club.json und die Karte wird gezeigt. Alles andere (oder
* leer) → keine Karte, nur der Ortsname (ein Fest woanders hätte
* sonst eine falsche Karte daneben).
* $anfahrt array optionale Absätze zum Parken/Zugang
*
* Nur für Feste, die noch ausstehen (app/pages/veranstaltung.php ruft die
* Komponente danach nicht mehr auf) — deshalb steht hier durchgehend Zukunft.
*
* KEINE Leaflet-Karte, sondern ein statisches Kartenbild: Leaflet würde seine
* Kacheln zur Laufzeit von einem fremden Tile-Server holen, und genau das
* verbietet Regel 1 (die CSP mit img-src 'self' würde sie ohnehin blocken).
* Das Bild liegt selbst gehostet bei uns, der Klick führt erst dann zu Google
* Maps, wenn jemand ihn macht. Namensnennung für die OSM-Daten steht als
* Bildunterschrift, das verlangt die ODbL.
*/
$dateText = $date_text ?? '';
$dateIso = $date_iso ?? '';
$time = $time ?? '';
$location = $location ?? '';
$karte = $karte ?? '';
$anfahrt = $anfahrt ?? [];
$istBeiUns = $karte === 'club';
$club = $istBeiUns ? json_load('club') : [];
$adresse = $club['address'] ?? [];
$geo = $club['geo'] ?? [];
// Kartenlink ohne API-Schlüssel und ohne Einbettung: Koordinaten aus club.json
// (Single Source), damit die Adresse hier nicht als Text zweitverwertet wird.
$mapsUrl = '';
if ($istBeiUns && !empty($geo['lat']) && !empty($geo['lng'])) {
$mapsUrl = 'https://www.google.com/maps/search/?api=1&query=' . $geo['lat'] . ',' . $geo['lng'];
}
?>
<section class="section veranstaltung-info" id="infos">
<div class="container">
<div class="veranstaltung-info__panel">
<div class="veranstaltung-info__facts">
<h2>Gut zu wissen</h2>
<?php if ($dateText !== ''): ?>
<p class="veranstaltung-info__wann">
<time datetime="<?= e($dateIso) ?>"><?= e($dateText) ?></time>
</p>
<?php endif; ?>
<?php if ($time !== ''): ?>
<?php /* Wochentag dazu, weil bei einem mehrtägigen Fest sonst offen bleibt, für
welchen Tag die Uhrzeit gilt. */ ?>
<?php $wochentag = german_weekday($dateIso); ?>
<p class="veranstaltung-info__beginn">Los geht es<?= $wochentag !== '' ? ' am ' . e($wochentag) : '' ?> um <?= e($time) ?> Uhr.</p>
<?php endif; ?>
<address class="veranstaltung-info__adresse">
<?php if ($istBeiUns && !empty($adresse['street'])): ?>
<?= e($adresse['venue'] ?? $location) ?><br>
<?= e($adresse['street']) ?><br>
<?= e($adresse['zip'] ?? '') ?> <?= e($adresse['city'] ?? '') ?>
<?php else: ?>
<?= e($location) ?>
<?php endif; ?>
</address>
<?php foreach ($anfahrt as $absatz): ?>
<p class="veranstaltung-info__hinweis"><?= e($absatz) ?></p>
<?php endforeach; ?>
</div>
<?php if ($mapsUrl !== ''): ?>
<figure class="veranstaltung-info__map">
<a class="veranstaltung-info__map-link" href="<?= e($mapsUrl) ?>" target="_blank" rel="noopener">
<?php component('img', [
'image' => ['src' => 'img/pages/sportheim-map.jpg', 'width' => 512, 'height' => 512, 'alt' => 'Karte mit unserem Sportgelände im Katzbachtal'],
'class' => 'veranstaltung-info__map-img',
]); ?>
<span class="veranstaltung-info__map-cta">
<?= icon('geo-alt-fill') ?> In Google Maps öffnen
<span class="visually-hidden">(öffnet in neuem Tab)</span>
</span>
</a>
<figcaption class="veranstaltung-info__map-credit">Kartendaten © OpenStreetMap-Mitwirkende</figcaption>
</figure>
<?php endif; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* Die Plakate einer Veranstaltung (Ankündigung, Programm, …) als große,
* anklickbare Bilder. Props:
* $plakate array [{base, widths, alt, source?}, …] (script-verwaltet)
* $title string (optional, Default 'Plakate')
* $compact bool (optional) — kleinere Variante für den Rückblick
*
* Die Überschrift ist sichtbar, WENN die Plakate klein und nachgestellt sind
* (Rückblick): nach einer langen Fotogalerie brauchen zwei kleine Bilder am
* Seitenende eine Beschriftung. Als Hauptblock einer Ankündigung erklären sie
* sich dagegen selbst, dort bleibt die Überschrift visually-hidden — im Markup
* steht sie trotzdem, damit die Headings-Hierarchie keine Lücke hat (gleiche
* Lösung wie in stadionzeitung-cards).
*
* Warum eine eigene Komponente und nicht die Fotogalerie: ein Plakat ist kein
* Foto vom Fest, sondern trägt Informationen (Termine, Uhrzeiten, Ort). Es
* gehört nach vorne statt in den Rückblick und braucht Hochformat statt
* beschnittener Raster-Kacheln. Vergrößern läuft aber über dieselbe Lightbox —
* gerade bei einem Programmplakat will man das lesen können.
*
* Ohne JS ist jedes Plakat ein normaler Link auf die größte Variante.
*/
$plakate = $plakate ?? [];
$title = $title ?? 'Plakate';
$compact = $compact ?? false;
if ($plakate === []) {
return;
}
?>
<section class="section veranstaltung-plakate<?= $compact ? ' veranstaltung-plakate--compact' : '' ?>" id="plakate" data-lightbox-gallery>
<div class="container">
<h2<?= $compact ? '' : ' class="visually-hidden"' ?>><?= e($title) ?></h2>
<ul class="veranstaltung-plakate__grid">
<?php foreach ($plakate as $i => $plakat): ?>
<li class="veranstaltung-plakate__item">
<a class="veranstaltung-plakate__link" href="<?= e(asset($plakat['base'] . '-' . max($plakat['widths']) . '.jpg')) ?>" data-lightbox="<?= $i ?>">
<?php component('img', [
'image' => $plakat,
'sizes' => count($plakate) > 1 ? '(max-width: 768px) 92vw, 44vw' : '(max-width: 768px) 92vw, 30vw',
'eager' => $i === 0 && !$compact,
'class' => 'veranstaltung-plakate__img',
]); ?>
<span class="visually-hidden">(größer ansehen)</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php component('lightbox-overlay', ['label' => $title, 'count' => count($plakate)]); ?>
</section>

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* Tagesprogramm einer Veranstaltung. Props:
* $programm array [{tag: 'YYYY-MM-DD', punkte: [{zeit: 'HH:MM', titel, text?}]}, …]
* $title string (optional, Default 'Programm')
* $compact bool (optional) — kleinere Variante für den Rückblick, wo das
* Programm nicht mehr informiert, sondern nur noch dokumentiert.
*
* Ein Tag = eine Gruppe mit Wochentag-Überschrift, damit ein dreitägiges Fest
* lesbar bleibt. Ohne Programm rendert die Komponente nichts (die Seite startet
* ja mit nichts als dem Plakat).
*/
$programm = $programm ?? [];
$title = $title ?? 'Programm';
$compact = $compact ?? false;
if ($programm === []) {
return;
}
?>
<section class="section veranstaltung-programm<?= $compact ? ' veranstaltung-programm--compact' : '' ?>" id="programm">
<div class="container">
<h2><?= e($title) ?></h2>
<div class="veranstaltung-programm__tage">
<?php foreach ($programm as $tag): ?>
<?php
$tagIso = (string) ($tag['tag'] ?? '');
$weekday = german_weekday($tagIso);
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . german_date($tagIso));
?>
<div class="veranstaltung-programm__tag">
<?php if ($tagIso !== ''): ?>
<h3 class="veranstaltung-programm__tag-titel">
<time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time>
</h3>
<?php endif; ?>
<ul class="veranstaltung-programm__liste">
<?php foreach ($tag['punkte'] ?? [] as $punkt): ?>
<?php $zeit = (string) ($punkt['zeit'] ?? ''); ?>
<li class="veranstaltung-programm__punkt">
<?php if ($zeit !== ''): ?>
<span class="veranstaltung-programm__zeit">
<time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time>
</span>
<?php else: ?>
<?php /* Platzhalter hält die Spalte, aber ohne die Zeit-Klasse — sonst stünde
dort der rote Balken ohne Inhalt daneben. */ ?>
<span aria-hidden="true"></span>
<?php endif; ?>
<span class="veranstaltung-programm__body">
<span class="veranstaltung-programm__titel"><?= e($punkt['titel'] ?? '') ?></span>
<?php if (!empty($punkt['text'])): ?>
<span class="veranstaltung-programm__text"><?= e($punkt['text']) ?></span>
<?php endif; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* Liste der Veranstaltungen, ganze Karte verlinkt auf die Detailseite. Props:
* $items array aus data/veranstaltungen.json (bereits vorsortiert)
* $cta string (optional, Default 'Ansehen') — Linktext auf der Karte
* $empty_text string (optional)
*
* EINE Kartenform für „Was ansteht" und „Rückblick" (Beschluss 30.07.2026):
* Plakat links, Text rechts, volle Breite, untereinander gestapelt. Vorher war
* der erste Eintrag breit und alles Weitere ein schmales Raster — zwei Formen
* für dieselbe Sache, und der Rückblick sah dadurch nach Restposten aus.
*
* Vorschaubild ist das Hauptplakat (Hochformat). Fehlt es (die Seite entsteht ja
* schon, bevor das Plakat fertig ist), greift das erste Galeriebild; ohne
* beides bleibt die Karte reiner Text und bricht nicht.
*/
$items = $items ?? [];
$cta = $cta ?? 'Ansehen';
$emptyText = $empty_text ?? 'Aktuell keine Veranstaltungen.';
/** Erstes verfügbares Bild einer Veranstaltung (Hauptplakat vor Galerie). */
$vorschau = static function (array $v): ?array {
return $v['plakate'][0] ?? $v['galerie'][0] ?? null;
};
?>
<?php if ($items === []): ?>
<p class="text-muted"><?= e($emptyText) ?></p>
<?php else: ?>
<ul class="veranstaltungen-cards__list">
<?php foreach ($items as $v): ?>
<?php
$bild = $vorschau($v);
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
?>
<li>
<a class="veranstaltung-card" href="<?= e(url('veranstaltungen/' . $v['slug'])) ?>">
<?php if ($bild !== null): ?>
<div class="veranstaltung-card__media">
<?php component('img', [
'image' => $bild,
'sizes' => '(max-width: 768px) 100vw, 300px',
'class' => 'veranstaltung-card__img',
]); ?>
</div>
<?php endif; ?>
<div class="veranstaltung-card__body">
<h3 class="veranstaltung-card__name"><?= e($v['title']) ?></h3>
<p class="veranstaltung-card__meta">
<time datetime="<?= e($v['date_start']) ?>"><?= e($dateText) ?></time>
<?php if (!empty($v['location'])): ?>
· <?= e($v['location']) ?>
<?php endif; ?>
</p>
<?php if (!empty($v['text'])): ?>
<p class="veranstaltung-card__text"><?= e($v['text']) ?></p>
<?php endif; ?>
<span class="veranstaltung-card__cta">
<?= e($cta) ?> <?= icon('arrow-right', 'veranstaltung-card__arrow') ?>
</span>
</div>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,8 @@ $anchor_slug = static function (string $s): string {
$meta = [
'title' => 'Ansprechpartner',
'description' => 'Ansprechpartner und Kontaktwege beim TSV 1908 Kulmbach Geschäftsstelle, Turnabteilung und Vorstand mit E-Mail und Telefon auf einen Blick.',
'description' => 'Alle Ansprechpartner des TSV 08 Kulmbach auf einen Blick: Herren-, Frauen- und Jugendfußball sowie Turnen, mit Telefonnummer oder E-Mail. Sprich uns direkt an!',
'scripts' => ['persons-tabs.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
@@ -40,7 +41,7 @@ $meta = [
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'bottom-left',
'align' => 'center',
'title' => 'Ansprechpartner',
'text' => $page['intro'] ?? '',
'image' => [
@@ -50,14 +51,29 @@ component('hero', ['hero' => [
],
]]);
$i = 0;
foreach ($groups as $label => $depts):
?>
<?php /* EINE Sektion für beide Bereiche, Tab-Leiste direkt über den
Karten (assets/js/persons-tabs.js schaltet um und spiegelt den
aktiven Bereich in die URL — #kontakt-turnen ist teilbar).
Ohne JS bleibt die Leiste hidden und beide Panels stehen mit
ihren Überschriften untereinander. */ ?>
<section class="section section--tinted" id="kontakt" data-persons-tabs>
<div class="container">
<div class="filter-bar persons-tabs__bar" role="group" aria-label="Bereich wählen" hidden data-persons-tabs-bar>
<?php $i = 0; foreach (array_keys($groups) as $label): ?>
<button type="button" class="tab filter-bar__tab" data-tab-target="kontakt-<?= e($anchor_slug($label)) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
<?php endforeach; ?>
</div>
<?php
foreach ($groups as $label => $depts) {
component('person-grid', [
'departments' => $depts,
'title' => $label,
'intro' => '',
'anchor' => 'kontakt-' . $anchor_slug($label),
'tinted' => ($i % 2 === 0),
'embedded' => true,
]);
$i++;
endforeach;
}
?>
</div>
</section>
<?php

View File

@@ -5,10 +5,16 @@ declare(strict_types=1);
/**
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die drei
* Formulare (Versand über den All-Inkl-Mailserver, also derselbe Auftrags-
* verarbeiter wie beim Hosting), server-seitig gecachter Instagram-Feed
* (Bilder von unserer Domain). Vereinsdaten aus club.json.
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die vier
* Formulare (Kontakt, Turnabteilung, Mitgliedsantrag, Sportheimbuchung; Versand
* über den All-Inkl-Mailserver, also derselbe Auftragsverarbeiter wie beim
* Hosting), server-seitig gecachter Instagram-Feed (Bilder von unserer Domain).
* Vereinsdaten aus club.json.
*
* Alle Empfänger liegen auf unserer eigenen Domain bei All-Inkl (info@,
* turnen@) — deshalb stimmt die Aussage weiter unten, dass kein zusätzlicher
* E-Mail-Dienstleister im Spiel ist. Zeigt ein Postfach künftig auf einen
* fremden Anbieter, muss dieser Satz mit.
*/
$club = json_load('club');
$addr = $club['address'] ?? [];
@@ -36,7 +42,7 @@ $meta = [
</p>
</div>
<div class="legal__body">
<div class="legal__body prose">
<section>
<h2>Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</p>
@@ -123,6 +129,12 @@ $meta = [
Tracking-Zwecken</strong> und bindet keine Dienste ein, die solche Cookies
setzen würden. Aus diesem Grund benötigen wir auch kein Cookie-Banner.
</p>
<p>
Eine Ausnahme betrifft nur den passwortgeschützten internen Bereich
(siehe „Interne Dateiablage“): Wer sich dort anmeldet, erhält ein
technisch notwendiges Sitzungs-Cookie, das die Anmeldung für die Dauer
der Sitzung merkt. Ohne dieses Cookie ist eine Anmeldung nicht möglich.
</p>
</section>
<section>
@@ -152,6 +164,15 @@ $meta = [
E-Mail-Adresse, optional die Telefonnummer sowie Art, Datum und Personenzahl
deiner Veranstaltung, um dir ein Angebot machen zu können.
</p>
<p>
Das <strong>Anfrageformular der Turnabteilung</strong> auf
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
interessierst; eine Nachricht ist dort freiwillig. Diese Anfragen gehen an das
Postfach der Turnabteilung
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
weil die Abteilung sie selbst beantwortet.
</p>
<p>
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
@@ -160,6 +181,18 @@ $meta = [
deine Anfrage abschließend bearbeitet ist und keine gesetzlichen
Aufbewahrungspflichten entgegenstehen.
</p>
<p>
Schickst du uns Material für unsere <strong>Vereinshistorie</strong> Fotos,
Zeitungsausschnitte, Dokumente oder Erinnerungen an
<a href="mailto:<?= e($club['email_historie']) ?>"><?= e($club['email_historie']) ?></a>,
bewahren wir es gemeinsam mit deinem Namen als Quellenangabe in unserem
Vereinsarchiv auf. Grundlage dafür ist unser berechtigtes Interesse an der
Dokumentation der Vereinsgeschichte (Art. 6 Abs. 1 lit. f DSGVO).
Veröffentlichen auf dieser Website, in einer Chronik oder Festschrift
tun wir nur, was du uns ausdrücklich freigegeben hast (Art. 6 Abs. 1 lit. a
DSGVO); diese Freigabe kannst du jederzeit widerrufen. Sind auf Bildern
weitere Personen erkennbar, sag uns bitte, was du darüber weißt.
</p>
<p>
Der technische Versand der Formular-Nachrichten als E-Mail läuft über den
Mailserver unseres Webhosters ALL-INKL.COM (siehe oben) — die Daten bleiben
@@ -177,6 +210,35 @@ $meta = [
</p>
</section>
<section>
<h2>Interne Dateiablage</h2>
<p>
Unter <code>/dateien</code> betreiben wir einen
<strong>passwortgeschützten Bereich</strong>, in dem Mitglieder Fotos und
Videos von Vereinsveranstaltungen für die Website abliefern können. Er ist
nicht öffentlich, nicht verlinkt und von Suchmaschinen ausgenommen; die
Zugangsdaten geben wir nur intern weiter.
</p>
<p>
Verarbeitet werden dort ausschließlich die hochgeladenen Dateien selbst
(einschließlich der darin enthaltenen Aufnahmen von Personen und der
technischen Bilddaten wie Aufnahmezeit oder Kameramodell) sowie der
gewählte Benutzername mit dem Zeitpunkt der Anmeldung. Fehlgeschlagene
Anmeldeversuche halten wir zum Schutz gegen automatisierte Angriffe kurz
fest, dabei die IP-Adresse nur als nicht zurückrechenbare Prüfsumme.
</p>
<p>
Die Dateien liegen ausschließlich auf unserem eigenen Webspace, außerhalb
des öffentlich erreichbaren Bereichs, und werden nicht an Dritte
weitergegeben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO
(berechtigtes Interesse an der Vereinskommunikation); für die
Veröffentlichung von Personenaufnahmen auf der Website gilt zusätzlich der
Abschnitt „Foto- und Videoaufnahmen“. Wer nicht möchte, dass eine Aufnahme
von ihm dort abgelegt oder veröffentlicht wird, kann sich jederzeit an die
oben genannten Kontaktdaten wenden wir löschen sie dann.
</p>
</section>
<section>
<h2>Keine Analyse-, Tracking- oder Werbedienste</h2>
<p>

View File

@@ -21,7 +21,7 @@ $cards = [
$meta = [
'title' => 'Fußball in Kulmbach',
'description' => 'Herren-, Frauen- und Jugendfußball beim TSV 08 Kulmbach: zwei Herrenmannschaften, Frauenmannschaft und Nachwuchs aktiv werden im Katzbachtal.',
'description' => 'Fußball in Kulmbach beim TSV 08: zwei Herrenmannschaften, Frauenfußball und Jugendteams von der G- bis zur C-Jugend. Komm zum Probetraining im Katzbachtal!',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema(
[

View File

@@ -3,14 +3,28 @@
declare(strict_types=1);
/**
* Vereinshistorie — schmaler Hero + interaktive Timeline.
* Inhalte: data/historie.json (Single Source of Truth).
* Vereinshistorie — schmaler Hero + interaktive Timeline + Aufruf zum Mitsammeln.
* Inhalte: data/historie.json (Single Source of Truth), Mailadresse aus data/club.json.
*/
$historie = json_load('historie');
$club = json_load('club');
// Die Adresse steht ausschließlich in club.json — der Fließtext in historie.json
// markiert ihre Stelle mit {mail}, CTA-Label und mailto-Ziel entstehen hier.
// Betreff vorbelegt, damit Zuschriften direkt zuordenbar sind.
$mail = $club['email_historie'];
$contribute = $historie['contribute'] + ['cta' => [
'label' => $mail,
'href' => 'mailto:' . $mail . '?subject=' . rawurlencode('Material für die Vereinshistorie'),
]];
$contribute['text'] = array_map(
static fn (string $p): string => strtr($p, ['{mail}' => $mail]),
$contribute['text']
);
$meta = [
'title' => 'Vereinshistorie Der Katzbachtaler Weg',
'description' => 'Erlebe die bewegte Geschichte des TSV 08 Kulmbach von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
'title' => 'Vereinshistorie: Der Katzbachtaler Weg',
'description' => 'Erlebe unsere bewegte Geschichte, von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
'scripts' => ['timeline.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
@@ -21,3 +35,4 @@ $meta = [
component('hero', ['hero' => $historie['hero']]);
component('timeline', ['events' => $historie['events']]);
component('section', ['section' => $contribute]);

View File

@@ -10,14 +10,17 @@ declare(strict_types=1);
$home = json_load('home');
$meta = [
'title' => 'Fußball & Turnen in Kulmbach TSV 08 Kulmbach',
'title' => 'Fußball & Turnen in Kulmbach | TSV 08 Kulmbach',
'title_absolute' => true,
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin seit 1908 im Katzbachtal. Jetzt Mitglied werden!',
'scripts' => ['form.js', 'carousel.js'],
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin: seit 1908 in Kulmbach. Jetzt Mitglied werden!',
'scripts' => ['form.js', 'carousel.js', 'crest.js'],
'schema' => [website_schema()],
];
component('hero', ['hero' => $home['hero']]);
// Die nächsten drei Termine als Live-Aufhänger im Hero — Spiele und
// Vereins-Events gemischt, aus upcoming_highlights() (baut auf derselben
// Merge-Logik auf wie /termine). Keine Termine → der Hero rendert ohne Karte.
component('hero', ['hero' => $home['hero'], 'upcoming' => upcoming_highlights(3)]);
component('banner-slider');
component('instagram-feed');
@@ -25,12 +28,10 @@ foreach ($home['sections'] as $section) {
component('section', ['section' => $section]);
}
component('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]);
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
component('person-grid', [
'departments' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
'columns' => 3,
]);
component('contact-form', ['intro' => $home['contact']]);

View File

@@ -28,7 +28,7 @@ $meta = [
<p class="legal__lead">Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).</p>
</div>
<div class="legal__body">
<div class="legal__body prose">
<section>
<h2>Anbieter</h2>
<div class="legal__card">

View File

@@ -21,7 +21,7 @@ $contacts = array_values(array_filter(
$meta = [
'title' => 'Jugendfußball in Kulmbach',
'description' => 'Jugendfußball in Kulmbach entdecke unsere Mannschaften von der G- bis zur C-Jugend, Betreuer und Trainingszeiten. Probiere Fußball in familiärer Atmosphäre aus.',
'description' => 'Jugendfußball in Kulmbach beim TSV 08: Teams von der G- bis zur C-Jugend, Trainingszeiten und kostenloses Schnuppertraining. Komm vorbei ins Katzbachtal!',
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
'schema' => page_schema(
@@ -70,7 +70,7 @@ component('age-groups', ['groups' => $page['groups'] ?? []]);
component('faq-contact', [
'faq' => $page['faq'] ?? [],
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp. Wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
'persons' => $contacts,
'contactTitle' => '',
'anchor' => 'kontakt',

View File

@@ -10,7 +10,7 @@ $page = json_load('kontakt');
$meta = [
'title' => 'Kontakt',
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder einem allgemeinen Anliegen. Wir melden uns so schnell wie möglich.',
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder deinem Anliegen. Wir melden uns so schnell wie möglich.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'scripts' => ['form.js'],
'schema' => page_schema([

View File

@@ -14,9 +14,9 @@ $teams = $mc['teams'] ?? [];
$upcoming = $mc['upcoming'] ?? [];
$meta = [
'title' => 'Matchcenter Spiele, Ergebnisse & Tabellen',
'title' => 'Matchcenter · Spiele, Ergebnisse & Tabellen',
'description' => 'Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften des TSV 08 Kulmbach: 1. Mannschaft und Frauenmannschaft automatisch aktuell.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'og_image' => 'img/pages/matchcenter-hero-1600.jpg',
'scripts' => ['crest.js', 'group-tabs.js'],
'schema' => array_merge(
page_schema([
@@ -30,11 +30,16 @@ $meta = [
component('hero', ['hero' => [
'compact' => true,
'align' => 'bottom-left',
'align' => 'center',
'title' => 'Matchcenter',
'text' => 'Alle Spiele, Ergebnisse und Tabellen unserer Aktivenmannschaften immer aktuell.',
/* Eigenes Hero-Motiv (Spielszene) statt des leeren Platzes von /fussball — die Seite
zeigt Spiele, also zeigt der Kopf welche. Quelle: 14_mrz.jpg aus der alten Seite,
vor dem Skalieren oben um 230 px beschnitten (1620x1080 → 1620x850). Der Beschnitt
rückt die Spielszene in die Bildmitte, sonst schneidet der flache Compact-Hero sie
auf Mobil komplett weg. */
'image' => [
'base' => 'img/pages/fussball-hero',
'base' => 'img/pages/matchcenter-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],

View File

@@ -19,7 +19,7 @@ $feeOptions = array_map(
$meta = [
'title' => 'Mitglied werden',
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag alle Beiträge und Infos für deinen Vereinsbeitritt.',
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag. Alle Beiträge und Infos für deinen Vereinsbeitritt.',
'og_image' => 'img/hero/hero-1600.jpg',
'scripts' => ['form.js'],
'schema' => page_schema(
@@ -33,7 +33,9 @@ $meta = [
component('hero', ['hero' => $page['hero']]);
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung']);
// Titel nur für Screenreader: der Hero sagt bereits „Wähle deine Abteilung", und die
// beiden Karten sprechen optisch für sich. Die h2 bleibt für die Heading-Kette stehen.
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung', 'title_hidden' => true]);
?>
<section class="section membership-signup" id="anmeldung-fussball">
<div class="container membership-signup__inner">

View File

@@ -26,8 +26,8 @@ $contact_for = static function (array $pos) use ($directory): ?array {
$meta = [
'title' => 'Mitmachen & Ehrenamt',
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen mit direktem Ansprechpartner, ohne Vorerfahrung.',
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen. Direkte Ansprechpartner, keine Vorerfahrung nötig!',
'og_image' => 'img/pages/mitmachen-hero-1600.jpg',
'scripts' => ['position-filter.js'],
'schema' => array_merge(
page_schema(
@@ -61,7 +61,7 @@ component('hero', ['hero' => $page['hero']]);
<?php component('position-card', ['position' => $pos, 'contact' => $contact_for($pos)]); ?>
<?php endforeach; ?>
</div>
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
</div>
</section>
<?php if (!empty($page['arguments']['items'])): ?>

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Einzelne Meldung als Artikel: dynamische Route news/<slug>, siehe
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
* Ermittelt den Slug selbst wieder aus der URL, Daten aus data/news.json
* (chat-gepflegt, keine Automatisierung).
*
* Die Seite existiert nur für Meldungen MIT `artikel` (news_has_article()):
* eine Kurzmeldung bleibt eine Feed-Zeile auf /news, ihre URL liefert 404. Ohne
* diese Bedingung stünde unter jedem Slug eine Seite mit nichts als der
* Überschrift.
*
* Geladen wird bewusst UNGEFILTERT (news_find(), nicht news_load()): ein
* Entwurf muss unter seiner direkten URL lesbar bleiben, damit er gegengelesen
* werden kann. Sichtbar wird er dadurch nirgends sonst, und er trägt noindex.
*
* Bewusst OHNE hero (wie die Veranstaltungs-Detailseite): das Titelbild ist der
* Aufmacher im Artikelkörper, nicht ein cover-beschnittener Hintergrund.
*/
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$newsSlug = substr($urlSlug, strlen('news/'));
$n = $newsSlug !== '' ? news_find($newsSlug) : null;
if ($n === null || !news_has_article($n)) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
http_response_code(404);
require APP_PATH . '/pages/404.php';
return;
}
$isDraft = !news_is_published($n);
$pageSlug = 'news/' . $n['slug'];
$hatBilder = array_filter(
$n['artikel'],
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild'
) !== [];
$meta = [
'title' => $n['title'],
'description' => (string) ($n['text'] ?? $n['title']),
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'News', 'slug' => 'news'],
['name' => $n['title'], 'slug' => $pageSlug],
]),
// Mit $pageSlug: derselbe Article-Knoten (@id) wie auf /news.
array_values(array_filter([news_article_schema($n, $pageSlug)]))
),
];
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
if ($hatBilder) {
$meta['scripts'] = ['lightbox.js'];
}
if (!empty($n['image']['base']) && !empty($n['image']['widths'])) {
$meta['og_image'] = $n['image']['base'] . '-' . max($n['image']['widths']) . '.jpg';
}
if ($isDraft) {
$meta['noindex'] = true;
}
?>
<?php component('detail-head', [
'back' => ['href' => url('news'), 'label' => 'Alle News'],
'title' => $n['title'],
'badge' => $isDraft ? 'Entwurf' : '',
'date_iso' => (string) ($n['date'] ?? ''),
'date_text' => !empty($n['date']) ? german_date((string) $n['date']) : '',
]); ?>
<?php component('news-artikel', [
'image' => $n['image'] ?? null,
'blocks' => $n['artikel'],
]); ?>

56
app/pages/news.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* Zentrale News-Seite: mischt redaktionelle Meldungen (data/news.json,
* chat-gepflegt), Instagram-Posts (data/instagram.json, automatisch gecacht)
* und zuletzt gespielte Ergebnisse (data/matchcenter.json, teamübergreifend)
* zu einem gemeinsamen, absteigend sortierten Feed. Merge-Logik in
* news_build_feed() (app/helpers.php) — dieselbe Grundlage nutzt die
* Stadionzeitungs-Vorlage (Phase 3), damit beide nie auseinanderlaufen.
*
* Meldungen mit Artikeltext (`artikel` in data/news.json) verlinken auf ihre
* Detailseite /news/<slug>. Sie stehen immer alle im Feed: die Kappung aus
* config('news.max_items') trifft nur Instagram und Ergebnisse, sonst wäre ein
* älterer Artikel irgendwann von der Website aus nicht mehr verlinkt.
*/
$feed = news_build_feed();
$meta = [
'title' => 'News',
'description' => 'Alle Neuigkeiten des TSV 08 Kulmbach: aktuelle Meldungen, Fußball-Ergebnisse und Instagram-Beiträge aus dem Vereinsleben in Kulmbach an einem Ort.',
'scripts' => ['news-filter.js'],
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'News', 'slug' => 'news'],
]),
// Meldungen mit Detailseite bekommen ihren Slug mit — dann trägt der
// Article-Knoten hier dieselbe @id wie auf /news/<slug>, beide Seiten
// beschreiben also nachweislich denselben Artikel.
array_values(array_filter(array_map(
static fn (array $n): ?array => news_article_schema($n, news_has_article($n) ? 'news/' . $n['slug'] : null),
$feed['news']
)))
),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'News',
'text' => 'Unsere Meldungen, Ergebnisse und Instagram-Beiträge an einem Ort.',
'image' => [
'base' => 'img/pages/fussball-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
?>
<section class="section">
<div class="container">
<?php component('news-feed', ['items' => $feed['items'], 'featured' => true, 'filterable' => true, 'empty_text' => 'Aktuell gibt es noch keine News.']); ?>
</div>
</section>

View File

@@ -11,7 +11,7 @@ $page = json_load('partner-werden');
$meta = [
'title' => 'Werbepartner werden',
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen.',
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach: sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen!',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema(
[

View File

@@ -11,7 +11,7 @@ $club = json_load('club');
$meta = [
'title' => 'Sportheim mieten in Kulmbach',
'description' => 'Das Sportheim auf der Hans-Rausch-Sportanlage in Kulmbach für Feiern und Veranstaltungen mieten Küche, Außenbereich, Beamer optional. Jetzt anfragen.',
'description' => 'Sportheim in Kulmbach mieten: auf der Hans-Rausch-Sportanlage, mit Küche, Außenbereich und Beamer auf Wunsch. Jetzt unverbindlich anfragen!',
'og_image' => 'img/pages/sportheim-2.jpg',
'schema' => page_schema(
[

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
/**
* Einzelne Stadionzeitung-Ausgabe: dynamische Route stadionzeitung/<slug>,
* siehe public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs
* kennt). Ermittelt den Ausgaben-Slug selbst wieder aus der URL, Daten aus
* data/stadionzeitung.json (maschinenverwaltet von bin/stadionzeitung-add.php).
*
* Bewusst OHNE das übliche hero-Component: der Swipe-Viewer soll so viel
* Bildschirmhöhe wie möglich bekommen (gerade am Handy) — stattdessen ein
* schmaler Kopfbereich direkt über dem Viewer.
*/
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$ausgabeSlug = substr($urlSlug, strlen('stadionzeitung/'));
/*
* Reservierter Slug „aktuell": leitet auf die neueste veröffentlichte Ausgabe
* weiter. Damit zeigt ein GEDRUCKTER QR-Code (Plakat, Aushang) dauerhaft auf die
* jeweils aktuelle Ausgabe, ohne dass zu jedem Heimspiel ein neuer Code
* entstehen muss. Kollision ausgeschlossen: Ausgaben-Slugs beginnen immer mit
* dem Datum (2026-08-01-…).
*
* Drei Dinge sind hier load-bearing:
* 1. **302, nie 301.** Ein 301 wird von Browsern und Crawlern dauerhaft
* gemerkt — das Plakat zeigte dann für immer auf die erste Ausgabe, und
* zwar unreparierbar auf allen Geräten, die ihn einmal gesehen haben.
* 2. `no-store`, damit ein Handy, das im letzten Monat gescannt hat, nicht
* das alte Ziel wiederverwendet.
* 3. Ohne veröffentlichte Ausgabe geht es aufs Archiv statt in einen 404 —
* ein gedruckter Code darf nie ins Leere führen.
*/
if ($ausgabeSlug === 'aktuell') {
$latest = stadionzeitung_latest();
header('Cache-Control: no-store');
header(
'Location: ' . ($latest !== null ? url('stadionzeitung/' . $latest['slug']) : url('stadionzeitung')),
true,
302
);
// exit, nicht return: nach einem `return` liefe in public/index.php noch
// layout.php und hängte einen kompletten HTML-Body an die Weiterleitung.
// Der Ausgabepuffer aus render_page() ist hier noch leer, es geht also
// nichts verloren.
exit;
}
$issue = null;
foreach (json_load('stadionzeitung')['issues'] ?? [] as $entry) {
if (($entry['slug'] ?? '') === $ausgabeSlug) {
$issue = $entry;
break;
}
}
if ($issue === null) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
http_response_code(404);
require APP_PATH . '/pages/404.php';
return;
}
// Entwurf vs. veröffentlicht (Interview-Beschluss 29.07.2026): Entwürfe sind
// „unsichtbar, aber offen" — sie fehlen im Archiv und in der Sitemap und
// tragen noindex, die direkte URL funktioniert aber (zum Gegenlesen ohne
// Login-System). Die Druckfassung (?druck=1) montiert dieselben Seiten in
// Broschüren-Reihenfolge fürs gefaltete A5-Heft.
$isDraft = !stadionzeitung_is_published($issue);
$isDruck = isset($_GET['druck']);
// Erste Seite ist seit dem automatischen Cover (Phase 3) eine Live-Seite ohne
// eigenes Bild — og_image gibt es nur, wenn die Ausgabe (noch/wieder) mit
// einem Bild beginnt, sonst greift das globale Default-OG-Bild (meta.php).
$firstImage = $issue['images'][0] ?? null;
$cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null;
$meta = [
'title' => 'Stadionzeitung gegen ' . $issue['opponent'] . ($isDruck ? ' (Druckfassung)' : ''),
'description' => 'Stadionzeitung zum Heimspiel gegen ' . $issue['opponent'] . ' am ' . german_date($issue['date']) . ' digital zum Durchblättern.',
'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
['name' => 'Gegen ' . $issue['opponent'], 'slug' => 'stadionzeitung/' . $issue['slug']],
]),
];
if ($cover !== null) {
$meta['og_image'] = $cover['base'] . '-' . max($cover['widths']) . '.jpg';
}
// Entwürfe und die Druckfassung (parametrisierte Zweitansicht derselben
// Inhalte) gehören nicht in den Index.
if ($isDraft || $isDruck) {
$meta['noindex'] = true;
}
?>
<?php
// Kopfleiste: im Viewer Titel + Datum, in der Druckfassung stattdessen die
// Blatt-Bilanz und ein Drucken-Knopf griffbereit oben (der ausführliche
// Hinweis + Button am Seitenende bleibt).
$druckSeiten = count($issue['images'] ?? []);
$druckBlaetter = (int) ceil($druckSeiten / 4);
?>
<?php /* Kopfleiste in drei Zonen und zwei Reihen: oben Rückweg links, Titel mittig,
Aktion rechts — darunter eine ruhige Statuszeile. Die Badges standen
früher neben dem Titel und machten die Mitte unruhig; Zustand („Entwurf",
„Druckfassung") und Bilanz sind Information, keine Überschrift. */ ?>
<header class="stadionzeitung-issue-head<?= $isDruck ? ' stadionzeitung-issue-head--druck' : '' ?>">
<div class="container stadionzeitung-issue-head__inner">
<a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>">
<?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?>
</a>
<p class="stadionzeitung-issue-head__title">Gegen <?= e($issue['opponent']) ?></p>
<?php if ($isDruck): ?>
<p class="stadionzeitung-issue-head__actions">
<button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden>
<?= icon('printer') ?> Drucken
</button>
</p>
<?php else: ?>
<p class="stadionzeitung-issue-head__date">
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
</p>
<?php endif; ?>
<?php if ($isDraft || $isDruck): ?>
<p class="stadionzeitung-issue-head__state">
<?php if ($isDraft): ?>
<span class="stadionzeitung-issue-head__badge stadionzeitung-issue-head__badge--entwurf">Entwurf</span>
<?php endif; ?>
<?php if ($isDruck): ?>
<span>Druckfassung · <?= $druckBlaetter ?> Blätter · <?= $druckSeiten ?> Seiten</span>
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</header>
<?php component($isDruck ? 'stadionzeitung-druck' : 'stadionzeitung-viewer', ['issue' => $issue]); ?>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Stadionzeitung-Archiv: Vorschau-Karten aller Ausgaben, verlinkt auf den
* Swipe-Viewer je Ausgabe (stadionzeitung/<slug>, dynamische Route in
* public/index.php). Daten: data/stadionzeitung.json (maschinenverwaltet von
* bin/stadionzeitung-add.php — nie von Hand editieren).
*/
// Entwürfe bleiben unsichtbar (kein Archiv-Eintrag, siehe status-Feld) —
// erreichbar sind sie nur über ihre direkte URL, zum Gegenlesen.
$issues = array_values(array_filter(
json_load('stadionzeitung')['issues'] ?? [],
'stadionzeitung_is_published'
));
$meta = [
'title' => 'Stadionzeitung',
'description' => 'Die Stadionzeitung zu jedem Heimspiel des TSV 08 Kulmbach digital zum Durchblättern alle Ausgaben im Archiv.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
]),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'Stadionzeitung',
'text' => 'Die Stadionzeitung zu jedem Heimspiel digital zum Durchblättern, auch unterwegs am Handy.',
'image' => [
'base' => 'img/pages/fussball-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
component('stadionzeitung-cards', ['issues' => $issues]);

View File

@@ -10,8 +10,8 @@ $slug = 'fussball/damen';
$team = json_load('teams')['damen'];
$meta = [
'title' => 'Frauenmannschaft Frauenfußball Kulmbach',
'description' => 'Frauen- und Mädchenfußball beim TSV 08 Kulmbach: Ob erfahren oder Wiedereinsteigerin im Katzbachtal findest du dein Team. Komm zum Probetraining.',
'title' => 'Frauenmannschaft · Frauenfußball Kulmbach',
'description' => 'Frauenfußball in Kulmbach beim TSV 08: Ob erfahren oder Wiedereinsteigerin, im Katzbachtal findest du dein Team. Komm zum Probetraining!',
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug),
];

View File

@@ -9,8 +9,8 @@ $slug = 'fussball/erste-mannschaft';
$team = json_load('teams')['erste-mannschaft'];
$meta = [
'title' => '1. Mannschaft Kreisklasse Kulmbach',
'description' => 'Die erste Herrenmannschaft des TSV 08 Kulmbach spielt auf Kreisklasse-Niveau ambitioniert und mit echtem Vereinsgeist. Komm vorbei und spiel mit.',
'title' => '1. Mannschaft · Kreisklasse Kulmbach',
'description' => 'Die 1. Mannschaft des TSV 08 Kulmbach spielt Fußball in der Kreisklasse im Katzbachtal. Ehrgeizig, aber familiär: Komm zum Probetraining!',
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug),
];

View File

@@ -9,8 +9,8 @@ $slug = 'fussball/zweite-mannschaft';
$team = json_load('teams')['zweite-mannschaft'];
$meta = [
'title' => '2. Mannschaft Herrenfußball Kulmbach',
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach spielt in Spielgemeinschaft mit dem TSV Melkendorf Fußball mit Spaß für alle, die gern kicken.',
'title' => '2. Mannschaft · Herrenfußball Kulmbach',
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach: Spielgemeinschaft mit dem TSV Melkendorf. Fußball mit Spaß für alle, die gern kicken. Komm vorbei!',
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [],
'schema' => team_schema($team, $slug),

62
app/pages/termine.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* Zentrale Termine/Events-Seite: kombiniert Fußball-Spieltermine (aus
* data/matchcenter.json — next_matches/last_results, NICHT dupliziert) mit
* manuell gepflegten Vereins-Events (data/events.json, chat-gepflegt wie
* teams.json/partners.json — keine Automatisierung, kein Bin-Skript) und den
* Veranstaltungen mit eigener Detailseite (data/veranstaltungen.json, dorthin
* verlinkt). Getrennt
* in kommend/vergangen, je eine gemeinsame chronologische Liste — kein Filter
* nach Kategorie (v1, siehe Interview). Merge-Logik in termine_build_lists()
* (app/helpers.php) — dieselbe Grundlage nutzt die Stadionzeitungs-Vorlage
* (Phase 3), damit beide nie auseinanderlaufen.
*/
$termine = termine_build_lists();
$meta = [
'title' => 'Termine',
'description' => 'Alle kommenden Termine des TSV 08 Kulmbach: Fußball-Spiele unserer Mannschaften und Vereins-Events in Kulmbach auf einen Blick. Schau vorbei im Katzbachtal!',
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Termine', 'slug' => 'termine'],
]),
sportsevent_nodes($termine['upcoming_matches']),
array_values(array_filter(array_map('event_schema', $termine['events']))),
// Veranstaltungen tragen zusätzlich ihre eigene URL, damit dieser Knoten
// und der auf der Detailseite dasselbe Event beschreiben.
array_values(array_filter(array_map(
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
$termine['veranstaltungen']
)))
),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'Termine',
'text' => 'Alle kommenden Spiele und Vereins-Events auf einen Blick.',
'image' => [
'base' => 'img/pages/fussball-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
?>
<section class="section">
<div class="container">
<h2>Kommende Termine</h2>
<?php component('termine-list', ['items' => $termine['upcoming'], 'empty_text' => 'Aktuell sind keine Termine geplant.']); ?>
</div>
</section>
<section class="section section--sunken">
<div class="container">
<h2>Vergangene Termine</h2>
<?php component('termine-list', ['items' => $termine['past'], 'empty_text' => 'Noch keine vergangenen Termine erfasst.']); ?>
</div>
</section>

View File

@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
$meta = [
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
'description' => $d['hero']['text'],
'og_image' => 'img/hero/hero-1600.jpg',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'scripts' => ['group-tabs.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
$meta = [
'title' => 'Erwachsenenturnen in Kulmbach',
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
'og_image' => 'img/hero/hero-1600.jpg',
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['step-aerobic-fitness-pilates'];
$meta = [
'title' => 'Step Aerobic, Fitness & Pilates in Kulmbach',
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
'og_image' => 'img/hero/hero-1600.jpg',
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['trampolin'];
$meta = [
'title' => 'Trampolinturnen in Kulmbach',
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
'og_image' => 'img/hero/hero-1600.jpg',
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin: Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['wettkampfturnen'];
$meta = [
'title' => 'Wettkampfturnen in Kulmbach',
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
'og_image' => 'img/hero/hero-1600.jpg',
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen: Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -3,14 +3,24 @@
declare(strict_types=1);
/**
* Turn-Übersichtsseite (reiner Hub). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten. Keine weiteren Sektionen — die Detailinhalte
* (Beiträge, FAQ, Trainingszeiten) leben auf den Disziplin-Unterseiten.
* Turn-Übersichtsseite (Hub + Abteilungskontakt). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten → Anfrageformular. Die Detailinhalte
* (Beiträge, Trainingszeiten) leben auf den Disziplin-Unterseiten.
*
* Das Formular ist die allgemeine contact-form-Komponente mit
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
* Turnabteilung (club.email_turnen) statt info@. Die alte Seite hatte dafür ein
* eigenes, zweites Formular — hier ist es dieselbe Komponente und dieselbe
* Action, damit der Spam-Schutz nicht doppelt gepflegt werden muss.
*/
$page = json_load('turnen')['overview'];
// Heros nutzen das Vereins-Default-Bild; nur die Übersichts-Kacheln zeigen die
// (vorläufigen) themenbezogenen Unsplash-Platzhalter aus public/assets/img/pages/.
// Heros aller Turn-Seiten zeigen VORÜBERGEHEND das Drohnen-Luftbild des
// Vereinsgeländes (img/pages/vereinsgelaende, Quelle: data/turnen.json) statt des
// Fußballplatz-Bildes. Ehrlicher wäre ein Bild aus der Turnhalle — liegt eines
// vor, tauschen die sechs Hero-Blöcke in data/turnen.json und das og_image der
// sechs Seiten-Dateien. Die Übersichts-Kacheln zeigen weiter die (vorläufigen)
// themenbezogenen Platzhalter aus public/assets/img/pages/.
$cards = [
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1600], 'alt' => 'Erwachsenenturnen beim TSV 08 Kulmbach']],
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1600], 'alt' => 'Eltern-Kind- und Kinderturnen beim TSV 08 Kulmbach']],
@@ -22,7 +32,7 @@ $cards = [
$meta = [
'title' => 'Turnen in Kulmbach',
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
'og_image' => 'img/hero/hero-1600.jpg',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],
@@ -36,3 +46,8 @@ component('team-cards', [
'title_hidden' => true,
'anchor' => 'gruppen',
]);
component('contact-form', [
'intro' => $page['contact'],
'return' => 'turnen',
'department' => 'turnen',
]);

157
app/pages/veranstaltung.php Normal file
View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
/**
* Einzelne Veranstaltung: dynamische Route veranstaltungen/<slug>, siehe
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
* Ermittelt den Slug selbst wieder aus der URL, Daten aus
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
* schreibt bin/veranstaltung-bilder.php.
*
* Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()):
* ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er
* gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
* trägt noindex.
*
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
* ein Archiv-Eintrag hat kein eigenes Titelbild, und das einzige vorhandene
* Bild ist ein Plakat im Hochformat, das als cover-beschnittener
* Hero-Hintergrund zerstört würde. Stattdessen ein schmaler Kopf mit Rückweg.
* Die Übersichtsseite /veranstaltungen behält den regulären Unterseiten-Hero.
*/
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
$v = null;
foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
if (($entry['slug'] ?? '') === $veranstaltungSlug) {
$v = $entry;
break;
}
}
if ($v === null) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
http_response_code(404);
require APP_PATH . '/pages/404.php';
return;
}
$isDraft = !veranstaltung_is_published($v);
// Der Lebenszyklus läuft über das Datum, nicht über ein Feld von Hand:
// vor dem Fest wirbt die Seite, danach erzählt sie. Maßgeblich ist der LETZTE
// Tag (termin_is_upcoming()), damit ein dreitägiges Fest am Samstag nicht schon
// zum Rückblick wird.
$isVorbei = !termin_is_upcoming($v, date('Y-m-d'));
$plakate = $v['plakate'] ?? [];
$galerie = $v['galerie'] ?? [];
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
$meta = [
'title' => $v['title'],
'description' => (string) ($v['text'] ?? $v['title']),
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
['name' => $v['title'], 'slug' => 'veranstaltungen/' . $v['slug']],
]),
array_values(array_filter([event_schema($v, 'veranstaltungen/' . $v['slug'])]))
),
];
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
if ($plakate !== [] || $galerie !== []) {
$meta['scripts'] = ['lightbox.js'];
}
$ogBild = $plakate[0] ?? $galerie[0] ?? null;
if ($ogBild !== null && !empty($ogBild['widths'])) {
$meta['og_image'] = $ogBild['base'] . '-' . max($ogBild['widths']) . '.jpg';
}
if ($isDraft) {
$meta['noindex'] = true;
}
// Einleitung: vor dem Fest die Ankündigung, danach der Rückblick. Fehlt der
// jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die Seite darf
// nie leer sein, sie startet ja mit nichts als dem Plakat.
// Fällt der jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die
// Seite darf nie leer sein, sie startet ja mit nichts als dem Plakat.
$introText = $isVorbei ? ($v['rueckblick'] ?? null) : ($v['ankuendigung'] ?? null);
$introSection = !empty($introText) ? [
'id' => 'ueberblick',
'title' => $isVorbei ? 'Unser Rückblick' : 'Das erwartet dich',
// Ohne Bild läuft die Sektion einspaltig auf Lesebreite (split--plain).
'text' => $introText,
] : null;
?>
<?php component('detail-head', [
'back' => ['href' => url('veranstaltungen'), 'label' => 'Alle Veranstaltungen'],
'title' => $v['title'],
'badge' => $isDraft ? 'Entwurf' : '',
'date_iso' => (string) $v['date_start'],
'date_text' => $dateText,
'location' => (string) ($v['location'] ?? ''),
]); ?>
<?php /* Einleitungstext nur, wenn wirklich einer gepflegt ist — die Regel für die
ganze Seite: gezeigt wird, was in der JSON steht, und sonst nichts. */ ?>
<?php if ($introSection !== null): ?>
<?php component('section', ['section' => $introSection]); ?>
<?php endif; ?>
<?php /* Plakate und Galerie tauschen den Platz: vor dem Fest wirbt das Plakat,
danach tragen die Fotos die Seite und das Plakat bleibt als Andenken. */ ?>
<?php if ($isVorbei): ?>
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => 'Plakate von damals', 'compact' => true]); ?>
<?php else: ?>
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => count($plakate) > 1 ? 'Plakat und Programm' : 'Plakat']); ?>
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
<?php endif; ?>
<?php /* Zeitraum, Beginn, Adresse und Karte in einem Block — aber nur, solange das
Fest noch aussteht. Hinterher wäre es reine Doppelung: Datum und Ort
stehen im Kopf, und eine Anfahrtskarte zu einem Fest von letztem Jahr
hilft niemandem mehr. Alles Weitere steht auf den Plakaten. */ ?>
<?php if (!$isVorbei): ?>
<?php component('veranstaltung-info', [
'date_text' => $dateText,
'date_iso' => (string) $v['date_start'],
'time' => (string) ($v['time'] ?? ''),
'location' => (string) ($v['location'] ?? ''),
'karte' => (string) ($v['karte'] ?? ''),
'anfahrt' => $v['anfahrt'] ?? [],
]); ?>
<?php endif; ?>
<?php
// phase steuert, welche Abschnitte wann stehen: „Anfahrt und Parken" hilft nur
// vor dem Fest. Ohne Angabe steht ein Abschnitt immer — der harmlosere Default:
// geschriebener Text verschwindet nie überraschend.
foreach ($v['abschnitte'] ?? [] as $abschnitt):
$phase = $abschnitt['phase'] ?? 'immer';
if ($phase !== 'immer' && $phase !== ($isVorbei ? 'rueckblick' : 'ankuendigung')) {
continue;
}
?>
<?php component('section', ['section' => $abschnitt]); ?>
<?php endforeach; ?>
<?php /* Das Programm als Liste ist die Ausnahme, nicht die Regel: normalerweise
trägt es das Programmplakat. Nur wenn `programm` gepflegt ist, kommt es
zusätzlich als lesbare Liste dazu (Screenreader, Suchmaschinen). */ ?>
<?php component('veranstaltung-programm', [
'programm' => $v['programm'] ?? [],
'title' => $isVorbei ? 'So lief unser Programm' : 'Programm',
'compact' => $isVorbei,
]); ?>
<?php /* Helfer-Aufruf nur vor dem Fest: hinterher sind die Schichten gelaufen. */ ?>
<?php if (!$isVorbei && !empty($v['helfer'])): ?>
<?php component('cta-band', ['band' => $v['helfer'], 'anchor' => 'helfen']); ?>
<?php endif; ?>
<?php if (!empty($v['partner'])): ?>
<?php component('partner-grid', ['intro' => $v['partner'], 'anchor' => 'fest-partner']); ?>
<?php endif; ?>

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* Übersicht aller Veranstaltungen mit eigener Detailseite (Sportfest,
* Jubiläum, …) — Quelle data/veranstaltungen.json, geladen über
* veranstaltungen_load() (filtert Entwürfe, sortiert neueste zuerst).
*
* Zwei Abschnitte statt einer gemischten Liste: oben was ansteht (der nächste
* Termin als große Karte mit Plakat), unten das Archiv. Auch in der Sommerpause
* ohne anstehendes Fest ist die Seite damit gefüllt.
*
* Abgrenzung zu /termine: dort steht der vollständige Kalender (Spiele +
* Vereins-Termine + Veranstaltungen), hier nur das, was eine eigene Seite hat.
*/
$today = date('Y-m-d');
$alle = veranstaltungen_load();
$kommend = array_values(array_filter($alle, static fn (array $v): bool => termin_is_upcoming($v, $today)));
// veranstaltungen_load() sortiert absteigend (fürs Archiv richtig) — was
// ansteht, gehört aufsteigend sortiert: das Nächste zuerst.
usort($kommend, static fn (array $a, array $b): int => (string) $a['date_start'] <=> (string) $b['date_start']);
$vergangen = array_values(array_filter($alle, static fn (array $v): bool => !termin_is_upcoming($v, $today)));
$meta = [
'title' => 'Veranstaltungen',
'description' => 'Unsere Feste und Veranstaltungen: Sportfest, Frühschoppen und alles, was wir im Katzbachtal auf die Beine stellen.',
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
]),
// Nur die anstehenden als Event-Knoten: ein vergangenes Fest ist für
// Suchmaschinen kein Termin mehr, den man sich vormerken kann. Mit Slug,
// damit es derselbe Knoten ist wie auf /termine und der Detailseite.
array_values(array_filter(array_map(
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
$kommend
)))
),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'Veranstaltungen',
'text' => 'Sportfest, Frühschoppen, Feiern: hier findest du alles zu unseren Festen, vorher das Programm und hinterher die Bilder.',
'image' => [
'base' => 'img/pages/vereinsgelaende',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
?>
<section class="section">
<div class="container">
<h2>Was ansteht</h2>
<?php component('veranstaltungen-cards', [
'items' => $kommend,
'cta' => 'Zum Programm',
'empty_text' => 'Gerade ist kein Fest geplant. Sobald der nächste Termin steht, findest du ihn hier.',
]); ?>
</div>
</section>
<section class="section section--sunken">
<div class="container">
<h2>Rückblick</h2>
<?php component('veranstaltungen-cards', [
'items' => $vergangen,
'cta' => 'Bilder ansehen',
'empty_text' => 'Noch kein Fest im Rückblick. Nach unserem nächsten Sportfest findest du hier die Bilder.',
]); ?>
</div>
</section>

View File

@@ -26,7 +26,7 @@ $cards = [
$meta = [
'title' => 'Der Verein',
'description' => 'Der TSV 1908 Kulmbach e. V. seit 1908 dein Sportverein im Katzbachtal: über 650 Mitglieder, Fußball und Turnen, zuhause auf der Hans-Rausch-Sportanlage.',
'description' => 'Der TSV 1908 Kulmbach e. V.: seit 1908 dein Sportverein in Kulmbach mit über 650 Mitgliedern. Fußball und Turnen auf der Hans-Rausch-Sportanlage im Katzbachtal.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema(
[

View File

@@ -11,11 +11,13 @@ declare(strict_types=1);
*/
return [
'' => ['file' => 'home.php', 'sitemap' => true],
'news' => ['file' => 'news.php', 'sitemap' => true],
'fussball' => ['file' => 'fussball.php', 'sitemap' => true],
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true],
'stadionzeitung' => ['file' => 'stadionzeitung.php', 'sitemap' => true],
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
@@ -28,6 +30,8 @@ return [
'historie' => ['file' => 'historie.php', 'sitemap' => true],
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
'termine' => ['file' => 'termine.php', 'sitemap' => true],
'veranstaltungen' => ['file' => 'veranstaltungen.php', 'sitemap' => true],
'kontakt' => ['file' => 'kontakt.php', 'sitemap' => true],
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],

181
bin/anzeige-add.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* Sponsoren-Anzeige in den Bestand aufnehmen oder ihr Motiv erneuern (nur CLI).
*
* php bin/anzeige-add.php <bild-oder-ordner> [-v]
*
* Beispiele:
* php bin/anzeige-add.php ~/Desktop/anzeigen/ alle Bilder im Ordner
* php bin/anzeige-add.php ~/Desktop/eldorado.jpg nur diese eine
*
* DER DATEINAME IST DER SLUG: `eldorado.jpg` wird zu `img/anzeigen/eldorado-*.jpg`
* und findet damit den bestehenden Eintrag wieder. Ein neues Motiv für einen
* bekannten Sponsor heißt also einfach wieder `eldorado.jpg`.
*
* Anzeigen sind ein DAUERHAFTER Bestand (data/anzeigen.json), kein Zubehör einer
* einzelnen Ausgabe: Sponsorenmotive ändern sich selten, die Stadionzeitung
* erscheint oft. bin/stadionzeitung-add.php holt sie sich von hier, pro Heft ist
* nichts abzuliefern. Vorher lag jede Anzeige als Kopie unter jeder Ausgabe und
* ihr Alt-Text musste jedes Mal neu geschrieben werden.
*
* Das Skript schreibt ausschließlich `base` und `widths`. `sponsor`, `alt`,
* `aktiv` und die REIHENFOLGE der Liste (= Reihenfolge im Heft) sind
* chat-gepflegt und überleben jeden Lauf unangetastet — dasselbe Prinzip wie bei
* bin/veranstaltung-bilder.php. Bei einem neuen Sponsor wird `sponsor` aus dem
* Dateinamen vorbelegt und muss im Chat richtiggestellt werden; das Skript sagt,
* bei welchen.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('stadionzeitung', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
exit(1);
};
if ($args === []) {
fwrite(STDERR, "Nutzung: php bin/anzeige-add.php <bild-oder-ordner> [-v]\n");
exit(1);
}
// --- Quelldateien einsammeln ---
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$quelle = $args[0];
if (is_dir($quelle)) {
$sources = glob(rtrim($quelle, '/') . "/*.{{$exts}}", GLOB_BRACE) ?: [];
natsort($sources);
$sources = array_values($sources);
} elseif (is_file($quelle)) {
$sources = [$quelle];
} else {
$fail("Nicht gefunden: {$quelle}");
}
if ($sources === []) {
$fail("Keine Bilder in {$quelle} gefunden.");
}
// Selbst-Import verhindern: zeigt jemand auf den Zielordner, wären die Quellen
// die erzeugten Varianten (`eldorado-1440.jpg`), und aus jeder Breite entstünde
// ein eigener Sponsor („eldorado 1440"). Einmal reingelaufen, deshalb der Riegel.
$zielReal = realpath(PUBLIC_PATH . '/assets/img/anzeigen');
foreach ($sources as $src) {
if ($zielReal !== false && realpath(dirname($src)) === $zielReal) {
$fail('Quelle liegt im Zielordner public/assets/img/anzeigen/ — das sind die erzeugten Varianten, nicht die Originale.');
}
}
// --- Lock gegen parallele Läufe ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/anzeigen.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
$dataFile = DATA_PATH . '/anzeigen.json';
$data = is_file($dataFile)
? (json_decode((string) file_get_contents($dataFile), true) ?: ['anzeigen' => []])
: ['anzeigen' => []];
$destDir = PUBLIC_PATH . '/assets/img/anzeigen';
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
$widths = [480, 960, 1440];
/** Dateiname zu Slug: Kleinschreibung, Umlaute weg, alles andere zu Bindestrichen. */
$slugify = static function (string $text): string {
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
$text = strtolower($text);
return trim(preg_replace('/[^a-z0-9]+/', '-', $text) ?? '', '-');
};
$neu = [];
$erneuert = [];
foreach ($sources as $src) {
$slug = $slugify(pathinfo($src, PATHINFO_FILENAME));
if ($slug === '') {
$log('Dateiname ergibt keinen Slug, übersprungen: ' . basename($src), 'WARN');
continue;
}
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
// nicht mehr passen.
foreach (glob("{$destDir}/{$slug}-*.jpg") ?: [] as $alt) {
unlink($alt);
}
try {
// Qualität 82 wie bei den Plakaten: Anzeigen tragen Text, und Ringing um
// Buchstaben sieht man.
$variants = resize_image_variants($src, "{$destDir}/{$slug}", $widths, 82);
} catch (RuntimeException $e) {
$fail('Anzeige nicht verarbeitbar (' . basename($src) . '): ' . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
// Bestehenden Eintrag suchen und NUR base/widths ersetzen.
$index = null;
foreach ($data['anzeigen'] as $i => $eintrag) {
if (($eintrag['slug'] ?? '') === $slug) {
$index = $i;
break;
}
}
if ($index === null) {
// Neuer Sponsor: Platzhalter-Name aus dem Slug, im Chat zu ersetzen.
$data['anzeigen'][] = [
'slug' => $slug,
'sponsor' => str_replace('-', ' ', $slug),
'aktiv' => true,
'base' => "img/anzeigen/{$slug}",
'widths' => $generated,
];
$neu[] = $slug;
} else {
$data['anzeigen'][$index]['base'] = "img/anzeigen/{$slug}";
$data['anzeigen'][$index]['widths'] = $generated;
$erneuert[] = $slug;
}
$log("Anzeige {$slug}: " . implode('/', $generated) . 'px');
}
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $dataFile . '.tmp';
file_put_contents($tmp, $payload . "\n");
if (!rename($tmp, $dataFile)) {
$fail('Konnte data/anzeigen.json nicht schreiben.');
}
$log(sprintf('Anzeigen-Bestand aktualisiert: %d neu, %d erneuert.', count($neu), count($erneuert)));
echo 'Anzeigen-Bestand: ' . count($data['anzeigen']) . ' Motive, ' . count($neu) . ' neu, ' . count($erneuert) . " erneuert.\n";
if ($neu !== []) {
echo "Sponsorenname noch Platzhalter bei: " . implode(', ', $neu) . "\n";
echo "In data/anzeigen.json richtigstellen (daraus entsteht der Alt-Text „Anzeige: …\").\n";
}

491
bin/chronik-add.php Normal file
View File

@@ -0,0 +1,491 @@
<?php
declare(strict_types=1);
/**
* Fundstücke für die Vereinschronik 2028 einsammeln und ablegen.
*
* Holt zu einer Web-Adresse Überschrift, Datum, Teaser, Bildnachweis und das Aufmacherbild,
* legt beides im richtigen Jahrzehnt-Ordner unter Vereinshistorie/ ab, schreibt einen
* Quellen-Beleg daneben und eine Zeile ins Register (docs/chronik/register.csv).
*
* Warum überhaupt ein Skript und kein „Rechtsklick, Bild speichern": das Bild allein ist
* in fünf Jahren wertlos. Woher es stammt, wann es entstand und wem es gehört, steht nirgends
* im JPEG — und genau das braucht man, wenn die Chronik in den Druck geht. Das Skript hält
* den Zusammenhang fest, in dem Moment, in dem er noch bekannt ist.
*
* Es wird NICHTS massenhaft abgegriffen: pro Aufruf eine Seite, mit Pause zwischen den
* Abrufen. Die Presseportale haben keine Übersichtsseite mehr, über die man den Verein
* vollständig abfragen könnte — die Fundstellen kommen aus gezielter Suche, nicht aus
* einem Crawler.
*
* NUR CLI, nie im Request-Pfad (wie alle bin/-Skripte).
*
* Aufruf:
* php bin/chronik-add.php <URL> [--datum=1972-07-15] [--titel="…"] [--art=Zeitungsartikel]
* [--notiz="…"] [--urheber="…"] [--ohne-bild] [--nur-register]
* php bin/chronik-add.php --liste=urls.txt Zeilen: URL | Datum | Titel (| optional)
* php bin/chronik-add.php --datei=<pfad> --datum=1983-06-11 --titel="Festzug 75 Jahre"
* [--quelle="Album Familie Rausch"] [--urheber="…"] [--art=Foto]
* [--rechte-klaeren]
* php bin/chronik-add.php --rechte nur 00_RECHTE-ZU-KLAEREN.txt neu schreiben
* php bin/chronik-add.php --bericht Registerübersicht auf stdout
*
* Optionen --datum/--titel gewinnen immer gegen das, was die Seite selbst angibt.
* --datei benennt die Datei um und verschiebt sie in den passenden Jahrzehnt-Ordner.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
require __DIR__ . '/chronik-lib.php';
$opts = [];
$positional = [];
foreach (array_slice($argv, 1) as $arg) {
if (preg_match('/^--([a-z-]+)(?:=(.*))?$/s', $arg, $m)) {
$opts[$m[1]] = $m[2] ?? true;
} else {
$positional[] = $arg;
}
}
$root = chronik_root();
if (!is_dir($root)) {
fwrite(STDERR, "Vereinshistorie/ fehlt — erst 'php bin/chronik-init.php' laufen lassen.\n");
exit(1);
}
// --- Nebenmodi -------------------------------------------------------------
if (isset($opts['rechte'])) {
$n = chronik_write_rights_todo();
echo "00_RECHTE-ZU-KLAEREN.txt geschrieben — {$n} offene Posten.\n";
exit(0);
}
if (isset($opts['bericht'])) {
chronik_report();
exit(0);
}
// --- HTTP ------------------------------------------------------------------
/**
* Browser-typische Header. Ohne sie antworten mehrere der Quellen mit 403 —
* dieselbe Erfahrung wie beim BFV-Widget (dort HTTP 418), siehe matchcenter-sync.php.
*/
function chronik_fetch(string $url, ?string $referer = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 25,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
CURLOPT_HTTPHEADER => array_filter([
'Accept: text/html,application/xhtml+xml,image/avif,image/webp,*/*;q=0.8',
'Accept-Language: de-DE,de;q=0.9',
$referer !== null ? 'Referer: ' . $referer : null,
]),
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$final = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
return [
'ok' => $body !== false && $status === 200,
'status' => $status,
'body' => $body === false ? '' : $body,
'type' => $type,
'url' => $final !== '' ? $final : $url,
];
}
/** Ersten Treffer eines Musters liefern, HTML-Entities aufgelöst. */
function chronik_match(string $html, string $pattern): ?string
{
if (!preg_match($pattern, $html, $m)) {
return null;
}
$val = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $val !== '' ? $val : null;
}
/**
* Fließtext einer Seite herausschälen.
*
* Grob und absichtlich: Skripte und Styles raus, Tags raus, alles behalten, was länger als
* ein Navigationspunkt ist. Für saubere Extraktion bräuchte es pro Portal eine eigene Regel —
* das lohnt hier nicht, weil der Text als Beleg dient und nicht als Layout.
*/
function chronik_body_text(string $html): string
{
$html = (string) preg_replace('#<(script|style|nav|header|footer|form)\b.*?</\1>#is', ' ', $html);
$html = (string) preg_replace('#<(br|/p|/div|/h[1-6]|/li|/td|/tr)[^>]*>#i', "\n", $html);
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$keep = [];
$seen = [];
foreach (explode("\n", $text) as $line) {
$line = trim((string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $line));
// 45 Zeichen trennt Fließtext zuverlässig von Menüpunkten und Buttons.
if (mb_strlen($line) < 45 || isset($seen[$line])) {
continue;
}
$seen[$line] = true;
$keep[] = $line;
}
return implode("\n\n", $keep);
}
/** Seite auswerten: was an Metadaten drinsteht, ohne Layout-Annahmen. */
function chronik_parse(string $html): array
{
$strip = static fn(string $s): string => trim((string) preg_replace('/\s+/', ' ', strip_tags($s)));
$titel = chronik_match($html, '/<meta property="og:title" content="([^"]+)"/i')
?? chronik_match($html, '/<h1[^>]*>(.*?)<\/h1>/is')
?? chronik_match($html, '/<title[^>]*>(.*?)<\/title>/is');
if ($titel !== null) {
$titel = $strip($titel);
// Seitentitel tragen oft " | Portalname" mit — für den Dateinamen stört das nur.
$titel = (string) preg_replace('/\s*[|-]\s*(inFranken\.de|BFV|FUSSBALL\.DE|Deutsche Turnliga)\s*$/iu', '', $titel);
}
$datum = chronik_match($html, '/<meta property="article:published_time" content="(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/"datePublished"\s*:\s*"(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/datetime="(\d{4}-\d{2}-\d{2})/i');
$teaser = chronik_match($html, '/<meta property="og:description" content="([^"]+)"/i')
?? chronik_match($html, '/<meta name="description" content="([^"]+)"/i');
$bild = chronik_match($html, '/<meta property="og:image" content="([^"]+)"/i');
// Bildnachweis steht bei Presseseiten fast immer in der Bildunterschrift
// („Foto: Max Mustermann") — genau die Angabe, die später fehlt.
$urheber = null;
if (preg_match_all('/<figcaption[^>]*>(.*?)<\/figcaption>/is', $html, $caps)) {
foreach ($caps[1] as $cap) {
$cap = $strip(html_entity_decode($cap, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (preg_match('/\b(Foto|Bild|Quelle|©)\s*:?\s*(.{2,80})$/ui', $cap, $m)) {
$urheber = trim($m[2], " .;");
break;
}
}
}
$urheber ??= chronik_match($html, '/<meta name="author" content="([^"]+)"/i');
return compact('titel', 'datum', 'teaser', 'bild', 'urheber');
}
// --- Ein Fundstück verarbeiten ---------------------------------------------
function chronik_add(string $url, array $opts, array $override = []): bool
{
$root = chronik_root();
$heute = date('Y-m-d');
if (chronik_register_has_url($url) && !isset($opts['erneut'])) {
echo "· schon im Register: {$url}\n";
return true;
}
// Bei Wayback-Adressen zählt für Rechte und Quellenkürzel die ARCHIVIERTE Seite,
// nicht das Archiv — abgerufen wird trotzdem der Schnappschuss.
$quellUrl = chronik_origin_url($url);
$host = (string) (parse_url($quellUrl, PHP_URL_HOST) ?: '');
if ($host === '') {
fwrite(STDERR, "✗ Keine gültige Adresse: {$url}\n");
return false;
}
$rights = chronik_rights($host);
$meta = ['titel' => null, 'datum' => null, 'teaser' => null, 'bild' => null, 'urheber' => null];
$volltext = '';
if (!isset($opts['nur-register'])) {
$res = chronik_fetch($url);
if (!$res['ok']) {
fwrite(STDERR, "✗ HTTP {$res['status']}: {$url}\n");
log_write('chronik', 'WARN', "Abruf fehlgeschlagen (HTTP {$res['status']}): {$url}");
return false;
}
$meta = chronik_parse($res['body']);
// Die Rechtelage entscheidet, wie viel Text bleibt: eigenes Vereinsmaterial sichern
// wir vollständig (sonst ist es weg — die alten Vereinsseiten sind offline), fremde
// Presseartikel nur als Nachweis mit Überschrift und Anriss.
if (!$rights['klaeren']) {
$volltext = chronik_body_text($res['body']);
}
}
$titel = (string) ($override['titel'] ?? $opts['titel'] ?? $meta['titel'] ?? 'Ohne Titel');
$datum = $override['datum'] ?? $opts['datum'] ?? $meta['datum'] ?? null;
$urheber = (string) ($opts['urheber'] ?? $meta['urheber'] ?? '');
$art = (string) ($opts['art'] ?? 'Zeitungsartikel');
$notiz = (string) ($opts['notiz'] ?? '');
if ($quellUrl !== $url) {
$notiz = trim($notiz . ' Archivfassung (Internet Archive), Original nicht mehr online: ' . $quellUrl);
}
$tag = chronik_source_tag($host);
$bucket = chronik_bucket(is_string($datum) ? $datum : null);
$dnorm = chronik_date_norm(is_string($datum) ? $datum : null);
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
$dir = $root . '/' . $bucket;
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Namenskollision auflösen. Passiert wirklich: zwei Seiten „Bundesliga" und
// „Bundesliga-1" mit gleicher Überschrift und gleichem Jahr ergeben denselben Namen —
// ohne Suffix überschreibt der zweite Fund den ersten stillschweigend.
// Anker ist die .quelle.txt, die zu jedem Fundstück entsteht.
if (is_file($dir . '/' . $base . '.quelle.txt')) {
$n = 2;
while (is_file($dir . '/' . $base . '-' . $n . '.quelle.txt')) {
$n++;
}
$base .= '-' . $n;
}
// --- Aufmacherbild ---
$bildDatei = '';
if (!isset($opts['ohne-bild']) && !isset($opts['nur-register']) && is_string($meta['bild'])) {
$img = chronik_fetch($meta['bild'], $url);
$ext = match (true) {
str_contains($img['type'], 'png') => 'png',
str_contains($img['type'], 'webp') => 'webp',
str_contains($img['type'], 'gif') => 'gif',
default => 'jpg',
};
// 6 KB Untergrenze: Portale liefern bei fehlendem Aufmacher ein Platzhalter-Pixel aus.
if ($img['ok'] && strlen($img['body']) > 6144) {
$bildDatei = $base . '.' . $ext;
file_put_contents($dir . '/' . $bildDatei, $img['body']);
}
}
// --- Textbeleg: das Fundstück bleibt auffindbar, auch wenn die Seite verschwindet ---
$belegDatei = $base . '.artikel.txt';
$beleg = implode("\n", array_filter([
$titel,
str_repeat('=', min(72, max(8, strlen($titel)))),
'',
$dnorm !== '0000-00-00' ? 'Erschienen: ' . $dnorm : null,
'Quelle: ' . $host,
'URL: ' . $url,
'Abgerufen: ' . $heute,
'',
// Bei Volltext ist der Teaser nur dessen erster Absatz — nicht doppelt hinschreiben.
($meta['teaser'] !== null && $volltext === '') ? wordwrap((string) $meta['teaser'], 88, "\n") : null,
$volltext === '' ? '' : null,
'--',
$volltext !== ''
? "Eigenes Vereinsmaterial — vollständig gesichert, weil die Quellseite offline ist."
: 'Nur Überschrift, Datum und Anrisstext festgehalten — der vollständige Artikeltext',
$volltext !== ''
? ''
: 'gehört dem Verlag und wird hier nicht gespeichert. Für die Chronik über die URL',
$volltext !== '' ? '' : 'oder das Zeitungsarchiv im Original nachlesen.',
$volltext !== '' ? "\n" . wordwrap($volltext, 88, "\n") : null,
], static fn($l): bool => $l !== null));
file_put_contents($dir . '/' . $belegDatei, $beleg . "\n");
// --- Register + Beleg-Sidecar ---
$row = [
'datei' => $bucket . '/' . ($bildDatei !== '' ? $bildDatei : $belegDatei),
'datum' => $dnorm,
'titel' => $titel,
'art' => $art,
'quelle' => $host,
'url' => $url,
'urheber' => $urheber,
'rechte' => $rights['status'],
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
'abgerufen' => $heute,
'notiz' => $notiz,
];
chronik_register_append($row);
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
$flag = $rights['klaeren'] ? ' [Rechte klären]' : '';
echo "{$bucket}/{$base}" . ($bildDatei !== '' ? ' (+Bild)' : ' (nur Text)') . $flag . "\n";
log_write('chronik', 'INFO', "Fundstück aufgenommen: {$bucket}/{$base} ({$host})");
return true;
}
/**
* Eine Datei aufnehmen, die schon da ist — Scan aus einer Festschrift, Foto aus einem
* privaten Album, per Hand gespeichertes Pressebild.
*
* Genau derselbe Weg wie bei einer Web-Adresse: umbenennen nach Konvention, ins richtige
* Jahrzehnt einsortieren, Beleg daneben, Zeile ins Register. Ohne das landen die Scans aus
* dem Sammelaufruf als IMG_4711.jpg im Ordner und sind in einem Jahr nicht mehr zuzuordnen.
*/
function chronik_add_file(string $path, array $opts): bool
{
if (!is_file($path)) {
fwrite(STDERR, "✗ Datei nicht gefunden: {$path}\n");
return false;
}
$root = chronik_root();
$heute = date('Y-m-d');
$datum = is_string($opts['datum'] ?? null) ? $opts['datum'] : null;
$titel = (string) ($opts['titel'] ?? pathinfo($path, PATHINFO_FILENAME));
$quelle = (string) ($opts['quelle'] ?? 'Vereinsbestand');
$urheber = (string) ($opts['urheber'] ?? '');
$art = (string) ($opts['art'] ?? 'Foto');
$notiz = (string) ($opts['notiz'] ?? '');
// Ohne --quelle gilt: es kommt aus dem Verein und ist nutzbar. Mit --quelle=<Host>
// greift dieselbe Einschätzung wie bei einem Abruf aus dem Netz.
$rights = str_contains($quelle, '.')
? chronik_rights($quelle)
: ['status' => 'Vereinsbestand — Herkunft ' . $quelle, 'klaeren' => false,
'hinweis' => 'Aus dem Bestand des Vereins bzw. von Mitgliedern. Bei erkennbaren Personen '
. 'die Einwilligung zur Veröffentlichung einholen, bei Kindern die Eltern fragen.'];
if (isset($opts['rechte-klaeren'])) {
$rights['klaeren'] = true;
}
$tag = str_contains($quelle, '.') ? chronik_source_tag($quelle) : strtoupper(chronik_slug($quelle, 12));
$bucket = chronik_bucket($datum);
$dnorm = chronik_date_norm($datum);
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)) ?: 'bin';
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
$dir = $root . '/' . $bucket;
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "✗ Zielordner nicht anlegbar: {$bucket}\n");
return false;
}
$ziel = $dir . '/' . $base . '.' . $ext;
if (realpath($path) !== realpath($ziel) && !rename($path, $ziel)) {
fwrite(STDERR, "✗ Verschieben fehlgeschlagen: {$path}\n");
return false;
}
$row = [
'datei' => $bucket . '/' . $base . '.' . $ext,
'datum' => $dnorm,
'titel' => $titel,
'art' => $art,
'quelle' => $quelle,
'url' => (string) ($opts['url'] ?? ''),
'urheber' => $urheber,
'rechte' => $rights['status'],
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
'abgerufen' => $heute,
'notiz' => $notiz,
];
chronik_register_append($row);
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
echo "{$bucket}/{$base}.{$ext}" . ($rights['klaeren'] ? ' [Rechte klären]' : '') . "\n";
return true;
}
/** Registerübersicht — was haben wir, wo klafft die Lücke. */
function chronik_report(): void
{
$path = chronik_register_path();
if (!is_file($path)) {
echo "Register ist noch leer.\n";
return;
}
$fh = fopen($path, 'r');
fgetcsv($fh, 0, ';', '"', '\\');
$perBucket = array_fill_keys(array_merge(array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]), 0);
$perQuelle = [];
$offen = 0;
$gesamt = 0;
while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
$gesamt++;
$bucket = explode('/', (string) ($r[0] ?? ''))[0];
if (isset($perBucket[$bucket])) {
$perBucket[$bucket]++;
}
$perQuelle[$r[4] ?? '?'] = ($perQuelle[$r[4] ?? '?'] ?? 0) + 1;
if (($r[8] ?? '') === 'ja') {
$offen++;
}
}
fclose($fh);
echo "Chronik-Register — {$gesamt} Fundstücke, {$offen} mit offenen Rechten\n\n";
echo "Nach Zeitraum:\n";
foreach ($perBucket as $b => $n) {
printf(" %-28s %3d %s\n", $b, $n, $n === 0 ? '← Lücke' : str_repeat('▌', min(40, $n)));
}
echo "\nNach Quelle:\n";
arsort($perQuelle);
foreach ($perQuelle as $q => $n) {
printf(" %-28s %3d\n", $q, $n);
}
}
// --- Hauptlauf -------------------------------------------------------------
if (isset($opts['datei'])) {
$ok = chronik_add_file((string) $opts['datei'], $opts);
$offen = chronik_write_rights_todo();
echo "\n{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
exit($ok ? 0 : 1);
}
$jobs = [];
if (isset($opts['liste'])) {
$listFile = (string) $opts['liste'];
if (!is_file($listFile)) {
fwrite(STDERR, "Liste nicht gefunden: {$listFile}\n");
exit(1);
}
foreach (file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$parts = array_map('trim', explode('|', $line));
$jobs[] = [
'url' => $parts[0],
'datum' => ($parts[1] ?? '') !== '' ? $parts[1] : null,
'titel' => ($parts[2] ?? '') !== '' ? $parts[2] : null,
];
}
} elseif ($positional !== []) {
$jobs[] = ['url' => $positional[0], 'datum' => null, 'titel' => null];
} else {
fwrite(STDERR, "Aufruf: php bin/chronik-add.php <URL> | --liste=urls.txt | --rechte | --bericht\n");
exit(1);
}
$ok = 0;
$fehler = 0;
foreach ($jobs as $i => $job) {
if ($i > 0) {
// Pause zwischen den Abrufen: das hier ist Recherche, kein Crawl.
usleep(1_500_000);
}
$override = array_filter(['datum' => $job['datum'], 'titel' => $job['titel']], static fn($v): bool => $v !== null);
chronik_add($job['url'], $opts, $override) ? $ok++ : $fehler++;
}
$offen = chronik_write_rights_todo();
echo "\n{$ok} aufgenommen" . ($fehler > 0 ? ", {$fehler} fehlgeschlagen" : '') . ".\n";
echo "{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
exit($fehler > 0 ? 1 : 0);

94
bin/chronik-init.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* Ordnerstruktur für die Jubiläums-Chronik 2028 anlegen (unter Vereinshistorie/).
*
* Vereinshistorie/
* 00_LIESMICH.txt ← Benennung + Ablageregeln, direkt im Ordner lesbar
* 00_RECHTE-ZU-KLAEREN.txt ← von chronik-add.php gepflegt, siehe unten
* 1908-1929/ … 2010-2028/ ← Jahrzehnt-Bündel, chronologisch
* Festschriften-und-Dokumente/ ← Jubiläumsschriften, Satzungen, Urkunden, Protokolle
* Undatiert/ ← Fundstücke ohne Jahr; wandern nach der Zuordnung um
*
* Warum Zeit als oberste Ebene und nicht Abteilung: eine Chronik wird chronologisch
* gelesen und geschrieben. Fußball/Turnen steht im Dateinamen, danach kann man filtern —
* ein Jahrzehnt dagegen ist die Einheit, in der Kapitel entstehen.
*
* Warum Jahrzehnte und nicht Einzeljahre: für 1908-1970 gibt es pro Jahr oft null bis drei
* Fundstücke. 60 fast leere Ordner verstecken den Bestand, statt ihn zu zeigen. Die frühen
* Bündel sind bewusst 20 Jahre breit, die jüngeren enger — dort wird das Material dichter.
*
* Die Ablage ist bewusst dieselbe wie in dateien-init.php: flach, ohne Vorgaben in der
* Tiefe. Wer ein Ereignis bündeln will, legt darin „1972-07-15 Einweihung Sportheim" an.
*
* Idempotent — vorhandene Ordner und Dateien bleiben unberührt.
*
* NUR CLI, und die Ordner müssen auf dem SERVER entstehen, nicht per FTP hochgeladen
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC).
*
* Aufruf: php bin/chronik-init.php
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
require __DIR__ . '/chronik-lib.php';
$root = chronik_root();
$parent = dirname($root);
if (!is_dir($parent)) {
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
exit(1);
}
if (!is_writable($parent)) {
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
exit(1);
}
$created = 0;
$failed = 0;
$dirs = array_merge([''], array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]);
foreach (array_unique($dirs) as $rel) {
$dir = $rel === '' ? $root : $root . '/' . $rel;
$label = 'Vereinshistorie' . ($rel === '' ? '' : '/' . $rel);
if (is_dir($dir)) {
echo "· {$label} (gibt es schon)\n";
continue;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "{$label} konnte nicht angelegt werden\n");
$failed++;
continue;
}
echo "{$label}\n";
$created++;
}
// LIESMICH: die Ablageregeln gehören in den Ordner, nicht nur ins Repo — gelesen wird
// dort, wo hochgeladen wird. Wird nicht überschrieben, falls jemand sie ergänzt hat.
$readme = $root . '/00_LIESMICH.txt';
if (!is_file($readme)) {
file_put_contents($readme, chronik_readme_text());
echo "✓ Vereinshistorie/00_LIESMICH.txt\n";
$created++;
} else {
echo "· Vereinshistorie/00_LIESMICH.txt (gibt es schon)\n";
}
// Entwurf der Verlagsanfrage: gehört neben die Fundstücke, auf die er sich bezieht.
$anfrage = $root . '/00_RECHTE-Anfrage-Verlag-Entwurf.txt';
if (!is_file($anfrage)) {
file_put_contents($anfrage, chronik_verlagsanfrage_text());
echo "✓ Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt\n";
$created++;
} else {
echo "· Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt (gibt es schon)\n";
}
echo "\n{$created} Einträge angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
exit($failed > 0 ? 1 : 0);

564
bin/chronik-lib.php Normal file
View File

@@ -0,0 +1,564 @@
<?php
declare(strict_types=1);
/**
* Gemeinsame Logik der Chronik-Skripte (chronik-init.php, chronik-add.php).
*
* Bewusst hier unter bin/ und NICHT in app/helpers.php: das ist Recherche-Werkzeug für
* die Jubiläums-Chronik 2028, kein Website-Code. Nichts davon darf je im Request-Pfad
* landen — die Seite kennt die Chronik nicht.
*
* Zwei Dinge trägt diese Datei, und beide sind der eigentliche Punkt der Übung:
*
* 1. Der DATEINAME ist der Metadatenträger. Die Files Gallery unter /dateien kennt keine
* Felder für Datum, Quelle oder Rechte — sie listet Dateien. Also muss alles, was ein
* Fundstück später brauchbar macht, in den Namen: `1972-07-15_Einweihung-Sportheim_BR.jpg`.
* Datum vorn, damit die alphabetische Sortierung der Gallery zufällig die chronologische ist.
*
* 2. RECHTE werden beim Einsammeln festgehalten, nicht 2028 rekonstruiert. Zu jedem Fundstück
* entsteht eine `.quelle.txt` daneben und eine Zeile im Register. Was geklärt werden muss,
* landet zusätzlich in 00_RECHTE-ZU-KLAEREN.txt — im Ordner, wo es jemand sieht.
*/
/** Jahrzehnt-Bündel: [von, bis] => Ordnername. Früh breit, später enger — dort wird das Material dichter. */
const CHRONIK_BUCKETS = [
'1908-1929' => '1908-1929',
'1930-1949' => '1930-1949',
'1950-1969' => '1950-1969',
'1970-1989' => '1970-1989',
'1990-2009' => '1990-2009',
'2010-2028' => '2010-2028',
];
const CHRONIK_DOKUMENTE = 'Festschriften-und-Dokumente';
const CHRONIK_UNDATIERT = 'Undatiert';
const CHRONIK_README = '00_LIESMICH.txt';
const CHRONIK_RECHTE = '00_RECHTE-ZU-KLAEREN.txt';
/** Register liegt im REPO, nicht im Uploads-Ordner: uploads/ ist gitignored, das Register braucht Versionsgeschichte. */
function chronik_register_path(): string
{
return ROOT_PATH . '/docs/chronik/register.csv';
}
function chronik_root(): string
{
return STORAGE_PATH . '/dateien/uploads/Vereinshistorie';
}
/**
* Wayback-Adressen auf die ursprüngliche Adresse zurückführen.
*
* Das Internet Archive ist nur der Aufbewahrungsort — Urheber und Rechte richten sich nach
* der Seite, die dort eingefroren wurde. Ein Schnappschuss der eigenen alten Vereinsseite
* gehört dem Verein, nicht dem Archiv.
*/
function chronik_origin_url(string $url): string
{
if (preg_match('#^https?://web\.archive\.org/web/[^/]+/(https?://.+)$#i', $url, $m)) {
return $m[1];
}
return $url;
}
/**
* Rechtelage aus dem Host ableiten.
*
* Die Einschätzung ist eine ARBEITSHILFE, keine Rechtsauskunft — sie sagt nur, wo geklärt
* werden muss, bevor etwas gedruckt oder auf die Website gestellt wird. Sammeln und Sichten
* ist das eine, Veröffentlichen das andere; genau diese Grenze markiert das Feld.
*
* @return array{status:string, klaeren:bool, hinweis:string}
*/
function chronik_rights(string $host): array
{
$host = strtolower(preg_replace('/^www\./', '', $host));
$map = [
'infranken.de' => [
'Presse © Mediengruppe Oberfranken (inFranken/Bayerische Rundschau)',
true,
'Text UND Foto sind geschützt. Für Abdruck in der Chronik bzw. Nutzung auf der Website '
. 'Nachdruckrechte beim Verlag anfragen (Mediengruppe Oberfranken, Bamberg). Für '
. 'Vereinsjubiläen wird das üblicherweise gegen Quellenangabe eingeräumt — aber schriftlich, und früh.',
],
'bayerische-rundschau.de' => [
'Presse © Mediengruppe Oberfranken (Bayerische Rundschau)',
true,
'Wie inFranken — gleicher Verlag. Nachdruckrechte schriftlich einholen.',
],
'frankenpost.de' => [
'Presse © Mediengruppe Oberfranken (Frankenpost)',
true,
'Gleiches Haus wie Bayerische Rundschau/inFranken — eine gemeinsame Anfrage an die '
. 'Mediengruppe Oberfranken deckt alle drei Titel ab.',
],
'fraenkischertag.de' => [
'Presse © Mediengruppe Oberfranken (Fränkischer Tag)',
true,
'Vierter Titel des Hauses, dieselbe Anfrage deckt ihn mit ab. Achtung beim Lokalsport '
. 'Kulmbach: dort steht oft „Foto: privat" — das Bild kommt dann aus dem Verein oder von '
. 'einer Privatperson, die Bildrechte liegen bei ihr und nicht beim Verlag. Wer das ist, '
. 'weiß die Redaktion oder die Mannschaft; danach ist es ein Anruf statt einer Anfrage.',
],
'kicker.de' => [
'Presse © kicker / Olympia-Verlag',
true,
'Reine Stammdaten (Liga, Spielort) sind Fakten und frei; Texte und Fotos nicht.',
],
'europlan-online.de' => [
'© Europlan-Online (Groundhopping-Portal)',
true,
'Stadiondaten als Fakten frei verwendbar; die Stadionfotos gehören den einzelnen '
. 'Fotografen und sind einzeln anzufragen.',
],
'turneninkulmbach.info' => [
'Turnabteilung TSV 08 Kulmbach — eigenes Material',
false,
'Seite der eigenen Turnabteilung. Intern klären, wer sie betreut, dann sind die '
. 'Inhalte für die Chronik verfügbar.',
],
'anpfiff.info' => [
'© anpfiff.info',
true,
'Amateurfußball-Portal. Ergebnisse frei, Berichte und Fotos nicht.',
],
'bfv.de' => [
'© Bayerischer Fußball-Verband',
true,
'Reine Ergebnis- und Tabellendaten (Fakten) sind frei verwendbar; Texte, Fotos und Wappen nicht. '
. 'Wappen fremder Vereine nur mit deren Zustimmung abbilden.',
],
'fussball.de' => [
'© DFB / Bayerischer Fußball-Verband',
true,
'Ergebnisse und Tabellenstände als Fakten frei; Seitenaufbau, Texte und Fotos nicht.',
],
'deutsche-turnliga.de' => [
'© Deutsche Turnliga',
true,
'Ergebnisdaten frei, Texte/Fotos nicht.',
],
'tsv08kulmbach.de' => [
'Eigenes Material (TSV 08 Kulmbach)',
false,
'Aus dem Bestand des Vereins — nutzbar. Bei Personenfotos trotzdem das Recht am eigenen Bild beachten.',
],
// Frühere Vereinsdomain (Jimdo, ~20162023). DNS läuft nicht mehr, erreichbar nur
// noch über das Internet Archive — inhaltlich aber unser eigenes Material.
'tsv1908kulmbach.de' => [
'Eigenes Material (TSV 08 Kulmbach, frühere Vereinsseite)',
false,
'Alte Vereinsseite, nur noch im Internet Archive. Inhalte stammen vom Verein selbst. '
. 'Bei Personenfotos das Recht am eigenen Bild beachten.',
],
'commons.wikimedia.org' => [
'Wikimedia Commons — freie Lizenz, Bedingung prüfen',
true,
'Frei nutzbar, aber fast immer MIT Namensnennung und Lizenzangabe. Die konkrete Lizenz '
. '(CC BY-SA, CC0, PD) auf der Dateiseite ablesen und im Register eintragen.',
],
'de.wikipedia.org' => [
'Wikipedia — CC BY-SA',
true,
'Text nur mit Namensnennung und unter gleicher Lizenz weiterverwendbar. Für eine Chronik '
. 'besser als Faktenquelle nutzen und selbst formulieren.',
],
'facebook.com' => [
'Social Media — Urheber unklar',
true,
'Wer das Foto gemacht hat, steht dort meist nicht. Vor einer Veröffentlichung Urheber '
. 'ermitteln (oft der Verein selbst oder ein Mitglied) und Einwilligung abgeborgener Personen prüfen.',
],
'instagram.com' => [
'Social Media — Urheber unklar',
true,
'Wie Facebook. Eigene Vereinsposts sind unkritisch, fremde nicht.',
],
'digipress.digitale-sammlungen.de' => [
'Bayerische Staatsbibliothek — meist gemeinfrei',
false,
'Zeitungen bis ~1920er sind in der Regel gemeinfrei. Quellenangabe (Blatt, Datum, Seite, BSB) trotzdem mitführen.',
],
];
foreach ($map as $needle => [$status, $klaeren, $hinweis]) {
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
return ['status' => $status, 'klaeren' => $klaeren, 'hinweis' => $hinweis];
}
}
return [
'status' => 'Ungeklärt — Urheber unbekannt',
'klaeren' => true,
'hinweis' => 'Herkunft im Register nachtragen und vor jeder Veröffentlichung klären.',
];
}
/**
* Datum (YYYY, YYYY-MM oder YYYY-MM-DD) → Zielordner.
* Unbekanntes oder unplausibles Datum landet in Undatiert/ statt still im falschen Jahrzehnt.
*/
function chronik_bucket(?string $date): string
{
if ($date === null || !preg_match('/^(\d{4})/', $date, $m)) {
return CHRONIK_UNDATIERT;
}
$year = (int) $m[1];
foreach (CHRONIK_BUCKETS as $range => $dir) {
[$from, $to] = array_map('intval', explode('-', $range));
if ($year >= $from && $year <= $to) {
return $dir;
}
}
return CHRONIK_UNDATIERT;
}
/** Datum auf YYYY-MM-DD normalisieren; unbekannte Teile werden 00 (sortiert vor dem 1. des Monats). */
function chronik_date_norm(?string $date): string
{
if ($date === null || !preg_match('/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/', $date, $m)) {
return '0000-00-00';
}
return sprintf('%s-%s-%s', $m[1], $m[2] ?? '00', $m[3] ?? '00');
}
/** Titel → dateinamensicherer Slug. Umlaute ausgeschrieben, damit NFD/NFC nie zum Thema wird. */
function chronik_slug(string $text, int $maxLen = 60): string
{
$text = strtr($text, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue',
'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'á' => 'a', 'à' => 'a', 'ç' => 'c',
]);
$text = (string) preg_replace('/[^A-Za-z0-9]+/', '-', $text);
$text = trim((string) preg_replace('/-+/', '-', $text), '-');
if (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
$text = (string) preg_replace('/-[^-]*$/', '', $text) ?: $text;
}
return $text !== '' ? $text : 'ohne-titel';
}
/** Kurzkürzel der Quelle für den Dateinamen (BR, BFV, …). */
function chronik_source_tag(string $host): string
{
$host = strtolower(preg_replace('/^www\./', '', $host));
$tags = [
'infranken.de' => 'BR',
'bayerische-rundschau.de' => 'BR',
'frankenpost.de' => 'FP',
'fraenkischertag.de' => 'FT',
'kicker.de' => 'KICKER',
'europlan-online.de' => 'EUROPLAN',
'anpfiff.info' => 'ANPFIFF',
'turneninkulmbach.info' => 'TURNEN08',
'tsv1908kulmbach.de' => 'TSV08',
'bfv.de' => 'BFV',
'fussball.de' => 'FUSSBALLDE',
'deutsche-turnliga.de' => 'DTL',
'tsv08kulmbach.de' => 'TSV08',
'facebook.com' => 'FB',
'instagram.com' => 'IG',
'de.wikipedia.org' => 'WIKIPEDIA',
'commons.wikimedia.org' => 'COMMONS',
'digipress.digitale-sammlungen.de' => 'BSB',
'web.archive.org' => 'WAYBACK',
];
foreach ($tags as $needle => $tag) {
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
return $tag;
}
}
return strtoupper(chronik_slug(explode('.', $host)[0] ?? 'quelle', 12));
}
/** Registerzeile anhängen (CSV, Semikolon — öffnet sich in Excel/LibreOffice ohne Import-Dialog). */
function chronik_register_append(array $row): void
{
$path = chronik_register_path();
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$cols = ['datei', 'datum', 'titel', 'art', 'quelle', 'url', 'urheber', 'rechte', 'klaeren', 'abgerufen', 'notiz'];
$new = !is_file($path);
$fh = fopen($path, 'a');
if (!$fh) {
return;
}
flock($fh, LOCK_EX);
if ($new) {
fputcsv($fh, $cols, ';', '"', '\\');
}
fputcsv($fh, array_map(static fn(string $c): string => (string) ($row[$c] ?? ''), $cols), ';', '"', '\\');
flock($fh, LOCK_UN);
fclose($fh);
}
/** Schon im Register? Verhindert Dubletten bei wiederholten Läufen. */
function chronik_register_has_url(string $url): bool
{
$path = chronik_register_path();
if (!is_file($path)) {
return false;
}
$fh = fopen($path, 'r');
if (!$fh) {
return false;
}
$found = false;
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
if (($row[5] ?? '') === $url) {
$found = true;
break;
}
}
fclose($fh);
return $found;
}
/** Beleg-Sidecar neben dem Fundstück: alles, was der Dateiname nicht fasst. */
function chronik_sidecar_text(array $row, string $hinweis): string
{
$lines = [
'FUNDSTÜCK FÜR DIE VEREINSCHRONIK — TSV 08 Kulmbach',
str_repeat('=', 52),
'',
'Titel: ' . ($row['titel'] ?? ''),
'Datum: ' . ($row['datum'] ?? '') . ' (00 = unbekannt)',
'Art: ' . ($row['art'] ?? ''),
'Quelle: ' . ($row['quelle'] ?? ''),
'URL: ' . ($row['url'] ?? ''),
'Urheber: ' . (($row['urheber'] ?? '') !== '' ? $row['urheber'] : 'nicht angegeben'),
'Abgerufen am: ' . ($row['abgerufen'] ?? ''),
'',
'RECHTE: ' . ($row['rechte'] ?? ''),
' ' . (($row['klaeren'] ?? '') === 'ja' ? '>>> VOR VERÖFFENTLICHUNG KLÄREN <<<' : 'nutzbar'),
'',
wordwrap($hinweis, 88, "\n"),
];
if (($row['notiz'] ?? '') !== '') {
$lines[] = '';
$lines[] = 'Notiz:';
$lines[] = wordwrap((string) $row['notiz'], 88, "\n");
}
return implode("\n", $lines) . "\n";
}
/**
* 00_RECHTE-ZU-KLAEREN.txt aus dem Register neu schreiben.
* Bewusst generiert statt fortgeschrieben: die Datei soll immer den aktuellen Stand zeigen,
* auch wenn im Register etwas auf „geklärt" gesetzt wurde.
*/
function chronik_write_rights_todo(): int
{
$path = chronik_register_path();
if (!is_file($path)) {
return 0;
}
$byRights = [];
$count = 0;
$fh = fopen($path, 'r');
if (!$fh) {
return 0;
}
fgetcsv($fh, 0, ';', '"', '\\'); // Kopfzeile
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
if (($row[8] ?? '') !== 'ja') {
continue;
}
$byRights[$row[7] ?? 'Ungeklärt'][] = [
'datei' => $row[0] ?? '',
'titel' => $row[2] ?? '',
'datum' => $row[1] ?? '',
'url' => $row[5] ?? '',
// Der Verlag verlangt den Bildnachweis namentlich — wo er bekannt ist,
// gehört er in die Anfrage-Anlage, nicht nur ins Register.
'urheber' => $row[6] ?? '',
];
$count++;
}
fclose($fh);
$out = [
'RECHTE, DIE VOR DER VERÖFFENTLICHUNG ZU KLÄREN SIND',
str_repeat('=', 52),
'',
'Diese Datei wird von bin/chronik-add.php erzeugt — nicht von Hand pflegen,',
'sondern die Spalte „klaeren" in docs/chronik/register.csv auf „nein" setzen,',
'sobald eine Freigabe vorliegt, und das Skript erneut laufen lassen.',
'',
'Sammeln und intern sichten ist unproblematisch. Erst der Abdruck in der Chronik',
'oder die Veröffentlichung auf der Website braucht eine Erlaubnis.',
'',
"Offen: {$count} Fundstücke",
'',
];
foreach ($byRights as $rechte => $items) {
$out[] = str_repeat('-', 52);
$out[] = $rechte . ' (' . count($items) . ')';
$out[] = str_repeat('-', 52);
foreach ($items as $i) {
$out[] = ' · ' . $i['datum'] . ' ' . $i['titel'];
$out[] = ' ' . $i['datei'];
if ($i['urheber'] !== '') {
$out[] = ' ' . $i['urheber'];
}
if ($i['url'] !== '') {
$out[] = ' ' . $i['url'];
}
}
$out[] = '';
}
$out[] = 'Stand: ' . date('d.m.Y H:i');
file_put_contents(chronik_root() . '/' . CHRONIK_RECHTE, implode("\n", $out) . "\n");
return $count;
}
/**
* Entwurf der Nachdruck-Anfrage an den Verlag, als Datei im Ordner.
*
* Steht hier und nicht im Repo, weil der Vorstand sie braucht, nicht die Entwicklung — und
* weil sie neben den Fundstücken liegen soll, auf die sie sich bezieht. Der wichtigste Satz
* darin ist die Bitte um EINE Freigabe für alle drei Titel: Bayerische Rundschau, inFranken
* und Frankenpost gehören zur selben Mediengruppe, drei Einzelanfragen wären unnötig.
*/
function chronik_verlagsanfrage_text(): string
{
return <<<TXT
ENTWURF — ANFRAGE NACHDRUCKRECHTE
=================================
Für die Vereinschronik zum 120-jährigen Bestehen (2028) wollen wir Zeitungsberichte und
Pressefotos verwenden. Die gehören dem Verlag, nicht uns — auch die, in denen wir vorkommen.
Bitte früh abschicken. Eine Absage im Jahr 2027 wirft das Chronik-Konzept um, eine Absage
2026 kostet nichts.
Empfänger: Mediengruppe Oberfranken Zeitungsverlage GmbH & Co. KG
(deckt Bayerische Rundschau, inFranken.de UND Frankenpost gemeinsam ab)
Vor dem Versand prüfen: aktuelle Anschrift und zuständige Ansprechperson auf der
Verlagsseite nachsehen, die Liste im Anhang aus docs/chronik/register.csv erzeugen.
---------------------------------------------------------------------------
Betreff: Nachdruckrechte für die Vereinschronik zum 120-jährigen Jubiläum des
TSV 08 Kulmbach (2028)
Sehr geehrte Damen und Herren,
der TSV 1908 Kulmbach e. V. begeht im Jahr 2028 sein 120-jähriges Bestehen. Aus diesem
Anlass erarbeiten wir eine Vereinschronik.
Über die Jahrzehnte hat die Bayerische Rundschau das Vereinsleben in Kulmbach begleitet.
Ihre Berichte sind für unsere Vereinsgeschichte häufig die einzige erhaltene Quelle.
Wir möchten Sie deshalb um die Erlaubnis bitten, einzelne Artikel und Pressefotos in der
Chronik abzudrucken — jeweils mit vollständiger Quellenangabe (Titel, Datum, Autorin
oder Autor, Bildnachweis).
Konkret geht es um:
· Umfang: voraussichtlich [ANZAHL] Artikel und [ANZAHL] Fotos
(Aufstellung im Anhang)
· Verwendung: gedruckte Festschrift, Auflage ca. [ANZAHL] Exemplare,
Abgabe an Mitglieder und Gäste, nicht gewerblich
· zusätzlich: Darstellung einzelner Ausschnitte im Historie-Bereich unserer
Vereinswebsite tsv08kulmbach.de
· Zeitraum: Erscheinen der Chronik im Jubiläumsjahr 2028
Sollten für die Nutzung Gebühren anfallen, bitten wir um eine Auskunft über die Konditionen.
Als gemeinnütziger Verein sind wir für ein Entgegenkommen dankbar.
Für Rückfragen stehen wir gerne zur Verfügung.
Mit freundlichen Grüßen
[NAME], [FUNKTION]
TSV 1908 Kulmbach e. V.
[ANSCHRIFT / TELEFON / E-MAIL]
Anlage: Aufstellung der gewünschten Artikel und Fotos
---------------------------------------------------------------------------
NOCH ZU KLÄREN, UNABHÄNGIG VOM VERLAG
-------------------------------------
· Private Fotos von Mitgliedern: Einverständnis der abgebildeten Personen einholen.
Bei Kindern die Eltern fragen. Am einfachsten gleich beim Einsammeln miterledigen.
· Wappen anderer Vereine: nur mit deren Zustimmung abbilden.
· Fotos ohne bekannten Urheber: Herkunft ermitteln, solange es noch jemand weiß.
In zwei Jahren weiß es niemand mehr.
TXT;
}
function chronik_readme_text(): string
{
return <<<TXT
VEREINSHISTORIE — MATERIALSAMMLUNG FÜR DAS JUBILÄUM 2028
========================================================
Hier sammeln wir alles, was in die Vereinschronik zum 120-jährigen Bestehen
einfließen kann: Fotos, Zeitungsausschnitte, Urkunden, Festschriften, Programmhefte,
Mannschaftsbilder, Wimpel, Protokolle.
Lieber zu viel als zu wenig. Aussortiert wird später.
WO KOMMT ES HIN?
----------------
In den Ordner des Jahrzehnts, aus dem es stammt:
1908-1929 1930-1949 1950-1969 1970-1989 1990-2009 2010-2028
Weißt du das Jahr nicht? -> Undatiert/ (wir ordnen es später zu)
Festschrift, Satzung, Urkunde, Protokoll? -> Festschriften-und-Dokumente/
WIE SOLL ES HEISSEN?
--------------------
Datum zuerst, dann worum es geht:
1972-07-15_Einweihung-Sportheim.jpg
1985-00-00_Mannschaftsfoto-Erste.jpg (Monat/Tag unbekannt -> 00)
1962-00-00_Aufstieg-Bericht_BR.jpg (Quelle hinten, hier: Bayerische Rundschau)
Das Datum vorn sorgt dafür, dass die Liste sich von selbst richtig sortiert.
Wenn du den Namen nicht hinbekommst: trotzdem hochladen. Ein falsch benanntes
Foto ist unendlich viel besser als ein Foto, das im Schuhkarton bleibt.
WAS UNS BEI JEDEM STÜCK HILFT
-----------------------------
Wenn du es weißt, schreib es dazu (in den Dateinamen oder als kurze Textdatei daneben):
· Wann ungefähr?
· Wer ist zu sehen?
· Wo aufgenommen?
· Wer hat fotografiert / wo stand der Artikel?
Der letzte Punkt ist der wichtigste und der, der am häufigsten fehlt. Ohne ihn
dürfen wir das Bild später womöglich nicht drucken.
RECHTE
------
Zeitungsartikel und Pressefotos gehören dem Verlag, nicht uns — auch wenn wir
darin vorkommen. Was noch freigegeben werden muss, steht in
00_RECHTE-ZU-KLAEREN.txt
Private Aufnahmen von Vereinsmitgliedern sind unkritisch, solange die abgebildeten
Personen mit einer Veröffentlichung einverstanden sind.
Fragen? Melde dich bei der Vorstandschaft.
TXT;
}

83
bin/dateien-init.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* Ordnerstruktur der Dateiablage /dateien anlegen: Jahr → Abteilung.
*
* 2026/
* Fußball/
* Jugend/
* Turnen/
* Feste und Aktionen/
*
* Warum diese Reihenfolge: das Jahr außen hält die oberste Ebene für immer kurz
* (ein Eintrag pro Jahr) und macht die Grenze zum Archiv sichtbar. Die Abteilungen
* innen sind bewusst dieselben wie die Bereiche der Website — dann ist beim Hochladen
* schon klar, auf welche Seite das Material später gehört. Wer ein Ereignis bündeln
* will, legt darin einen Ordner mit Datum voran an („2026-08-15 Sommerfest"); der
* sortiert sich dann von selbst richtig ein.
*
* Tiefer als diese zwei Ebenen wird nichts vorgegeben: die hochladenden Personen
* stehen meist mit dem Handy am Spielfeldrand, und jede zusätzliche Ebene wird
* ignoriert oder falsch getroffen.
*
* Idempotent — vorhandene Ordner bleiben unberührt. Im Januar für das neue Jahr
* einmal neu aufrufen (oder das Vereins-Konto legt den Jahresordner selbst an).
*
* NUR CLI. Die Ordner müssen auf dem Server entstehen, nicht per FTP hochgeladen
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC), sonst
* steht „Fußball" am Ende zweimal da.
*
* Aufruf: php bin/dateien-init.php [Jahr] (ohne Argument: laufendes Jahr)
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const DATEIEN_ABTEILUNGEN = ['Fußball', 'Jugend', 'Turnen', 'Feste und Aktionen'];
// Europe/Berlin, weil PHP global auf UTC läuft — sonst kippt das Jahr am 31.12. zu früh.
$year = (string) ($argv[1] ?? (new DateTimeImmutable('now', new DateTimeZone('Europe/Berlin')))->format('Y'));
if (!preg_match('/^20\d{2}$/', $year)) {
fwrite(STDERR, "Ungültiges Jahr: {$year}\nAufruf: php bin/dateien-init.php [Jahr]\n");
exit(1);
}
$root = STORAGE_PATH . '/dateien/uploads';
if (!is_dir($root)) {
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
exit(1);
}
if (!is_writable($root)) {
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
exit(1);
}
$paths = [$year];
foreach (DATEIEN_ABTEILUNGEN as $abteilung) {
$paths[] = $year . '/' . $abteilung;
}
$created = 0;
$failed = 0;
foreach ($paths as $rel) {
$dir = $root . '/' . $rel;
if (is_dir($dir)) {
echo "· {$rel} (gibt es schon)\n";
continue;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "{$rel} konnte nicht angelegt werden\n");
$failed++;
continue;
}
echo "{$rel}\n";
$created++;
}
echo "\n{$created} Ordner angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
exit($failed > 0 ? 1 : 0);

273
bin/filesgallery-update.php Normal file
View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* Files Gallery (Dateiablage /dateien) aktualisieren — App-Datei und self-hosted Assets.
*
* Warum ein Skript: die App lädt ihre Assets sonst vom jsDelivr-CDN (index.php:965), was
* gegen Regel 1 verstößt. Self-Hosting ist offiziell vorgesehen, muss aber bei JEDEM
* Versionswechsel wiederholt werden, weil die Asset-Pfade die Version enthalten
* (files.photo.gallery@<version>/...). Dazu kommt der TSV-PATCH in index.php, den ein
* Update sonst wegwirft. Beides von Hand ist genau der Schritt, der einmal vergessen wird.
*
* NUR CLI/Build — nie im Request-Pfad. Die einzige ausgehende Verbindung ist jsDelivr,
* und die passiert hier, nicht im Browser des Besuchers.
*
* Aufruf:
* php bin/filesgallery-update.php --check nur prüfen (Patch da? Assets zur Version da?)
* php bin/filesgallery-update.php --assets Assets zur vendorten Version neu holen
* php bin/filesgallery-update.php neue index.php holen, patchen, Assets holen
*
* Nach einem echten Versionssprung: Login, Upload und Vorschau einmal durchklicken und
* die alten Asset-Verzeichnisse löschen (das Skript sagt, welche das sind).
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const FG_APP = PUBLIC_PATH . '/dateien/index.php';
const FG_ASSETS_DIR = PUBLIC_PATH . '/assets/filesgallery';
const FG_APP_URL = 'https://cdn.jsdelivr.net/npm/files.photo.gallery/index.php';
const FG_LOG = STORAGE_PATH . '/logs/filesgallery.log';
// Der Patch, ohne den die App bei abweichendem Arbeitsverzeichnis ohne Login startet.
const FG_PATCH_NEEDLE = "public static \$localconfigpath = '_filesconfig.php';";
const FG_PATCH_MARKER = "public static \$localconfigpath = __DIR__ . '/_filesconfig.php';";
const FG_PATCH_REPLACEMENT = <<<'PHP'
// TSV-PATCH (einzige Änderung an dieser Datei): absolut statt relativ.
// Der Original-Pfad wird gegen das aktuelle Arbeitsverzeichnis aufgelöst. Weicht das ab
// (z. B. unter `php -S` mit Router-Script), findet die App _filesconfig.php nicht und
// startet mit ihrer Default-Config — also OHNE Login und mit falschen Pfaden.
// Nebeneffekt: der Selbst-Ausschluss in Path::is_exclude() vergleicht diesen Wert mit
// einem Basename und greift dadurch nicht mehr. Belanglos, weil unser `root`
// (storage/dateien/uploads) außerhalb dieses Verzeichnisses liegt.
// Nach jedem Update neu setzen: `php bin/filesgallery-update.php` erledigt und prüft das.
public static $localconfigpath = __DIR__ . '/_filesconfig.php';
PHP;
$mode = $argv[1] ?? '';
if (!in_array($mode, ['', '--check', '--assets'], true)) {
fwrite(STDERR, "Unbekannte Option: {$mode}\nAufruf: php bin/filesgallery-update.php [--check|--assets]\n");
exit(1);
}
$errors = 0;
function fg_log(string $line): void
{
@file_put_contents(FG_LOG, date('c') . ' ' . $line . "\n", FILE_APPEND);
}
function fg_fail(string $message): void
{
global $errors;
$errors++;
fwrite(STDERR, "{$message}\n");
fg_log('FEHLER ' . $message);
}
/** Datei von jsDelivr holen; gibt den Rohinhalt zurück oder null. */
function fg_fetch(string $url): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120, // die Assets sind ein paar MB
CURLOPT_USERAGENT => 'tsv08kulmbach-filesgallery-update/1.0',
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
if (!is_string($body) || $body === '' || $status !== 200) {
fg_fail("Download fehlgeschlagen ({$status}" . ($error !== '' ? ", {$error}" : '') . "): {$url}");
return null;
}
return $body;
}
/** Version aus dem Quelltext der App lesen. */
function fg_version(string $source): ?string
{
return preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : null;
}
/**
* Den TSV-PATCH anwenden. Idempotent: ist er schon drin, bleibt die Datei unverändert.
* Fehlt beides (Patch und Original-Zeile), bricht das Skript ab — dann hat die App ihre
* Struktur geändert und der Patch muss von Hand nachgezogen werden.
*/
function fg_patch(string $source): ?string
{
if (str_contains($source, FG_PATCH_MARKER)) {
return $source;
}
if (!str_contains($source, FG_PATCH_NEEDLE)) {
fg_fail('Patch-Stelle nicht gefunden ($localconfigpath) — index.php von Hand prüfen, NICHT ungepatcht ausliefern.');
return null;
}
// Die Original-Zeile trägt einen Kommentar hinter dem Semikolon; den behalten wir.
return (string) preg_replace(
'/^\h*' . preg_quote(FG_PATCH_NEEDLE, '/') . '/m',
FG_PATCH_REPLACEMENT,
$source,
1
);
}
/** assets.zip zur Version holen und nach public/assets/filesgallery/ entpacken. */
function fg_assets(string $version): void
{
$url = "https://cdn.jsdelivr.net/npm/files.photo.gallery@{$version}/_files/assets/assets.zip";
$zipData = fg_fetch($url);
if ($zipData === null) {
return;
}
$tmp = tempnam(sys_get_temp_dir(), 'fg-assets-');
if ($tmp === false || file_put_contents($tmp, $zipData) === false) {
fg_fail('Konnte assets.zip nicht zwischenspeichern.');
return;
}
$zip = new ZipArchive();
if ($zip->open($tmp) !== true) {
fg_fail('assets.zip ist kein lesbares ZIP-Archiv.');
@unlink($tmp);
return;
}
if (!is_dir(FG_ASSETS_DIR) && !mkdir(FG_ASSETS_DIR, 0775, true) && !is_dir(FG_ASSETS_DIR)) {
fg_fail('Kann ' . FG_ASSETS_DIR . ' nicht anlegen.');
$zip->close();
@unlink($tmp);
return;
}
$written = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
// Manche Archive tragen den _files/assets-Pfad noch mit sich; wir wollen den Inhalt flach.
$rel = (string) preg_replace('#^(\./)?(_files/)?assets/#', '', $name);
$rel = ltrim(str_replace('\\', '/', $rel), '/');
// Zip-Slip: alles mit Parent-Traversal oder absolutem Pfad wird verworfen.
if ($rel === '' || str_contains($rel, '../')) {
continue;
}
$target = FG_ASSETS_DIR . '/' . $rel;
if (str_ends_with($name, '/')) {
if (!is_dir($target)) {
@mkdir($target, 0775, true);
}
continue;
}
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fg_fail("Kann Verzeichnis nicht anlegen: {$dir}");
continue;
}
$stream = $zip->getStream($name);
if ($stream === false) {
fg_fail("Konnte Eintrag nicht lesen: {$name}");
continue;
}
if (@file_put_contents($target, $stream) === false) {
fg_fail("Konnte Datei nicht schreiben: {$target}");
} else {
$written++;
}
fclose($stream);
}
$zip->close();
@unlink($tmp);
echo "✓ Assets entpackt ({$written} Dateien) → " . str_replace(ROOT_PATH . '/', '', FG_ASSETS_DIR) . "\n";
fg_log("Assets {$version} entpackt ({$written} Dateien)");
}
/** Prüfen, ob Patch und Assets zur vendorten Version passen. */
function fg_check(string $version, string $source): void
{
if (str_contains($source, FG_PATCH_MARKER)) {
echo "✓ TSV-PATCH in public/dateien/index.php vorhanden\n";
} else {
fg_fail('TSV-PATCH fehlt in public/dateien/index.php — die App würde ohne Login starten.');
}
$css = FG_ASSETS_DIR . "/files.photo.gallery@{$version}/css/files.css";
if (is_file($css)) {
echo "✓ Assets für Version {$version} vorhanden\n";
} else {
fg_fail("Assets für Version {$version} fehlen (erwartet: " . str_replace(ROOT_PATH . '/', '', $css) . ") — php bin/filesgallery-update.php --assets");
}
// Verirrte Assets anderer Versionen melden, damit sie nicht mitgeschleppt werden.
foreach (glob(FG_ASSETS_DIR . '/files.photo.gallery@*') ?: [] as $dir) {
if (basename($dir) !== "files.photo.gallery@{$version}") {
echo "· Alte Assets, können weg: " . str_replace(ROOT_PATH . '/', '', $dir) . "\n";
}
}
}
// --- Ablauf ---------------------------------------------------------------------------
if (!is_file(FG_APP)) {
fwrite(STDERR, '✗ ' . FG_APP . " fehlt.\n");
exit(1);
}
$source = (string) file_get_contents(FG_APP);
if ($mode === '' ) {
// Vollupdate: neue App-Datei holen, patchen, dann Assets zur neuen Version.
$fresh = fg_fetch(FG_APP_URL);
if ($fresh !== null) {
$newVersion = fg_version($fresh);
if ($newVersion === null) {
fg_fail('Version im Download nicht gefunden — Datei nicht übernommen.');
} else {
$patched = fg_patch($fresh);
if ($patched !== null) {
$old = fg_version($source) ?? '?';
if ($patched === $source) {
echo "· Version {$newVersion} ist schon aktuell und gepatcht\n";
} elseif (file_put_contents(FG_APP, $patched) === false) {
fg_fail('Konnte ' . FG_APP . ' nicht schreiben.');
} else {
echo "✓ index.php aktualisiert: {$old}{$newVersion} (gepatcht)\n";
fg_log("index.php {$old}{$newVersion}");
$source = $patched;
}
}
}
}
}
$version = fg_version($source);
if ($version === null) {
fwrite(STDERR, "✗ Version in public/dateien/index.php nicht lesbar.\n");
exit(1);
}
if ($mode !== '--check') {
fg_assets($version);
}
fg_check($version, $source);
if ($errors > 0) {
fwrite(STDERR, "\n{$errors} Problem(e) — nicht ausliefern, bis sie behoben sind.\n");
exit(1);
}
echo "\nFiles Gallery {$version}: alles an seinem Platz.\n";
exit(0);

View File

@@ -16,6 +16,8 @@ if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_slice($argv, 1);
$crop = false;
if (($args[0] ?? '') === '--crop') {
@@ -31,6 +33,7 @@ if (count($args) < 3) {
[$src, $dest, $spec] = $args;
$quality = (int) ($args[3] ?? 78);
if ($crop) {
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
'png' => imagecreatefrompng($src),
@@ -45,7 +48,6 @@ if (!$image) {
$srcW = imagesx($image);
$srcH = imagesy($image);
if ($crop) {
[$w, $h] = array_map('intval', explode('x', $spec));
$srcRatio = $srcW / $srcH;
$dstRatio = $w / $h;
@@ -65,12 +67,13 @@ if ($crop) {
exit(0);
}
foreach (explode(',', $spec) as $width) {
$w = min((int) $width, $srcW); // nie hochskalieren
$h = (int) round($srcH * $w / $srcW);
$out = imagecreatetruecolor($w, $h);
imagecopyresampled($out, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
$file = "{$dest}-{$w}.jpg";
imagejpeg($out, $file, $quality);
echo "{$file} ({$w}x{$h}, " . round(filesize($file) / 1024) . " KB)\n";
try {
$variants = resize_image_variants($src, $dest, array_map('intval', explode(',', $spec)), $quality);
} catch (RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
foreach ($variants as $variant) {
echo "{$variant['file']} ({$variant['width']}x{$variant['height']}, " . round($variant['bytes'] / 1024) . " KB)\n";
}

View File

@@ -16,35 +16,42 @@ declare(strict_types=1);
* 2. web_profile_info-API (JSON, edges[])
* 3. Profil-HTML nach eingebettetem JSON parsen
*/
if (PHP_SAPI !== 'cli') {
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv, true);
$verbose = in_array('-v', $argv ?? [], true);
$username = (string) config('instagram.username', 'tsv08kulmbach');
$maxPosts = (int) config('instagram.max_posts', 9);
$imgDir = PUBLIC_PATH . '/assets/img/instagram';
$cacheFile = DATA_PATH . '/instagram.json';
$log = static function (string $msg) use ($verbose): void {
$line = '[' . date('c') . "] {$msg}\n";
file_put_contents(STORAGE_PATH . '/logs/instagram.log', $line, FILE_APPEND);
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/instagram.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('instagram', $level, $msg);
if ($verbose) {
echo $line;
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$fail('Läuft bereits (Lock belegt)');
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- HTTP-Helfer mit Browser-typischen Headern ---
@@ -203,12 +210,12 @@ foreach ($posts as $post) {
if (!is_file($file)) {
$img = $fetch($post['display_url']);
if ($img === false) {
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen");
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$src = imagecreatefromstring($img);
if ($src === false) {
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen");
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$w = imagesx($src);

View File

@@ -3,14 +3,18 @@
declare(strict_types=1);
/**
* Logrotation für storage/logs/*.log — NUR CLI, per Cron 1×/Woche.
* Logpflege für storage/logs/ — NUR CLI, per Cron 1×/Woche.
*
* Wird eine Datei größer als das Limit, wandert sie nach <name>.log.1 (eine
* vorhandene .1 wird ersetzt) und das aktive Log startet leer. Damit bleiben
* höchstens zwei Generationen pro Log liegen — genug zum Nachsehen, ohne dass
* spam.log oder php-errors.log auf dem Shared Hosting unbegrenzt wachsen.
* Zwei Aufgaben:
* 1. Rotation nach Größe: wird eine Datei größer als das Limit, wandert sie nach
* <name>.log.1 (eine vorhandene .1 wird ersetzt) und das aktive Log startet leer.
* Es bleiben also höchstens zwei Generationen pro Log liegen.
* 2. Aufbewahrungsfrist: Einträge, die älter als --keep-days sind, werden aus den
* aktiven Logs entfernt; rotierte .1-Dateien jenseits der Frist gelöscht. Damit
* gibt es eine echte Löschfrist statt nur einer Größengrenze — auf einer ruhigen
* Vereinsseite läge ein Eintrag sonst jahrelang herum.
*
* Aufruf: php bin/log-rotate.php [--max-mb=2]
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
*/
if (PHP_SAPI !== 'cli') {
exit(1);
@@ -19,30 +23,108 @@ if (PHP_SAPI !== 'cli') {
require dirname(__DIR__) . '/app/bootstrap.php';
$maxMb = 2.0;
$keepDays = 90;
$verbose = in_array('-v', $argv, true);
foreach ($argv as $arg) {
if (preg_match('/^--max-mb=([0-9.]+)$/', $arg, $m) === 1) {
$maxMb = (float) $m[1];
} elseif (preg_match('/^--keep-days=(\d+)$/', $arg, $m) === 1) {
$keepDays = max(1, (int) $m[1]);
}
}
$limit = (int) ($maxMb * 1024 * 1024);
$cutoff = time() - $keepDays * 86400;
$logDir = STORAGE_PATH . '/logs';
/**
* Zeitstempel am Zeilenanfang: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
* (PHP selbst) oder ein nackter ISO-Stempel. Null = keiner.
*/
$stampOf = static function (string $line): ?int {
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
$ts = strtotime($m[1]);
return $ts === false ? null : $ts;
}
return null;
};
/**
* Einträge vor $cutoff entfernen. Zeilen ohne eigenen Zeitstempel (z. B. PHP-Stacktraces)
* gehören zum vorherigen Eintrag und teilen dessen Schicksal. Geschrieben wird über eine
* temporäre Datei + rename; ein Append genau in diesem Moment ginge verloren — bei einem
* wöchentlichen Lauf nachts ein akzeptables Risiko, dafür bleibt die Datei immer konsistent.
*/
$prune = static function (string $file) use ($cutoff, $stampOf): int {
$raw = (string) @file_get_contents($file);
if ($raw === '') {
return 0;
}
$keep = [];
$dropping = false;
$removed = 0;
foreach (explode("\n", $raw) as $line) {
$ts = $stampOf($line);
if ($ts !== null) {
$dropping = $ts < $cutoff;
}
if ($dropping) {
if (trim($line) !== '') {
$removed++;
}
continue;
}
$keep[] = $line;
}
if ($removed === 0) {
return 0;
}
$tmp = $file . '.tmp';
if (@file_put_contents($tmp, implode("\n", $keep)) === false || !@rename($tmp, $file)) {
@unlink($tmp);
return 0;
}
@chmod($file, 0664);
return $removed;
};
$rotated = 0;
foreach (glob(STORAGE_PATH . '/logs/*.log') ?: [] as $log) {
$size = (int) @filesize($log);
if ($size <= $limit) {
continue;
$pruned = 0;
$deleted = 0;
foreach (glob($logDir . '/*.log') ?: [] as $log) {
$name = basename($log);
// 1. Alte Einträge raus (vor der Größenprüfung — spart oft die Rotation).
$removed = $prune($log);
if ($removed > 0) {
$pruned += $removed;
if ($verbose) {
echo "gekürzt: {$name} ({$removed} Einträge älter als {$keepDays} Tage)\n";
}
if (!@rename($log, $log . '.1')) {
fwrite(STDERR, 'Rotation fehlgeschlagen: ' . basename($log) . "\n");
continue;
}
// Neue, leere Datei mit denselben Rechten anlegen, damit PHP/Cron weiterschreiben können.
@touch($log);
@chmod($log, 0664);
echo 'rotiert: ' . basename($log) . ' (' . round($size / 1048576, 1) . " MB)\n";
$rotated++;
}
echo $rotated === 0
? "Nichts zu rotieren (alle Logs unter {$maxMb} MB).\n"
: "{$rotated} Log(s) rotiert.\n";
// 2. Rotation nach Größe.
$size = (int) @filesize($log);
if ($size > $limit) {
if (@rename($log, $log . '.1')) {
@touch($log);
@chmod($log, 0664);
echo "rotiert: {$name} (" . round($size / 1048576, 1) . " MB)\n";
$rotated++;
} else {
fwrite(STDERR, "Rotation fehlgeschlagen: {$name}\n");
}
}
}
// 3. Rotierte Generationen jenseits der Frist löschen.
foreach (glob($logDir . '/*.log.1') ?: [] as $old) {
if ((int) @filemtime($old) < $cutoff && @unlink($old)) {
echo 'gelöscht: ' . basename($old) . " (älter als {$keepDays} Tage)\n";
$deleted++;
}
}
echo $rotated === 0 && $pruned === 0 && $deleted === 0
? "Nichts zu tun (alle Logs unter {$maxMb} MB, keine Einträge älter als {$keepDays} Tage).\n"
: "Fertig: {$rotated} rotiert, {$pruned} alte Einträge entfernt, {$deleted} Altdatei(en) gelöscht.\n";

166
bin/logs.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
/**
* Logübersicht — NUR CLI. Beantwortet in einem Aufruf: läuft alles, und wenn
* nicht, was ist zuletzt schiefgegangen? Ersetzt das Durchklicken von fünf
* Dateien per FTP.
*
* Aufruf:
* php bin/logs.php Übersicht + letzte Fehler
* php bin/logs.php --lines=20 mehr Zeilen je Kanal
* php bin/logs.php --errors nur die gesammelte error.log
* php bin/logs.php --channel=mail nur einen Kanal, dafür ausführlich
* php bin/logs.php --days=30 anderer Auswertungszeitraum (Default 7)
*
* Format der eigenen Logs (log_write() in helpers.php):
* [2026-07-27T08:11:45+00:00] ERROR Meldung
* php-errors.log kommt von PHP selbst und hat ein eigenes Format — es wird
* mitgezählt, aber nicht nach Level aufgeschlüsselt.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$lines = 5;
$days = 7;
$only = '';
$errorsOnly = false;
foreach ($argv as $arg) {
if (preg_match('/^--lines=(\d+)$/', $arg, $m) === 1) {
$lines = max(1, (int) $m[1]);
} elseif (preg_match('/^--days=(\d+)$/', $arg, $m) === 1) {
$days = max(1, (int) $m[1]);
} elseif (preg_match('/^--channel=([a-z0-9-]+)$/', $arg, $m) === 1) {
$only = $m[1];
$lines = max($lines, 20);
} elseif ($arg === '--errors') {
$errorsOnly = true;
$lines = max($lines, 20);
}
}
$logDir = STORAGE_PATH . '/logs';
$cutoff = time() - $days * 86400;
/**
* Zeitstempel am Zeilenanfang lesen: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
* (PHP selbst) oder ein nackter ISO-Stempel ohne Klammern. Null = kein Zeitstempel,
* die Zeile gehört dann zum vorherigen Eintrag (z. B. Stacktrace-Fortsetzung).
*/
$stampOf = static function (string $line): ?int {
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
$ts = strtotime($m[1]);
return $ts === false ? null : $ts;
}
return null;
};
/** @return string[] */
$readLines = static function (string $file): array {
if (!is_file($file) || filesize($file) === 0) {
return [];
}
return array_values(array_filter(explode("\n", (string) file_get_contents($file)), static fn ($l) => trim($l) !== ''));
};
$ago = static function (?int $ts): string {
if ($ts === null) {
return '—';
}
$d = time() - $ts;
if ($d < 3600) {
return 'vor ' . max(1, (int) round($d / 60)) . ' Min.';
}
if ($d < 86400) {
return 'vor ' . (int) round($d / 3600) . ' Std.';
}
return 'vor ' . (int) round($d / 86400) . ' Tagen';
};
$size = static function (string $file): string {
$b = is_file($file) ? (int) filesize($file) : 0;
return $b < 1024 ? "{$b} B" : ($b < 1048576 ? round($b / 1024) . ' KB' : round($b / 1048576, 1) . ' MB');
};
$tail = static function (array $all, int $n): array {
return array_slice($all, -$n);
};
echo "TSV 08 — Logübersicht (" . date('d.m.Y H:i') . ", Auswertung der letzten {$days} Tage)\n\n";
// --- Gesammelte Fehler zuerst: die eine Datei, die zählt ---
$errorFile = $logDir . '/error.log';
$errorLines = $readLines($errorFile);
$recentErrors = array_values(array_filter($errorLines, static fn ($l) => ($stampOf($l) ?? 0) >= $cutoff));
if ($recentErrors === []) {
echo "Keine Fehler in den letzten {$days} Tagen.\n";
} else {
echo count($recentErrors) . " Fehler in den letzten {$days} Tagen (storage/logs/error.log):\n";
foreach ($tail($recentErrors, $lines) as $line) {
echo ' ' . $line . "\n";
}
if (count($recentErrors) > $lines) {
echo ' … ' . (count($recentErrors) - $lines) . " weitere\n";
}
}
if ($errorsOnly) {
exit($recentErrors === [] ? 0 : 1);
}
// --- Kanäle ---
$files = glob($logDir . '/*.log') ?: [];
$channels = [];
foreach ($files as $file) {
$name = basename($file, '.log');
if ($name === 'error' || ($only !== '' && $name !== $only)) {
continue;
}
$channels[$name] = $file;
}
ksort($channels);
if ($channels === []) {
echo "\nKeine Kanal-Logs gefunden" . ($only !== '' ? " (--channel={$only})" : '') . ".\n";
exit(0);
}
echo "\n";
printf("%-14s %8s %-14s %s\n", 'KANAL', 'GRÖSSE', 'LETZTER EINTR.', "INFO/WARN/ERROR ({$days}d)");
echo str_repeat('-', 68) . "\n";
$body = [];
foreach ($channels as $name => $file) {
$all = $readLines($file);
$last = null;
$counts = ['INFO' => 0, 'WARN' => 0, 'ERROR' => 0];
foreach ($all as $line) {
$ts = $stampOf($line);
if ($ts !== null) {
$last = $ts;
}
if (($ts ?? 0) >= $cutoff && preg_match('/^\[[^\]]+\]\s+(INFO|WARN|ERROR)\s/', $line, $m) === 1) {
$counts[$m[1]]++;
}
}
$level = $name === 'php-errors'
? '(eigenes Format)'
: "{$counts['INFO']}/{$counts['WARN']}/{$counts['ERROR']}";
printf("%-14s %8s %-14s %s\n", $name, $size($file), $ago($last), $level);
$body[$name] = $tail($all, $lines);
}
foreach ($body as $name => $tailLines) {
echo "\n--- {$name}.log (letzte " . count($tailLines) . ") ---\n";
foreach ($tailLines as $line) {
echo $line . "\n";
}
}
echo "\nRotation: php bin/log-rotate.php · Deploy-Check: php bin/preflight.php\n";
exit($recentErrors === [] ? 0 : 1);

View File

@@ -21,14 +21,18 @@ declare(strict_types=1);
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
* setzt ihn (wie bin/instagram-sync.php).
*/
if (PHP_SAPI !== 'cli') {
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv, true);
$dump = in_array('--dump', $argv, true);
$verbose = in_array('-v', $argv ?? [], true);
$dump = in_array('--dump', $argv ?? [], true);
$apiHost = 'https://widget-prod.bfv.de';
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
@@ -61,16 +65,17 @@ $competitionLabels = [
'Hallenrunde' => 'Halle',
];
$log = static function (string $msg) use ($verbose): void {
$line = '[' . date('c') . "] {$msg}\n";
file_put_contents(STORAGE_PATH . '/logs/matchcenter.log', $line, FILE_APPEND);
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/matchcenter.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('matchcenter', $level, $msg);
if ($verbose) {
echo $line;
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
@@ -81,7 +86,9 @@ if ($teamsCfg === []) {
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$fail('Läuft bereits (Lock belegt)');
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
@@ -115,7 +122,7 @@ $fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
return null;
}
if ($dump) {
$log("DUMP {$url}\n" . substr($body, 0, 4000));
echo "DUMP {$url}\n" . substr($body, 0, 4000) . "\n";
}
$data = json_decode($body, true);
return is_array($data) ? $data : null;
@@ -139,12 +146,12 @@ $downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $cre
}
$bin = $fetch($crestBase . $clubId, false);
if ($bin === false) {
$log("Wappen-Download fehlgeschlagen: {$clubId}");
$log("Wappen-Download fehlgeschlagen: {$clubId}", 'WARN');
return null;
}
$src = @imagecreatefromstring($bin);
if ($src === false) {
$log("Wappen unlesbar: {$clubId}");
$log("Wappen unlesbar: {$clubId}", 'WARN');
return null;
}
$w = imagesx($src);
@@ -277,7 +284,7 @@ foreach ($teamsCfg as $cfg) {
$teamCfgId = (string) ($cfg['team_id'] ?? '');
$cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
if ($key === '' || $teamCfgId === '') {
$log("Team-Eintrag ohne key/team_id übersprungen");
$log("Team-Eintrag ohne key/team_id übersprungen", 'WARN');
continue;
}
$prevTeam = $prevByKey[$key] ?? null;
@@ -325,6 +332,13 @@ foreach ($teamsCfg as $cfg) {
$upcoming = [];
$results = [];
foreach ($matches as $m) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, gehört weder in "Nächste
// Spiele" noch in Ergebnisse (tauchte sonst bis in den
// Startseiten-Hero und die Stadionzeitung durch).
if (($m['homeTeamName'] ?? '') === 'SPIELFREI' || ($m['guestTeamName'] ?? '') === 'SPIELFREI') {
continue;
}
$hasResult = trim((string) ($m['result'] ?? '')) !== '';
$row = $mapMatch($m, $teamCfgId);
if ($hasResult) {
@@ -360,7 +374,7 @@ foreach ($teamsCfg as $cfg) {
$cacheFallbackUsed = true;
$keepCrests($upcoming);
$keepCrests($results);
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen");
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen", 'WARN');
}
// --- Tabellen-Teil ---
@@ -406,7 +420,7 @@ foreach ($teamsCfg as $cfg) {
$table = $prevTeam['table'] ?? [];
$cacheFallbackUsed = true;
$keepCrests($table);
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen");
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen", 'WARN');
}
// Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn
@@ -416,7 +430,7 @@ foreach ($teamsCfg as $cfg) {
$hasPrevious = $previous !== null
&& (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []);
if (!$hasCurrent && $matchesData === null && !$hasPrevious) {
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen");
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen", 'WARN');
continue;
}
@@ -448,7 +462,7 @@ foreach ($teamsCfg as $cfg) {
. ($hasPrevious ? ' (+ Vorsaison archiviert)' : ''));
} catch (\Throwable $e) {
// Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
$log("Team {$key} übersprungen: " . $e->getMessage());
$log("Team {$key} übersprungen: " . $e->getMessage(), 'WARN');
if ($prevTeam !== null) {
$resultTeams[] = $prevTeam;
$cacheFallbackUsed = true;

View File

@@ -37,6 +37,21 @@ if (str_ends_with($baseUrl, '/')) {
$warnings[] = 'base_url endet auf "/" — abs_url() erzeugt dann doppelte Slashes.';
}
// base_url MUSS der Host sein, den public/.htaccess erzwingt. Läuft es
// auseinander, zeigen Canonical, OG und Sitemap auf Adressen, die der Server
// sofort per 301 weiterleitet — und gedruckte QR-Codes bekommen einen
// unnötigen Umweg eingebacken. Genau dieser Fehler stand schon einmal drin
// (base_url ohne www, .htaccess mit).
$htaccess = PUBLIC_PATH . '/.htaccess';
if (is_file($htaccess)
&& preg_match('~RewriteRule \^ https://([a-z0-9.-]+)%\{REQUEST_URI\}~i', (string) file_get_contents($htaccess), $forced) === 1) {
$forcedHost = strtolower($forced[1]);
$configHost = strtolower((string) parse_url($baseUrl, PHP_URL_HOST));
if ($configHost !== '' && $configHost !== $forcedHost && !str_contains($configHost, 'localhost')) {
$errors[] = "base_url nutzt '{$configHost}', public/.htaccess leitet aber alles auf '{$forcedHost}' um — Canonical, OG und Sitemap zeigten auf weitergeleitete Adressen.";
}
}
// --- Abhängigkeiten ---
if (!is_file(ROOT_PATH . '/vendor/autoload.php')) {
$errors[] = 'vendor/ fehlt — auf dem Server "composer install --no-dev --optimize-autoloader" laufen lassen.';
@@ -64,6 +79,86 @@ foreach (['img/instagram', 'img/crests'] as $dir) {
}
}
// --- Dateiablage /dateien (Files Gallery) ---
// Die App muss in ihr Upload- und Storage-Verzeichnis schreiben können, sonst bricht
// schon der erste Aufruf ab (sie legt Cache-Verzeichnisse selbst an).
foreach (['uploads', 'system'] as $dir) {
$path = STORAGE_PATH . '/dateien/' . $dir;
if (!is_dir($path)) {
$errors[] = "storage/dateien/{$dir}/ fehlt — anlegen und beschreibbar machen.";
} elseif (!is_writable($path)) {
$errors[] = "storage/dateien/{$dir}/ ist nicht beschreibbar — Uploads und Thumbnails scheitern.";
}
}
// Ohne echten Hash ist der Zugang gesperrt (fail closed) — das soll auffallen, bevor
// jemand vor dem Login-Formular steht und sich wundert.
if (config('dateien') === null) {
$errors[] = "Der Block 'dateien' fehlt in config/config.php — die Dateiablage hätte keine Zugänge (siehe config.example.php).";
} else {
foreach (['verein', 'admin'] as $user) {
$hash = (string) config("dateien.users.{$user}.password_hash", '');
if ($hash === '' || $hash === 'CHANGE_ME') {
$errors[] = "dateien.users.{$user}.password_hash fehlt — dieser Zugang ist gesperrt.";
} elseif (password_get_info($hash)['algoName'] === 'unknown') {
$warnings[] = "dateien.users.{$user}.password_hash sieht wie Klartext aus — besser hashen: php -r \"echo password_hash('…', PASSWORD_DEFAULT);\"";
}
}
if (in_array((string) config('dateien.license_key', ''), ['', 'CHANGE_ME'], true)) {
$warnings[] = 'dateien.license_key fehlt — die Dateiablage läuft, zeigt aber den Hinweis der unlizenzierten Version.';
}
}
// Der Patch und die self-hosted Assets entscheiden darüber, ob die App überhaupt
// mit Login startet und ob sie ohne CDN auskommt. Details: bin/filesgallery-update.php
$galleryApp = PUBLIC_PATH . '/dateien/index.php';
if (!is_file($galleryApp)) {
$errors[] = 'public/dateien/index.php fehlt — die Dateiablage ist nicht ausgeliefert.';
} else {
$source = (string) file_get_contents($galleryApp);
if (!str_contains($source, "\$localconfigpath = __DIR__ . '/_filesconfig.php'")) {
$errors[] = 'public/dateien/index.php ist ungepatcht — die App würde OHNE Login starten. php bin/filesgallery-update.php';
}
// Wer Einstellungen direkt in die vendorte Datei schreibt, verliert sie beim nächsten
// Update — und Secrets hätten dort ohnehin nichts zu suchen (Regel 5). Die App warnt
// selbst davor (index.php:33), also fangen wir es hier ab.
if (preg_match('/^\s*\'(license_key|username|password)\' => \'.+\',/m', $source, $edited) === 1) {
$errors[] = "public/dateien/index.php wurde direkt editiert ('{$edited[1]}') — Wert nach config/config.php (dateien.*) verschieben und die Zeile leeren, sonst ist er nach dem nächsten Update weg.";
}
$version = preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : '?';
if (!is_file(PUBLIC_PATH . "/assets/filesgallery/files.photo.gallery@{$version}/css/files.css")) {
$errors[] = "Assets der Dateiablage fehlen (Version {$version}) — sonst lädt sie vom CDN und die CSP blockt alles: php bin/filesgallery-update.php --assets";
}
}
// Es gilt immer die kleinere von zwei Grenzen: unsere aus _filesconfig.php und die von
// PHP. Steht PHP niedriger, wird jedes größere Video abgewiesen, ohne dass man es
// unserer Konfiguration ansehen könnte — deshalb hier vergleichen, nicht nur ausgeben.
$phpUpload = ini_parse_quantity((string) ini_get('upload_max_filesize'));
$phpPost = ini_parse_quantity((string) ini_get('post_max_size'));
// Bewusst gelesen und nicht ausgeführt: _filesconfig.php setzt display_errors und
// Session-Parameter — Nebenwirkungen, die in diesem Skript nichts zu suchen haben.
$ourMax = 0;
$galleryConfigFile = PUBLIC_PATH . '/dateien/_filesconfig.php';
if (is_file($galleryConfigFile)
&& preg_match("/'upload_max_filesize'\s*=>\s*(\d+)/", (string) file_get_contents($galleryConfigFile), $um) === 1) {
$ourMax = (int) $um[1];
}
$mb = static fn (int|float $bytes): string => round($bytes / 1048576) . ' MB';
echo 'INFO Uploadgrenze: ' . ($ourMax > 0 ? $mb($ourMax) : 'unbekannt')
. " laut _filesconfig.php, diese PHP-CLI erlaubt {$mb($phpUpload)} (post_max_size {$mb($phpPost)})\n";
if ($ourMax > 0 && $phpUpload < $ourMax) {
// Achtung: das hier ist die CLI-Konfiguration. Für Uploads zählt die des Webservers
// (FPM + .user.ini), die abweichen darf — deshalb Warnung und nicht Fehler.
$warnings[] = "Diese PHP-CLI deckelt Uploads auf {$mb($phpUpload)}, unsere Config erlaubt {$mb($ourMax)}. Für Uploads zählt aber die Webserver-Konfiguration: public/dateien/.user.ini setzt {$mb($ourMax)}. Verbindlich prüfen mit einem echten großen Upload über /dateien (oder phpinfo() im Browser).";
}
if ($phpPost <= $phpUpload) {
$warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung.";
}
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
$file = DATA_PATH . '/' . $name . '.json';
@@ -74,6 +169,133 @@ foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchce
}
}
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
// Steht es in beiden, taucht es doppelt in der Termine-Liste auf und /termine
// baut zwei Event-Knoten für dasselbe Fest — beides fällt im Alltag kaum auf,
// deshalb prüft es die Maschine.
$eventSlugs = array_filter(array_column(json_load('events')['events'] ?? [], 'slug'));
$veranstaltungRows = json_load('veranstaltungen')['veranstaltungen'] ?? [];
$veranstaltungSlugs = array_column($veranstaltungRows, 'slug');
$doppelt = array_intersect($eventSlugs, $veranstaltungSlugs);
if ($doppelt !== []) {
$errors[] = 'Diese Slugs stehen in data/events.json UND data/veranstaltungen.json: '
. implode(', ', $doppelt) . ' — der Termin erscheint doppelt. Eintrag in events.json löschen.';
}
if (count($veranstaltungSlugs) !== count(array_unique($veranstaltungSlugs))) {
$errors[] = 'data/veranstaltungen.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
}
foreach ($veranstaltungRows as $i => $row) {
if (empty($row['slug']) || empty($row['title']) || empty($row['date_start'])) {
$errors[] = "data/veranstaltungen.json, Eintrag #{$i}: slug, title und date_start sind Pflicht.";
}
}
// --- Formular-Empfänger auf eigener Domain ---
// app/pages/datenschutz.php sagt zu, dass wir "keinen zusätzlichen
// E-Mail-Dienstleister" einsetzen. Das stimmt nur, solange jeder Empfänger ein
// Postfach auf unserer Domain bei All-Inkl ist. Zeigt club.json auf einen
// fremden Anbieter (die Turnabteilung lief früher auf Gmail), muss die
// Datenschutzerklärung mit — und das fällt von allein nie auf.
$clubMails = array_filter(
json_load('club'),
static fn ($v, string $k): bool => str_starts_with($k, 'email') && is_string($v) && $v !== '',
ARRAY_FILTER_USE_BOTH
);
foreach ($clubMails as $key => $address) {
if (!str_ends_with(strtolower((string) $address), '@tsv08kulmbach.de')) {
$warnings[] = "club.json → {$key} ({$address}) liegt nicht auf tsv08kulmbach.de."
. ' Formular-Mails an diese Adresse verlassen die All-Inkl-Auftragsverarbeitung —'
. ' dann muss app/pages/datenschutz.php den Anbieter nennen.';
}
}
// --- News (data/news.json) ---
// Meldungen mit `artikel` haben eine eigene Seite /news/<slug>. Die Fehler, die
// dabei still ins Nichts laufen, prüft die Maschine: ein Artikel ohne Slug ist
// nicht erreichbar, ein doppelter Slug verdeckt den zweiten Artikel, ein
// unbekannter Blocktyp verschluckt seinen Inhalt beim Rendern.
$newsRows = json_load('news')['news'] ?? [];
$newsSlugs = array_values(array_filter(array_column($newsRows, 'slug')));
$newsBlockTypen = ['text', 'zwischentitel', 'bild', 'zitat'];
if (count($newsSlugs) !== count(array_unique($newsSlugs))) {
$errors[] = 'data/news.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
}
// Kollision mit einem exakten Routen-Slug: der Prefix-Match für 'news/' läuft in
// public/index.php VOR dem routes.php-Lookup. Ein künftiger Eintrag wie
// 'news/archiv' wäre also von der Detailseite verdeckt, und die Ursache dafür
// sieht man an der weißen Seite nicht.
$routeSlugs = array_keys(require APP_PATH . '/routes.php');
$verdeckt = array_intersect(array_map(static fn (string $s): string => 'news/' . $s, $newsSlugs), $routeSlugs);
if ($verdeckt !== []) {
$errors[] = 'Diese News-Slugs verdecken einen Routen-Eintrag: ' . implode(', ', $verdeckt)
. ' — der Prefix-Match greift vor routes.php. Slug der Meldung ändern.';
}
$gleichnamig = array_intersect($newsSlugs, $veranstaltungSlugs);
if ($gleichnamig !== []) {
$warnings[] = 'Diese Slugs stehen in data/news.json UND data/veranstaltungen.json: '
. implode(', ', $gleichnamig) . ' — die URLs kollidieren nicht (verschiedene Präfixe), aber es sind'
. ' wahrscheinlich zwei Seiten für dieselbe Sache.';
}
foreach ($newsRows as $i => $row) {
if (empty($row['title']) || empty($row['date'])) {
$errors[] = "data/news.json, Eintrag #{$i}: title und date sind Pflicht.";
}
if (!empty($row['date']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $row['date'])) {
// Der Feed sortiert Datumsangaben als Strings und german_date() fällt
// still auf den Rohwert zurück — ein abweichendes Format sortiert also
// falsch, ohne dass etwas kaputt aussieht.
$errors[] = "data/news.json, Eintrag #{$i}: date muss YYYY-MM-DD sein.";
}
if (empty($row['artikel'])) {
continue; // Kurzmeldung ohne Detailseite — ein Slug ist hier erlaubt, aber nicht nötig.
}
if (empty($row['slug'])) {
$errors[] = "data/news.json, Eintrag #{$i}: mit artikel[] braucht die Meldung einen slug, sonst ist die Detailseite nicht erreichbar.";
} elseif (!preg_match('/^[a-z0-9-]+$/', (string) $row['slug'])) {
$errors[] = "data/news.json, Eintrag #{$i}: slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.";
}
foreach ($row['artikel'] as $b => $block) {
$typ = (string) ($block['typ'] ?? '');
if (!in_array($typ, $newsBlockTypen, true)) {
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: unbekannter typ '{$typ}' — erlaubt sind "
. implode(', ', $newsBlockTypen) . '. Der Block würde beim Rendern verschluckt.';
continue;
}
if ($typ === 'bild' && empty($block['alt'])) {
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: Bild ohne alt-Text.";
}
}
}
// --- Cron-Endpoint (public/cron.php) ---
// Nur relevant, wenn der Tarif keine Cronjobs hat und der Takt von außen kommt.
// Ohne brauchbaren Schlüssel ist der Endpoint zu (403) und nichts synct — das fällt
// sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
$cronKey = (string) config('cron.key', '');
$cronInterval = (int) config('cron.min_interval', 240);
if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
$warnings[] = 'cron.key ist nicht gesetzt — public/cron.php weist jeden Aufruf ab. Nur in Ordnung, wenn der Takt über echte Shell-Cronjobs läuft.';
} else {
if (strlen($cronKey) < 24) {
$errors[] = 'cron.key ist kürzer als 24 Zeichen — public/cron.php verweigert den Dienst. Erzeugen: php -r \'echo bin2hex(random_bytes(24)), "\n";\'';
}
if ($cronKey === (string) config('app_secret')) {
$errors[] = 'cron.key ist identisch mit app_secret — der Cron-Schlüssel liegt bei einem externen Dienst und darf nicht die Formular-Token aufschließen.';
}
if ($cronInterval >= 1800) {
$warnings[] = "cron.min_interval ist {$cronInterval} s — bei halbstündlichem Takt am Spieltag würde jeder zweite Aufruf verworfen.";
}
}
// --- Ausgabe ---
foreach ($warnings as $w) {
echo "WARNUNG {$w}\n";

78
bin/qr.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* QR-Code für DRUCKSACHEN erzeugen (nur CLI).
*
* php bin/qr.php <pfad|url> [ziel.png] [--scale=24]
*
* Beispiele:
* php bin/qr.php stadionzeitung/aktuell
* php bin/qr.php stadionzeitung/aktuell ~/Desktop/qr-plakat.png --scale=30
* php bin/qr.php https://tsv08kulmbach.de/stadionzeitung/aktuell
*
* Abgrenzung zu den QR-Codes der Stadionzeitung: die erzeugt
* bin/stadionzeitung-add.php selbst, klein und für die A5-Seiten
* (generate_qr_png() Default-Skalierung, gut 200px). Dieses Skript ist für
* Plakate und Aushänge, die in Canva zusammengebaut werden — dort braucht es
* ein Vielfaches an Pixeln, sonst franst der Code im Druck aus.
*
* DIE EIGENTLICHE AUFGABE DIESES SKRIPTS IST DIE WARNUNG: ein QR-Code enthält
* die URL fest eingebacken. Wird er gegen die Entwicklungs-Config erzeugt, steht
* `http://localhost:8000/…` darin und das gedruckte Plakat ist Altpapier. Ein
* interner Pfad läuft deshalb durch abs_url() (Single Source: config base_url),
* und wenn die nicht auf https zeigt, sagt das Skript es laut.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => !str_starts_with($a, '--')));
$scale = 24;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--scale=')) {
$scale = max(1, (int) substr($arg, 8));
}
}
if ($args === []) {
fwrite(STDERR, "Nutzung: php bin/qr.php <pfad|url> [ziel.png] [--scale=24]\n");
exit(1);
}
$target = $args[0];
$isAbsolute = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
$url = $isAbsolute ? $target : abs_url(trim($target, '/'));
// Zielpfad: ohne Angabe neben das Skript-Verzeichnis in storage/qr/ ablegen —
// Druckmaterial ist kein Website-Asset und hat unter public/ nichts zu suchen.
$dest = $args[1] ?? null;
if ($dest === null) {
$dir = STORAGE_PATH . '/qr';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$name = preg_replace('/[^a-z0-9]+/', '-', strtolower($isAbsolute ? (string) parse_url($url, PHP_URL_PATH) : $target)) ?? 'qr';
$dest = $dir . '/qr-' . trim($name, '-') . '.png';
}
if (!$isAbsolute && !str_starts_with((string) config('base_url'), 'https://')) {
fwrite(STDERR, "WARNUNG base_url ist '" . (string) config('base_url') . "' — dieser QR-Code führt NICHT auf die echte Seite\n");
fwrite(STDERR, " und ist als Druckvorlage unbrauchbar. Entweder die Produktions-Config verwenden\n");
fwrite(STDERR, " oder die vollständige URL direkt übergeben:\n");
fwrite(STDERR, " php bin/qr.php https://tsv08kulmbach.de/{$target}\n\n");
}
if (!generate_qr_png($url, $dest, $scale)) {
fwrite(STDERR, "FEHLER QR-Code konnte nicht erzeugt werden (vendor-manual/phpqrcode vorhanden?)\n");
exit(1);
}
[$w, $h] = getimagesize($dest) ?: [0, 0];
echo "QR-Code erzeugt\n";
echo " Ziel-URL : {$url}\n";
echo " Datei : {$dest}\n";
echo " Größe : {$w}x{$h} px (bei 300 dpi etwa " . round($w / 300 * 2.54, 1) . " cm)\n";

Some files were not shown because too many files have changed in this diff Show More