Files
fs a101b0c3ec Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00

147 lines
7.3 KiB
PHP

<?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; ?>