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

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,26 @@ $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; ?>
<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; ?>
</div>
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
<?php else: ?>
<?php if (!empty($hero['kicker'])): ?>
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
<?php endif; ?>
@@ -41,6 +69,7 @@ $display = !empty($hero['display']);
<?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,19 +43,50 @@ if (!empty($names)) {
if ($persons === []) {
return;
}
?>
if (!empty($embedded)): ?>
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
<h2><?= e($title) ?></h2>
<ul class="persons__grid">
<?php foreach ($persons as $person): ?>
<li>
<?php component('person-card', ['person' => $person]); ?>
</li>
<?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; ?>
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>">
<?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 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],