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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
app/components/detail-head.php
Normal file
64
app/components/detail-head.php
Normal 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>
|
||||
53
app/components/event-feature.php
Normal file
53
app/components/event-feature.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
app/components/hero-upcoming.php
Normal file
74
app/components/hero-upcoming.php
Normal 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>
|
||||
@@ -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): ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
41
app/components/lightbox-overlay.php
Normal file
41
app/components/lightbox-overlay.php
Normal 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>
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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): ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
app/components/news-artikel.php
Normal file
84
app/components/news-artikel.php
Normal 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>
|
||||
146
app/components/news-feed.php
Normal file
146
app/components/news-feed.php
Normal 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; ?>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] . '.',
|
||||
|
||||
47
app/components/stadionzeitung-cards.php
Normal file
47
app/components/stadionzeitung-cards.php
Normal 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>
|
||||
129
app/components/stadionzeitung-cover.php
Normal file
129
app/components/stadionzeitung-cover.php
Normal 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>
|
||||
98
app/components/stadionzeitung-druck.php
Normal file
98
app/components/stadionzeitung-druck.php
Normal 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>
|
||||
72
app/components/stadionzeitung-ergebnisse.php
Normal file
72
app/components/stadionzeitung-ergebnisse.php
Normal 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>
|
||||
58
app/components/stadionzeitung-historie.php
Normal file
58
app/components/stadionzeitung-historie.php
Normal 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>
|
||||
57
app/components/stadionzeitung-impressum.php
Normal file
57
app/components/stadionzeitung-impressum.php
Normal 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>
|
||||
69
app/components/stadionzeitung-interview.php
Normal file
69
app/components/stadionzeitung-interview.php
Normal 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>
|
||||
69
app/components/stadionzeitung-kontakt.php
Normal file
69
app/components/stadionzeitung-kontakt.php
Normal 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>
|
||||
43
app/components/stadionzeitung-mitglied.php
Normal file
43
app/components/stadionzeitung-mitglied.php
Normal 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>
|
||||
52
app/components/stadionzeitung-momente.php
Normal file
52
app/components/stadionzeitung-momente.php
Normal 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>
|
||||
90
app/components/stadionzeitung-news.php
Normal file
90
app/components/stadionzeitung-news.php
Normal 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>
|
||||
63
app/components/stadionzeitung-partner.php
Normal file
63
app/components/stadionzeitung-partner.php
Normal 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>
|
||||
91
app/components/stadionzeitung-seite.php
Normal file
91
app/components/stadionzeitung-seite.php
Normal 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; ?>
|
||||
41
app/components/stadionzeitung-sportheim.php
Normal file
41
app/components/stadionzeitung-sportheim.php
Normal 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>
|
||||
31
app/components/stadionzeitung-tabelle.php
Normal file
31
app/components/stadionzeitung-tabelle.php
Normal 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>
|
||||
90
app/components/stadionzeitung-termine.php
Normal file
90
app/components/stadionzeitung-termine.php
Normal 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>
|
||||
77
app/components/stadionzeitung-viewer.php
Normal file
77
app/components/stadionzeitung-viewer.php
Normal 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>
|
||||
50
app/components/stadionzeitung-vorschau.php
Normal file
50
app/components/stadionzeitung-vorschau.php
Normal 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>
|
||||
70
app/components/stadionzeitung-vorwort.php
Normal file
70
app/components/stadionzeitung-vorwort.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
44
app/components/termine-list.php
Normal file
44
app/components/termine-list.php
Normal 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; ?>
|
||||
@@ -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; ?>
|
||||
|
||||
52
app/components/veranstaltung-galerie.php
Normal file
52
app/components/veranstaltung-galerie.php
Normal 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>
|
||||
93
app/components/veranstaltung-info.php
Normal file
93
app/components/veranstaltung-info.php
Normal 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>
|
||||
55
app/components/veranstaltung-plakate.php
Normal file
55
app/components/veranstaltung-plakate.php
Normal 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>
|
||||
66
app/components/veranstaltung-programm.php
Normal file
66
app/components/veranstaltung-programm.php
Normal 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>
|
||||
68
app/components/veranstaltungen-cards.php
Normal file
68
app/components/veranstaltungen-cards.php
Normal 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; ?>
|
||||
Reference in New Issue
Block a user