diff --git a/CLAUDE.md b/CLAUDE.md
index b866950..eb9e6eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,10 +54,10 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Partner/Sponsoren | `data/partners.json` | — |
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
| Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) |
-| Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort` und `interview`.** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt |
-| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv` und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen |
+| Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`) und `cover` (`teaser` einer Sonderausgabe).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt |
+| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen |
| Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) |
-| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) |
+| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) |
| News (`/news` + optionale Detailseiten) | `data/news.json` (redaktionelle Meldungen; `artikel[]` = Fließtext-Blöcke, `status` = Entwurf/veröffentlicht) — Instagram-Posts und Ergebnisse kommen weiterhin nur aus `data/instagram.json`/`data/matchcenter.json`, werden auf der News-Seite nur gemergt | Von Claude im Chat gepflegt wie `events.json`/`teams.json` — **keine** Automatisierung, kein Bin-Skript. Instagram/Ergebnisse nie hier duplizieren. `text` bleibt der Teaser (Feed, Meta-Description, Article-Schema), der Artikel steht in `artikel[]` |
| Dateiablage `/dateien` (Konfiguration) | `public/dateien/_filesconfig.php` (Rechte pro Konto: `storage/dateien/system/users/admin/config.php`) | **`storage/dateien/system/config/config.php` von Hand editieren** — die App generiert sie selbst und überschreibt sie bei jedem Update. Passwörter/Lizenz gehören in `config.php` (`dateien.*`) |
| Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
@@ -516,18 +516,23 @@ kein Download-Link — nur die Web-Ansicht. Der Ablauf pro Heimspiel ist damit:
**Feste Vorlage** (seit Interview 29.07.2026, Anzeigen-Verteilung überarbeitet 31.07.2026) — der
Inhalt ist eine Liste von **Blöcken**, die Anzeigen füllen die Lücken dazwischen (⌷ = Lücke):
```
-Cover → [Vorwort → Interview (nur wenn die Ausgabe eins trägt)] ⌷ Ergebnis-Rückblick ⌷
- Gegnerstatistik der tragenden Mannschaft (automatisch aus deren Tabelle) ⌷
+Cover → [Vorwort → Programm* → Interview → Aufruf (jedes nur wenn die Ausgabe es trägt)] ⌷
+ [Turnierpläne*: je Turnier eine Seite, EIN Block] ⌷ Ergebnis-Rückblick ⌷
+ [Gegnerstatistik: normal nur die tragende Mannschaft, in der Sportfest-Ausgabe je
+ Mannschaft mit Heimspiel am Fest eine — EIN Block, automatisch aus deren Tabelle] ⌷
[je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine
Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten
- Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) ⌷
+ Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) →
+ Danke-Seite (Partner-Logo-Wand + QR auf /partner-werden, ohne Lücke davor: Akquise und Dank
+ gehören zusammen) ⌷
Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim
Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite
```
Alle Seiten außer Rückseite und Vorwort-Fallback sind Live-Seiten. Nach dem Cover, im Aufschlag
-und vor der Rückseite steht **keine** Lücke (Begründung unten).
+und vor der Rückseite steht **keine** Lücke (Begründung unten). \* = nur Sonderausgabe, siehe
+„Sportfest-Ausgabe" unten.
**Doppelseiten-Regel fürs Falt-Heft:** aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander
(gerade Seite links). Die erste Tabellenseite muss deshalb auf einer GERADEN Seitenzahl liegen, damit
die Tabellen nebeneinander stehen. **Das rechnet das Skript selbst** (seit 31.07.2026): es verschiebt
@@ -548,12 +553,30 @@ Lücken bekommen 7 Lücken zwei. Zwei hintereinander ist das Optimum, nicht eins
Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf.
Ein `images[]`-Eintrag hat
`"type": "image"` (wie gehabt, `base`/`widths`/`alt`) oder `"type": "live"` (`page`: `cover` |
-`vorwort` | `interview` | `aufruf` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `momente` | `impressum`, kein Bild;
-`tabelle` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der Team-Key
-der Ausgabe). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
+`vorwort` | `interview` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `impressum`, kein Bild;
+`tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der
+Team-Key der Ausgabe; `turnier` mit `turnier`: Key aus `turniere[]` der Ausgaben-Veranstaltung;
+`danke` mit `anzeige`: das platzierte Anzeigenmotiv, siehe unten). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
wird jede Seite (Bild wie Live) über `app/components/stadionzeitung-seite.php`** — die eine
gemeinsame Render-Stelle für Viewer und Druckfassung, nie zwei Implementierungen derselben Seite.
+**Danke-Seite + platzierte Anzeigen (03.09.2026).** Der Dank an die Partner war bis dahin unten an
+ein Anzeigen-Bild geklebt (ein Canva-Motiv, das Sponsorenanzeige und Dank in einer Datei hatte) und
+bestand aus Wappen, Titel und einer Zeile auf leerer Fläche. Jetzt ist es eine Live-Seite
+(`stadionzeitung-danke.php`): die **Logo-Wand aus `data/partners.json`** (Single Source, dieselbe
+Quelle wie die Startseite), Kopf und der QR auf `/partner-werden`. Dazu ein neues, generisches Feld
+im Anzeigenbestand: **`platzierung`** nimmt eine Anzeige aus dem Streu-Pool und gibt ihr einen festen
+Platz auf einer Live-Seite (derzeit `danke`, dort sitzt die Emons-Anzeige über der Logo-Wand).
+- **Das Motiv steht im Seiteneintrag, nicht in der Komponente**: `bin/stadionzeitung-add.php` schreibt
+ es als `anzeige` in den `images[]`-Eintrag. Dadurch ist es eingefroren wie jede andere Anzeige, und
+ eine veröffentlichte Ausgabe ändert sich nicht, wenn der Sponsor später ein neues Motiv liefert.
+- **Die Seitenzahl bleibt gleich**: eine Anzeige verlässt den Pool, eine Live-Seite kommt dazu.
+- Das Kombi-Motiv wurde an seiner roten Trennlinie zerschnitten, damit die Sponsorenanzeige
+ vollständig bleibt und der Dank nicht zweimal im Heft steht.
+- **Falle in `bin/anzeige-add.php`** (dabei gefunden und behoben): das Aufräum-Muster lautete
+ `{$slug}-*.jpg` und traf damit jeden Slug, der den neuen als Präfix hat — `emons` löschte die
+ Motive von `emons-partnerdank`. Jetzt `{$slug}-[0-9]*.jpg`.
+
**Entwurf / Veröffentlicht (`status`-Feld):** `bin/stadionzeitung-add.php` legt jede Ausgabe als
`entwurf` an — unsichtbar in Archiv und Sitemap, `noindex`, aber unter ihrer direkten URL erreichbar
(zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen
@@ -635,6 +658,73 @@ Fragen/Antworten, Claude trägt sie ins `interview`-Feld der Ausgabe ein (`tease
Live-Seite nur ein, wenn das Feld existiert — Ausgaben ohne Interview haben weder Seite noch
Teaser-Zeile. Fragen in Abel fett, Antworten in `--ff-serif` (dieselbe Begründung wie beim Vorwort).
+**Sportfest-Ausgabe (03.09.2026): ein Slug-Verweis schaltet drei Seitentypen zu.** Trägt eine Ausgabe
+das chat-gepflegte Feld `veranstaltung` (Slug aus `data/veranstaltungen.json`), kommen dazu: die
+**Programm-Seite** (`stadionzeitung-programm.php`, aus `programm[]` der Veranstaltung), ein Block
+**Turnierplan-Seiten** (`stadionzeitung-turnier.php`, je Eintrag in `turniere[]` eine Seite) und eine
+**Gegnerseite je Mannschaft mit Heimspiel im Zeitraum** der Veranstaltung statt nur für die tragende.
+Dazu trägt `cover.teaser` die Coverzeile „Im Heft"; das Feld überlebt erneute Skript-Läufe wie
+`vorwort`. **`stadionzeitung_teaser()`** ist die eine Quelle dieser Zeile für Cover **und**
+Archivkarte: `cover.teaser` vor `interview.teaser` — eine Sonderausgabe ohne Interview hat sonst keine.
+**Das Cover einer Sonderausgabe zeigt das FEST, nicht die Begegnung** (Felix, 03.09.2026): Wappen
+und „Heimspiel gegen …" entfallen, an ihrer Stelle steht der Veranstaltungstitel in der
+Display-Schrift plus Zeitraum (Quelle: `veranstaltungen.json`, nie neu getippt). Grund: ein Fest
+bringt mehrere Heimspiele mit, eines davon aufs Cover zu heben wäre schlicht falsch. Ausgelöst wird
+das vom `veranstaltung`-Feld, nicht von einem eigenen Schalter.
+**Statt des Spielerfotos steht das HAUPTPLAKAT der Veranstaltung rechts** (erstes Plakat aus
+`veranstaltungen.json`, dieselbe Quelle wie die Detailseite, nie eine Kopie): leicht schräg gelegt
+(4 Grad) und an zwei gegenüberliegenden Ecken gerundet, links oben und rechts unten, mit Schatten
+statt Rahmen — wie ein aufs Cover gelegter Aushang (Felix, 03.09.2026). Es ist mit 66cqi deutlich
+kleiner als das Spielerfoto (98cqi): ein Freisteller ist schmal, ein Plakat im Hochformat läge bei
+derselben Höhe über der Typografie unten links. Die Textspalte wird dafür auf 48cqi verengt.
+Ohne Plakat greift das Spielerfoto, ohne beides trägt der Hintergrund die Seite. `box-shadow` ist
+hier unbedenklich; die Druck-Falle des Projekts betrifft `text-shadow` auf umbrechendem Text.
+
+**Sonst bleibt das Cover unverändert, und das ist eine Entscheidung:** eine rote Kennung
+„Sportfest-Ausgabe" als Kasten im Kopf und ein kräftiger roter Doppelrahmen waren gebaut und wurden
+am 03.09.2026 wieder entfernt (Felix: „sieht schrecklich aus"). Der Rahmen lief auf z2 quer über das
+freigestellte Spielerfoto, und der Kasten stach aus dem ruhigen Kopf heraus. Die Ausgabe kennzeichnet
+sich durch den großen Fest-Titel unten links ausreichend selbst. **Kein zweites Rot in den Kopf, und
+keinen farbigen Rahmen über das Spielerfoto** — der Kicker bleibt „Saison · Datum" wie überall.
+- **Die Daten liegen in `veranstaltungen.json`, nicht in der Ausgabe** — eine Quelle für Website und
+ Heft: dasselbe `programm[]` rendert `veranstaltung-programm.php` auf `/veranstaltungen/` und
+ `stadionzeitung-programm.php` auf dem A5-Blatt (zwei Darstellungen, nie zwei Datenstände).
+ `turniere[]` ist bisher **nur** Heft-Inhalt (Website-Darstellung offen), Feldform:
+ `{key, title, date, start, spieldauer, felder?, modus?, teams[], spiele[]{nr, zeit, feld?, heim, gast}}`.
+- **`stadionzeitung_gegner_fixtures()`** (`helpers.php`) ist die EINZIGE Stelle, die entscheidet,
+ welche Mannschaft eine Gegnerseite bekommt. `bin/stadionzeitung-add.php` plant die Seiten damit,
+ `stadionzeitung_build_snapshot()` baut die Daten damit — sonst entstünde eine Seite ohne Daten oder
+ umgekehrt. `gegner` ist deshalb eine **Map je Team-Key** (wie `tabellen`) statt eines einzelnen
+ Objekts; Ausgaben ohne `team` am Seiteneintrag fallen auf die tragende Mannschaft zurück. Ein
+ eingefrorener Snapshot mit der alten Ein-Objekt-Form existiert nicht (die einzige veröffentlichte
+ Ausgabe ist älter als die Gegnerseite und trägt dort `null`), deshalb gibt es dafür bewusst
+ **keinen** Kompatibilitäts-Zweig.
+- **Die Vorschau überspringt jedes Spiel mit eigener Gegnerseite**, nicht mehr nur das Heftspiel:
+ Zweite und Frauen stünden sonst zweimal im Heft. Sie zeigt dann die Spiele nach dem Fest.
+- **Programm und Turnierpläne sind live, ohne Snapshot** (wie Kontakt/Aufruf): chat-gepflegte Daten,
+ die kein Sync wegräumt. Eine spätere Korrektur am Programm ändert damit auch eine archivierte
+ Ausgabe — bewusst in Kauf genommen, dieselbe Abwägung wie bei der Kontakt-Seite.
+- **Die Turnierseite ist zum Beschreiben:** Spielplan mit LEERER Ergebnisspalte (Schreiblinien) und
+ leere Abschlusstabelle. Die Zeilenhöhe macht dort die **Schreiblinie**, nicht die Schriftgröße
+ (gemessen: eine kleinere Schrift brachte genau einen Pixel) — wer die E-Jugend-Seite mit zehn
+ Spielen aufs Blatt bringen muss, geht an `.stadionzeitung-turnier--dicht .__blank`. Die
+ Programm-Seite läuft aus demselben Grund mit straffen Abständen: zwölf Punkte plus drei Tagestitel
+ liefen mit den üblichen 3.3cqi 59px über die Blattkante. **Nach jeder Änderung Seitenhöhe messen.**
+- **Beim Messen die scrollbaren Seiten mitnehmen.** Vorwort, Termine und Sportheim tragen
+ `overflow-y: auto`: dort wächst nicht die Seite, sondern das Kind wird scrollbar — eine Messung
+ von `scrollHeight - clientHeight` auf `.stadionzeitung-page` meldet 0, und im Druck fehlt trotzdem
+ das Ende des Textes. Genau so fiel ein sechsabsätziges Vorwort erst in der Druckvorschau auf
+ (03.09.2026, seitdem vier Absätze). Verlässlich ist: Unterkante der Signatur gegen die Blattkante
+ prüfen, im Viewer **und** in `?druck=1`.
+- **Seitenzahl aufs Vielfache von 4 bringen, ohne Inhalt zu erfinden:** eine zeitgebundene
+ Eigenanzeige lässt sich per `bis` in `data/anzeigen.json` für diese Ausgabe abschalten (der Filter
+ vergleicht mit dem Ausgabedatum). So kam die Sportfest-Ausgabe von 49 auf 48 Seiten: das
+ Programmplakat als Anzeige wurde abgelöst, weil die gesetzte Programmseite es ersetzt.
+- **Ablauf ist zweistufig** (wie beim Vorwort): erst `bin/stadionzeitung-add.php` laufen lassen, dann
+ `veranstaltung`/`cover` in die Ausgabe eintragen, dann **erneut** laufen lassen. Vorher existiert
+ die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder
+ Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler.
+
**Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite —
`app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein**
Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das
diff --git a/app/components/stadionzeitung-cards.php b/app/components/stadionzeitung-cards.php
index 4f616fa..215fa17 100644
--- a/app/components/stadionzeitung-cards.php
+++ b/app/components/stadionzeitung-cards.php
@@ -36,11 +36,12 @@ $issues = $issues ?? [];
= e(german_date($issue['date'])) ?>
· = (int) $issue['pages'] ?> Seiten
-
+
= e($kartenTeaser) ?>
diff --git a/app/components/stadionzeitung-cover.php b/app/components/stadionzeitung-cover.php
index e750af6..ffd2c14 100644
--- a/app/components/stadionzeitung-cover.php
+++ b/app/components/stadionzeitung-cover.php
@@ -69,18 +69,64 @@ if ($match !== null) {
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
$linkText = club_domain();
-$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : ''));
+// Aufmacher unten links: normal die Begegnung (Wappen + „Heimspiel gegen …").
+// Zeigt die Ausgabe auf eine Veranstaltung (Sportfest-Ausgabe), tritt an deren
+// Stelle das FEST als Titelgeschichte — denn ein Fest bringt mehrere Heimspiele
+// mit (Erste, Zweite, Frauen), und ein einzelnes davon aufs Cover zu heben wäre
+// falsch (Felix, 03.09.2026). Titel und Zeitraum kommen aus
+// data/veranstaltungen.json, nie neu getippt.
+$feature = null;
+$featureV = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
+if ($featureV !== null) {
+ $feature = [
+ 'title' => trim((string) ($featureV['title'] ?? '')),
+ 'range' => german_date_range((string) ($featureV['date_start'] ?? ''), $featureV['date_end'] ?? null),
+ ];
+}
+
+// Bildmotiv rechts: normal das freigestellte Spielerfoto. Eine Sonderausgabe mit
+// Veranstaltung zeigt stattdessen deren HAUPTPLAKAT (erstes Plakat, dieselbe
+// Quelle wie /veranstaltungen/, nie eine Kopie) — leicht schräg gelegt mit
+// zwei abgerundeten Ecken, wie ein aufs Cover gelegter Aushang (Felix,
+// 03.09.2026). Ohne Plakat bleibt es beim Spielerfoto, ohne beides trägt der
+// Hintergrund die Seite allein.
+$poster = null;
+if ($featureV !== null) {
+ $ersteseite = $featureV['plakate'][0] ?? null;
+ if (is_array($ersteseite) && !empty($ersteseite['base'])) {
+ $poster = $ersteseite;
+ }
+}
+
+// Kicker: Saison · Datum, auf jedem Cover gleich. Eine Sonderausgabe bekommt
+// bewusst KEINE zusätzliche Kennung im Kopf: ein roter Kasten „Sportfest-Ausgabe"
+// war gebaut und am 03.09.2026 wieder entfernt (Felix) — er stach aus dem
+// ruhigen Kopf heraus, und die Ausgabe kennzeichnet sich unten links ohnehin
+// selbst durch den großen Fest-Titel.
+$kicker = implode(' · ', array_filter([
+ $season,
+ $date !== '' ? german_date($date) : '',
+], static fn (string $s): bool => $s !== ''));
// 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'] ?? ''));
+// aus stadionzeitung_teaser(): `cover.teaser` einer Sonderausgabe, sonst die
+// Coverzeile des Interviews. Dieselbe Funktion speist die Archivkarte.
+$teaser = stadionzeitung_teaser($issue);
?>
-
+
$heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
-
+
+
+ ['base' => $poster['base'], 'widths' => $poster['widths'] ?? [], 'alt' => ''],
+ 'class' => 'stadionzeitung-cover__poster',
+ 'sizes' => '(max-width: 600px) 55vw, 260px',
+ ]); ?>
+
Rund ums Heimspiel im Katzbachtal
+
+
+
+
= e($feature['title']) ?>
+
+
= e($feature['range']) ?>
+
+
+
@@ -124,6 +181,7 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?>
+
Im Heft
diff --git a/app/components/stadionzeitung-danke.php b/app/components/stadionzeitung-danke.php
new file mode 100644
index 0000000..741591c
--- /dev/null
+++ b/app/components/stadionzeitung-danke.php
@@ -0,0 +1,65 @@
+
+
+
+ $anzeige, 'sizes' => '420px', 'class' => 'stadionzeitung-danke__anzeige']); ?>
+
+
+ Unsere Partner
+
+ Danke für die Unterstützung
+
+
+
+
+
+
+ loading="lazy" decoding="async">
+
+
+
+
+
+
+
Mitmachen
+
Werden Sie unser Partner
+
= e(club_domain('partner-werden')) ?>
+
+
+
+
+
Scan mich
+
+
+
+
diff --git a/app/components/stadionzeitung-gegner.php b/app/components/stadionzeitung-gegner.php
index 9a206d3..a211bb5 100644
--- a/app/components/stadionzeitung-gegner.php
+++ b/app/components/stadionzeitung-gegner.php
@@ -7,9 +7,14 @@ declare(strict_types=1);
* aufgebauten Stadionzeitungs-Snapshot; die Komponente rendert nur. Dadurch
* sind Entwurf und veröffentlichte Druckausgabe konsistent und archivfest.
*
- * Props: $info stadionzeitung_live_data($issue)['gegner']
+ * Props: $info stadionzeitung_live_data($issue)['gegner'][team] — ein Eintrag der
+ * Map je Team-Key (team_name/opponent/league/season/fixture/own/
+ * opponent_stats). Normal nur die tragende Mannschaft; in der
+ * Sportfest-Ausgabe eine Seite je Heimspiel am Fest, deshalb
+ * nennt der Kicker die Mannschaft.
*/
$info = is_array($info ?? null) ? $info : [];
+$teamName = trim((string) ($info['team_name'] ?? ''));
$fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : [];
$own = is_array($info['own'] ?? null) ? $info['own'] : null;
$opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null;
@@ -39,7 +44,7 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
?>
- Unser Gegner
+ = e($teamName !== '' ? 'Unser Gegner · ' . $teamName : 'Unser Gegner') ?>
= e($opponentName) ?>
= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?>
@@ -70,9 +75,12 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
- TSV 08
+
+ Wir
Vergleich
- ASV
+ Gegner
diff --git a/app/components/stadionzeitung-programm.php b/app/components/stadionzeitung-programm.php
new file mode 100644
index 0000000..95367be
--- /dev/null
+++ b/app/components/stadionzeitung-programm.php
@@ -0,0 +1,71 @@
+
+
+
+ = e((string) ($v['title'] ?? 'Veranstaltung')) ?>
+ Programm
+
+ = e($sub) ?>
+
+
+
+
+
+
+
+ = e($tagText) ?>
+
+
+
+
+
+
+ = e($zeit) ?>
+
+
+
+
+ = e((string) ($punkt['titel'] ?? '')) ?>
+
+ = e((string) $punkt['text']) ?>
+
+
+
+
+
+
+
+
diff --git a/app/components/stadionzeitung-seite.php b/app/components/stadionzeitung-seite.php
index 6638955..e3fa035 100644
--- a/app/components/stadionzeitung-seite.php
+++ b/app/components/stadionzeitung-seite.php
@@ -9,9 +9,12 @@ declare(strict_types=1);
* anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
* $entry Eintrag aus issue.images[]:
* type 'image' (base/widths/alt),
- * type 'live' (page: cover|vorwort|interview|aufruf|tabelle|termine|
- * news|ergebnisse|gegner|vorschau|kontakt; tabelle optional
- * mit team: Team-Key aus matchcenter.json),
+ * type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier|
+ * tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt|
+ * mitglied|historie|sportheim|partner|danke|momente|impressum;
+ * tabelle und gegner optional mit team: Team-Key aus
+ * matchcenter.json; turnier mit turnier: Key aus
+ * veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung),
* type 'leer' (weiße Füllseite, nur Druckfassung — hält die
* Seitenzahl auf einem Vielfachen von 4)
* $issue Eintrag aus data/stadionzeitung.json
@@ -56,7 +59,19 @@ $type = $entry['type'] ?? 'image';
$data['ergebnisse'] ?? []]); ?>
- $data['gegner'] ?? null]); ?>
+ $data['gegner'][$gegnerTeam] ?? null]);
+?>
+
+
+ $issue]); ?>
+
+
+ $issue, 'turnier' => (string) ($entry['turnier'] ?? '')]); ?>
$data['vorschau'] ?? []]); ?>
@@ -76,6 +91,11 @@ $type = $entry['type'] ?? 'image';
+
+
+ $entry['anzeige'] ?? null]); ?>
+
$data['momente'] ?? []]); ?>
diff --git a/app/components/stadionzeitung-turnier.php b/app/components/stadionzeitung-turnier.php
new file mode 100644
index 0000000..91e44bc
--- /dev/null
+++ b/app/components/stadionzeitung-turnier.php
@@ -0,0 +1,158 @@
+ $clubName !== ''
+ && str_starts_with(mb_strtolower(trim($team)), mb_strtolower($clubName));
+
+$date = (string) ($t['date'] ?? '');
+$weekday = $date !== '' ? german_weekday($date) : '';
+$kicker = trim(implode(' · ', array_filter([
+ (string) ($v['title'] ?? ''),
+ trim(($weekday !== '' ? $weekday . ', ' : '') . ($date !== '' ? german_date($date) : '')),
+])));
+
+$teams = array_values(array_filter(
+ array_map(static fn ($x): string => trim((string) $x), $t['teams'] ?? []),
+ static fn (string $x): bool => $x !== ''
+));
+$spiele = array_values(array_filter($t['spiele'] ?? [], 'is_array'));
+$felder = (int) ($t['felder'] ?? 0);
+$hatFeld = $felder > 1;
+
+$meta = [];
+if (!empty($t['start'])) {
+ $meta[] = 'Beginn ' . (string) $t['start'] . ' Uhr';
+}
+if (!empty($t['spieldauer'])) {
+ $meta[] = 'Spieldauer ' . (int) $t['spieldauer'] . ' Min.';
+}
+if ($hatFeld) {
+ $meta[] = $felder . ' Felder';
+}
+if (!empty($t['modus'])) {
+ $meta[] = (string) $t['modus'];
+}
+$sub = trim(implode(' · ', $meta));
+$title = trim((string) ($t['title'] ?? $turnierKey));
+// Ab neun Spielen (E-Jugend: zehn) rücken die Zeilen enger, sonst läuft der
+// Spielplan mit der Abschlusstabelle über die Blattkante.
+$dicht = count($spiele) > 8;
+?>
+
+
+
+ = e($kicker) ?>
+
+ = e('Turnier ' . $title) ?>
+
+ = e($sub) ?>
+
+
+
+
+
Teilnehmer
+
+ $team): ?>
+= $i > 0 ? ' · ' : '' ?>= e($team) ?>
+
+
+
+
+
+
+ Spielplan
+
+
+ Nr.
+ Zeit
+
+ Feld
+
+ Begegnung
+ Ergebnis
+
+
+
+ $spiel): ?>
+
+
+ = e((string) ($spiel['nr'] ?? ($i + 1))) ?>
+ = e($zeit) ?>
+
+ = e((string) ($spiel['feld'] ?? '')) ?>
+
+ = e($heim) ?> gegen = e($gast) ?>
+ :
+
+
+
+
+
+
+
+ Platzierung zum Eintragen
+
+
+ Pl.
+ Teilnehmer
+ T
+ TD
+ Pkt
+
+
+
+
+
+ = $i ?>.
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/stadionzeitung-viewer.php b/app/components/stadionzeitung-viewer.php
index 3f92d7e..df4a7b1 100644
--- a/app/components/stadionzeitung-viewer.php
+++ b/app/components/stadionzeitung-viewer.php
@@ -5,7 +5,7 @@ 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]}})
+ * images[]{type: 'image'{base,widths,alt} | 'live'{page[,team][,turnier]}})
*
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
* voll lesbar, sequentiell auch per Screenreader. Mit JS
diff --git a/app/helpers.php b/app/helpers.php
index 169ed88..d404e07 100644
--- a/app/helpers.php
+++ b/app/helpers.php
@@ -568,6 +568,27 @@ function veranstaltungen_load(): array
return $items;
}
+/**
+ * Eine Veranstaltung per Slug, UNGEFILTERT (auch Entwürfe) — für die
+ * Detailseite (app/pages/veranstaltung.php) und die Stadionzeitungs-Live-Seiten
+ * der Sportfest-Ausgabe (Programm, Turnierpläne, Gegnerseiten je Heimspiel).
+ * Ungefiltert aus demselben Grund wie die Detailseite: ein Entwurf muss sich
+ * gegenlesen lassen, und eine Entwurfs-Ausgabe der Stadionzeitung darf auf
+ * einen Entwurfs-Termin zeigen. null, wenn es den Slug nicht gibt.
+ */
+function veranstaltung_find(string $slug): ?array
+{
+ if ($slug === '') {
+ return null;
+ }
+ foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
+ if (($entry['slug'] ?? '') === $slug) {
+ return $entry;
+ }
+ }
+ return null;
+}
+
/**
* Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der
* Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
@@ -955,6 +976,86 @@ function stadionzeitung_is_published(array $issue): bool
return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
+/**
+ * Coverzeile „Im Heft" einer Ausgabe — die EINE Quelle für das Cover
+ * (stadionzeitung-cover.php) und die Archivkarte (stadionzeitung-cards.php),
+ * damit beide nie Verschiedenes sagen. Eine Sonderausgabe (Sportfest) setzt
+ * `cover.teaser` chat-gepflegt; sonst trägt die Coverzeile des Interviews
+ * (`interview.teaser`). Leer, wenn es weder das eine noch das andere gibt.
+ */
+function stadionzeitung_teaser(array $issue): string
+{
+ $teaser = trim((string) ($issue['cover']['teaser'] ?? ''));
+ if ($teaser !== '') {
+ return $teaser;
+ }
+ return trim((string) ($issue['interview']['teaser'] ?? ''));
+}
+
+/**
+ * Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE
+ * Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND
+ * stadionzeitung_build_snapshot() (Daten), damit nie eine Seite ohne Daten
+ * oder Daten ohne Seite entstehen.
+ *
+ * Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`).
+ * Zusätzlich, wenn die Ausgabe eine `veranstaltung` (Slug) mit Zeitraum trägt
+ * (Sportfest-Ausgabe): jede weitere Mannschaft aus matchcenter.json mit einem
+ * HEIMspiel in diesem Zeitraum — drei Heimspiele am Fest, drei Gegnerseiten.
+ * Der Gegner ist dann der andere Verein der Begegnung. Reihenfolge wie in
+ * matchcenter.json (= config 'matchcenter.teams').
+ *
+ * Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']]
+ */
+function stadionzeitung_gegner_fixtures(array $issue): array
+{
+ $date = (string) ($issue['date'] ?? '');
+ $issueTeam = (string) ($issue['team_key'] ?? '');
+ $issueOpponent = trim((string) ($issue['opponent'] ?? ''));
+
+ $von = '';
+ $bis = '';
+ $veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
+ if ($veranstaltung !== null) {
+ $von = (string) ($veranstaltung['date_start'] ?? '');
+ $bis = (string) ($veranstaltung['date_end'] ?? $von);
+ }
+
+ $fixtures = [];
+ foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
+ $key = (string) ($team['key'] ?? '');
+ if ($key === '') {
+ continue;
+ }
+ if ($key === $issueTeam) {
+ if ($issueOpponent !== '') {
+ $fixtures[$key] = ['opponent' => $issueOpponent, 'date' => $date];
+ }
+ continue;
+ }
+ if ($von === '') {
+ continue;
+ }
+ foreach ($team['next_matches'] ?? [] as $match) {
+ if (empty($match['is_home'])) {
+ continue;
+ }
+ $matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
+ if ($matchDate < $von || $matchDate > $bis) {
+ continue;
+ }
+ $opponent = trim((string) ($match['away'] ?? ''));
+ if ($opponent === '' || $opponent === 'SPIELFREI') {
+ continue;
+ }
+ $fixtures[$key] = ['opponent' => $opponent, 'date' => $matchDate];
+ break;
+ }
+ }
+
+ return $fixtures;
+}
+
/**
* Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) — für die
* Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
@@ -988,12 +1089,15 @@ function stadionzeitung_build_snapshot(array $issue): array
{
$date = (string) ($issue['date'] ?? '');
$issueTeam = (string) ($issue['team_key'] ?? '');
- $issueOpponent = trim((string) ($issue['opponent'] ?? ''));
$tabellen = [];
$ergebnisse = [];
$vorschau = [];
- $gegner = null;
+ // Gegnerseiten als Map je Team-Key (wie `tabellen`): welche Mannschaften
+ // eine bekommen, entscheidet stadionzeitung_gegner_fixtures() — dieselbe
+ // Funktion, mit der bin/stadionzeitung-add.php die Seiten einplant.
+ $gegnerFixtures = stadionzeitung_gegner_fixtures($issue);
+ $gegner = [];
foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
$key = (string) ($team['key'] ?? '');
if ($key === '') {
@@ -1006,11 +1110,14 @@ function stadionzeitung_build_snapshot(array $issue): array
'table' => $team['table'] ?? [],
];
- // Gegnerseite der tragenden Mannschaft: ausschließlich aus demselben
- // Matchcenter-Datensatz wie die Tabelle ableiten. So gibt es keinen
- // zweiten BFV-Abruf und der Stand wird beim Veröffentlichen zusammen
- // mit den übrigen Live-Daten eingefroren.
- if ($key === $issueTeam && $issueOpponent !== '') {
+ // Gegnerseite(n): ausschließlich aus demselben Matchcenter-Datensatz
+ // wie die Tabelle ableiten. So gibt es keinen zweiten BFV-Abruf und der
+ // Stand wird beim Veröffentlichen zusammen mit den übrigen Live-Daten
+ // eingefroren. Normal nur die tragende Mannschaft; in der Sportfest-
+ // Ausgabe jede Mannschaft mit Heimspiel am Fest (s. gegner_fixtures).
+ if (isset($gegnerFixtures[$key])) {
+ $gegnerName = $gegnerFixtures[$key]['opponent'];
+ $gegnerDate = $gegnerFixtures[$key]['date'];
$table = is_array($team['table'] ?? null) ? $team['table'] : [];
$ownRow = null;
$opponentRow = null;
@@ -1018,7 +1125,7 @@ function stadionzeitung_build_snapshot(array $issue): array
if (!empty($row['is_own_club'])) {
$ownRow = $row;
}
- if (strcasecmp(trim((string) ($row['club'] ?? '')), $issueOpponent) === 0) {
+ if (strcasecmp(trim((string) ($row['club'] ?? '')), $gegnerName) === 0) {
$opponentRow = $row;
}
}
@@ -1027,15 +1134,15 @@ function stadionzeitung_build_snapshot(array $issue): array
foreach ($team['next_matches'] ?? [] as $match) {
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
$clubs = [(string) ($match['home'] ?? ''), (string) ($match['away'] ?? '')];
- if ($matchDate === $date && in_array($issueOpponent, $clubs, true)) {
+ if ($matchDate === $gegnerDate && in_array($gegnerName, $clubs, true)) {
$fixture = $match;
break;
}
}
- $gegner = [
+ $gegner[$key] = [
'team_name' => (string) ($team['name'] ?? ''),
- 'opponent' => $issueOpponent,
+ 'opponent' => $gegnerName,
'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''),
'fixture' => $fixture,
@@ -1064,7 +1171,11 @@ function stadionzeitung_build_snapshot(array $issue): array
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
// Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es
- // schon); ein Spiel einer ANDEREN Mannschaft am selben Tag bleibt drin.
+ // schon), und ebenso jedes Spiel, das eine eigene Gegnerseite hat
+ // (Sportfest-Ausgabe: Zweite und Frauen stünden sonst doppelt im Heft).
+ // Ein Spiel einer ANDEREN Mannschaft am selben Tag ohne Gegnerseite
+ // bleibt drin.
+ $gegnerDate = $gegnerFixtures[$key]['date'] ?? null;
foreach ($team['next_matches'] ?? [] as $match) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
@@ -1078,6 +1189,9 @@ function stadionzeitung_build_snapshot(array $issue): array
if ($matchDate === $date && $key === $issueTeam) {
continue;
}
+ if ($gegnerDate !== null && $matchDate === $gegnerDate) {
+ continue;
+ }
$vorschau[] = [
'team_key' => $key,
'team_name' => (string) ($team['name'] ?? ''),
diff --git a/app/pages/veranstaltung.php b/app/pages/veranstaltung.php
index 94e44e8..69d6bf7 100644
--- a/app/pages/veranstaltung.php
+++ b/app/pages/veranstaltung.php
@@ -9,9 +9,9 @@ declare(strict_types=1);
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
* schreibt bin/veranstaltung-bilder.php.
*
- * Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()):
- * ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er
- * gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
+ * Geladen wird hier bewusst UNGEFILTERT (veranstaltung_find(), nicht
+ * veranstaltungen_load()): ein Entwurf muss unter seiner direkten URL lesbar
+ * bleiben, damit er gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
* trägt noindex.
*
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
@@ -23,13 +23,7 @@ declare(strict_types=1);
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
-$v = null;
-foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
- if (($entry['slug'] ?? '') === $veranstaltungSlug) {
- $v = $entry;
- break;
- }
-}
+$v = veranstaltung_find($veranstaltungSlug);
if ($v === null) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
diff --git a/bin/anzeige-add.php b/bin/anzeige-add.php
index 28a15a4..c8cf7ae 100644
--- a/bin/anzeige-add.php
+++ b/bin/anzeige-add.php
@@ -123,7 +123,11 @@ foreach ($sources as $src) {
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
// nicht mehr passen.
- foreach (glob("{$destDir}/{$slug}-*.jpg") ?: [] as $alt) {
+ // Muster bewusst auf die BREITEN-Endung eingeschränkt (`-
.jpg`): mit
+ // `{$slug}-*.jpg` löscht ein Slug die Motive jedes Slugs, der ihn als Präfix
+ // hat — `emons` räumte so die Dateien von `emons-partnerdank` mit weg
+ // (03.09.2026 beobachtet, dort zufällig gewollt, generell Datenverlust).
+ foreach (glob("{$destDir}/{$slug}-[0-9]*.jpg") ?: [] as $alt) {
unlink($alt);
}
diff --git a/bin/preflight.php b/bin/preflight.php
index 48c2435..b12761e 100644
--- a/bin/preflight.php
+++ b/bin/preflight.php
@@ -326,6 +326,41 @@ if (is_file($szFile)) {
}
}
+// --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) ---
+// Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die
+// Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug
+// oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil
+// die Komponenten bewusst still zurückkehren. Deshalb hier laut.
+if (is_file($szFile)) {
+ $szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []);
+ $vBySlug = array_column($veranstaltungRows, null, 'slug');
+ foreach ($szData['issues'] ?? [] as $ausgabe) {
+ $vSlug = (string) ($ausgabe['veranstaltung'] ?? '');
+ if ($vSlug === '') {
+ continue;
+ }
+ if (!isset($vBySlug[$vSlug])) {
+ $errors[] = "Stadionzeitung '{$ausgabe['slug']}': veranstaltung '{$vSlug}' fehlt in data/veranstaltungen.json.";
+ continue;
+ }
+ $turniere = $vBySlug[$vSlug]['turniere'] ?? [];
+ foreach ($turniere as $i => $turnier) {
+ foreach (['key', 'title', 'date', 'teams', 'spiele'] as $feld) {
+ if (empty($turnier[$feld])) {
+ $warnings[] = "data/veranstaltungen.json, '{$vSlug}' → turniere[{$i}]: Feld '{$feld}' fehlt oder ist leer.";
+ }
+ }
+ }
+ $keys = array_column($turniere, 'key');
+ foreach ($ausgabe['images'] ?? [] as $seite) {
+ if (($seite['type'] ?? '') === 'live' && ($seite['page'] ?? '') === 'turnier'
+ && !in_array((string) ($seite['turnier'] ?? ''), $keys, true)) {
+ $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Turnierseite '" . ($seite['turnier'] ?? '') . "' hat keinen Eintrag in turniere[] von '{$vSlug}'.";
+ }
+ }
+ }
+}
+
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
diff --git a/bin/stadionzeitung-add.php b/bin/stadionzeitung-add.php
index f966d30..c6a24f3 100644
--- a/bin/stadionzeitung-add.php
+++ b/bin/stadionzeitung-add.php
@@ -58,6 +58,16 @@ declare(strict_types=1);
* Interview → Aufruf. Für die Aufruf-Seite erzeugt das Skript zusätzlich den
* QR (qr-aufruf.png im Ausgaben-Ordner) aus `aufruf.link`.
*
+ * Sportfest-Ausgabe (03.09.2026): trägt die Ausgabe ein `veranstaltung`-Feld
+ * (Slug aus data/veranstaltungen.json, chat-gepflegt), kommen dazu: die
+ * Programm-Seite im Aufschlag (aus programm[] der Veranstaltung, dieselbe Liste
+ * wie die Website), ein Block Turnierplan-Seiten (je Eintrag in turniere[] eine
+ * Seite) direkt danach, und eine Gegnerseite je Mannschaft mit HEIMspiel im
+ * Zeitraum der Veranstaltung statt nur für die tragende Mannschaft — welche das
+ * sind, sagt stadionzeitung_gegner_fixtures() (helpers.php), dieselbe Funktion,
+ * aus der der Snapshot die Daten baut. `cover` ({edition, teaser}) kennzeichnet
+ * das Cover als Sonderausgabe. Beide Felder überleben erneute Läufe wie `vorwort`.
+ *
* Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
*/
@@ -188,6 +198,19 @@ $oldVorwort = $oldIssue['vorwort'] ?? null;
$oldInterview = $oldIssue['interview'] ?? null;
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
$oldAufruf = $oldIssue['aufruf'] ?? null;
+// Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm,
+// Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt.
+$oldVeranstaltung = $oldIssue['veranstaltung'] ?? null;
+$oldCover = $oldIssue['cover'] ?? null;
+$veranstaltung = null;
+if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') {
+ $veranstaltung = veranstaltung_find($oldVeranstaltung);
+ if ($veranstaltung === null) {
+ $log("veranstaltung '{$oldVeranstaltung}' steht nicht in data/veranstaltungen.json — Programm-, Turnier- und Zusatz-Gegnerseiten entfallen.", 'WARN');
+ } else {
+ $log("Sportfest-Ausgabe: Veranstaltung '{$oldVeranstaltung}' (" . count($veranstaltung['programm'] ?? []) . ' Programmtage, ' . count($veranstaltung['turniere'] ?? []) . ' Turniere).');
+ }
+}
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
@@ -215,6 +238,28 @@ $widths = [480, 960, 1440];
// erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
// Bilder liegen schon. ---
+// Anzeigen mit `platzierung` haben einen FESTEN Platz auf einer Live-Seite
+// (derzeit `danke`: die Emons-Anzeige über der Partner-Logo-Wand) und laufen
+// deshalb nicht im Streu-Pool mit — sonst stünden sie zweimal im Heft.
+$platzierteAnzeigen = [];
+foreach (json_load('anzeigen')['anzeigen'] ?? [] as $a) {
+ $platz = trim((string) ($a['platzierung'] ?? ''));
+ if ($platz === '' || ($a['aktiv'] ?? true) === false || empty($a['base'])) {
+ continue;
+ }
+ if (!empty($a['bis']) && (string) $a['bis'] < $date) {
+ continue;
+ }
+ if (isset($platzierteAnzeigen[$platz])) {
+ continue;
+ }
+ $platzierteAnzeigen[$platz] = [
+ 'base' => (string) $a['base'],
+ 'widths' => $a['widths'] ?? [],
+ 'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . trim((string) ($a['sponsor'] ?? '')))),
+ ];
+}
+
$anzeigenSeiten = array_values(array_map(
static function (array $a): array {
$sponsor = trim((string) ($a['sponsor'] ?? ''));
@@ -237,6 +282,7 @@ $anzeigenSeiten = array_values(array_map(
// nächsten Handgriff im Heft zu bleiben.
static fn (array $a): bool => ($a['aktiv'] ?? true)
&& !empty($a['base'])
+ && empty($a['platzierung'])
&& (empty($a['bis']) || (string) $a['bis'] >= $date)
)
));
@@ -245,12 +291,8 @@ $anzeigenSeiten = array_values(array_map(
// Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
// bei jeder Umverteilung. ---
-$live = static function (string $page, ?string $team = null): array {
- $entry = ['type' => 'live', 'page' => $page];
- if ($team !== null) {
- $entry['team'] = $team;
- }
- return $entry;
+$live = static function (string $page, array $extra = []): array {
+ return ['type' => 'live', 'page' => $page] + $extra;
};
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
@@ -293,6 +335,11 @@ try {
} elseif ($vorwort !== null) {
$aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung');
}
+ // Programm-Seite der Sportfest-Ausgabe direkt hinter dem Vorwort: aufgeschlagen
+ // liegen Vorwort (links) und Programm (rechts) als Doppelseite 2/3 nebeneinander.
+ if ($veranstaltung !== null && !empty($veranstaltung['programm'])) {
+ $aufschlag[] = $live('programm');
+ }
// Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes
// Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
if ($oldInterview !== null) {
@@ -308,8 +355,40 @@ try {
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
}
+ // Turnierplan-Seiten der Sportfest-Ausgabe: je Turnier eine Seite, EIN Block
+ // (zusammenhängender Sportfest-Teil, keine Anzeige zwischen zwei Turnieren),
+ // gleich nach dem Aufschlag und vor dem Fußball-Teil.
+ $turniere = [];
+ foreach ($veranstaltung['turniere'] ?? [] as $turnier) {
+ if (is_array($turnier) && !empty($turnier['key'])) {
+ $turniere[] = $live('turnier', ['turnier' => (string) $turnier['key']]);
+ }
+ }
+ if ($turniere !== []) {
+ $bloecke[] = ['name' => 'turniere', 'seiten' => $turniere, 'anzeigen_danach' => true];
+ }
+
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
- $bloecke[] = ['name' => 'gegner', 'seiten' => [$live('gegner')], 'anzeigen_danach' => true];
+
+ // Gegnerseite(n): normal nur die tragende Mannschaft; in der Sportfest-Ausgabe
+ // je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als
+ // EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() —
+ // dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut,
+ // damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen.
+ $gegnerSeiten = [];
+ foreach (stadionzeitung_gegner_fixtures([
+ 'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent,
+ 'veranstaltung' => $oldVeranstaltung,
+ ]) as $gegnerTeam => $fixture) {
+ $gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]);
+ $log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']}).");
+ }
+ if ($gegnerSeiten === []) {
+ // Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft —
+ // sie meldet dann selbst, dass keine Vergleichsdaten vorliegen.
+ $gegnerSeiten[] = $live('gegner', ['team' => $teamKey]);
+ }
+ $bloecke[] = ['name' => 'gegner', 'seiten' => $gegnerSeiten, 'anzeigen_danach' => true];
// Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
@@ -317,15 +396,28 @@ try {
$tabellen = [];
foreach ($mc['teams'] ?? [] as $team) {
if (!empty($team['key'])) {
- $tabellen[] = $live('tabelle', (string) $team['key']);
+ $tabellen[] = $live('tabelle', ['team' => (string) $team['key']]);
}
}
if ($tabellen !== []) {
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
}
- foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) {
- $bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true];
+ foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'danke', 'momente'] as $seite) {
+ // Die Danke-Seite trägt ihr Anzeigenmotiv im Seiteneintrag (siehe oben),
+ // direkt hinter der Partner-werden-Seite: aufgeschlagen liegen damit
+ // Akquise und Dank als Partner-Doppelseite nebeneinander.
+ $extra = ($seite === 'danke' && isset($platzierteAnzeigen['danke']))
+ ? ['anzeige' => $platzierteAnzeigen['danke']]
+ : [];
+ // Hinter der Partner-werden-Seite bleibt die Lücke leer, damit die
+ // Danke-Seite direkt folgt: Akquise und Dank gehören zusammen und
+ // liegen aufgeschlagen im günstigen Fall als Doppelseite nebeneinander.
+ $bloecke[] = [
+ 'name' => $seite,
+ 'seiten' => [$live($seite, $extra)],
+ 'anzeigen_danach' => $seite !== 'partner',
+ ];
}
$bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];
@@ -651,6 +743,8 @@ $issues[] = [
'vorwort' => $oldVorwort,
'interview' => $oldInterview,
'aufruf' => $oldAufruf,
+ 'veranstaltung' => $oldVeranstaltung,
+ 'cover' => $oldCover,
'verfasser_photo' => $verfasserPhoto,
'termine_snapshot' => $termineSnapshot,
'images' => $images,
diff --git a/data/anzeigen.json b/data/anzeigen.json
index bc3f70e..ed585b5 100644
--- a/data/anzeigen.json
+++ b/data/anzeigen.json
@@ -239,7 +239,7 @@
"slug": "sportfest-2026-programm",
"sponsor": "Programm Sportfest 2026",
"aktiv": true,
- "bis": "2026-09-06",
+ "bis": "2026-09-03",
"alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade",
"base": "img/anzeigen/sportfest-2026-programm",
"widths": [
@@ -248,18 +248,6 @@
1440
]
},
- {
- "slug": "emons-partnerdank",
- "sponsor": "Emons Spedition und Dank an unsere Partner",
- "aktiv": true,
- "alt": "Anzeige: Emons Spedition, darunter unser Dank an alle Partner",
- "base": "img/anzeigen/emons-partnerdank",
- "widths": [
- 480,
- 960,
- 1440
- ]
- },
{
"slug": "gagel-pickelmann",
"sponsor": "Gagel Bäder und Mineralölhandel Pickelmann",
@@ -294,6 +282,19 @@
960,
1440
]
+ },
+ {
+ "slug": "emons",
+ "sponsor": "Emons Spedition",
+ "aktiv": true,
+ "base": "img/anzeigen/emons",
+ "widths": [
+ 480,
+ 960,
+ 1141
+ ],
+ "alt": "Anzeige: Emons Spedition Himmelkron, Ihr Partner für nationale und internationale Transporte",
+ "platzierung": "danke"
}
]
}
diff --git a/data/stadionzeitung.json b/data/stadionzeitung.json
index 7690dee..4144eb9 100644
--- a/data/stadionzeitung.json
+++ b/data/stadionzeitung.json
@@ -1,5 +1,847 @@
{
"issues": [
+ {
+ "slug": "2026-09-04-ats-wartenfels",
+ "opponent": "ATS Wartenfels",
+ "date": "2026-09-04",
+ "team_key": "erste-mannschaft",
+ "status": "entwurf",
+ "pages": 48,
+ "player_image": {
+ "base": "img/stadionzeitung/2026-09-04-ats-wartenfels/spieler",
+ "widths": [
+ 320,
+ 640,
+ 1000
+ ],
+ "ext": "png",
+ "alt": ""
+ },
+ "qr_image": "img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png",
+ "vorwort": {
+ "heading": "Drei Tage Fußball im Katzbachtal",
+ "paragraphs": [
+ "Liebe Fußballfreunde,",
+ "herzlich willkommen zu unserem Sportfest. Ein besonderer Gruß gilt dem Schiedsrichtergespann und unseren Gästen vom ATS Wartenfels.",
+ "Bis Sonntag ist bei uns durchgehend Betrieb: am Samstag unsere Zweite, am Sonntag unsere Frauen, dazwischen die Turniere unserer Jugend. Danke an alle, die dieses Wochenende möglich machen.",
+ "Der 7:3-Heimsieg gegen Marktschorgast gibt uns Rückenwind. Leidenschaft, Wille, Heimsieg. Los geht's!"
+ ],
+ "closing": "Rot-schwarze Grüße",
+ "signee_name": "Benedikt Höfner",
+ "signee_role": "Sportlicher Leiter"
+ },
+ "interview": null,
+ "aufruf": null,
+ "veranstaltung": "sportfest-2026",
+ "cover": {
+ "teaser": "Das komplette Programm zum Sportfest und die Spielpläne aller vier Jugendturniere."
+ },
+ "verfasser_photo": {
+ "base": "img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser",
+ "widths": [
+ 120,
+ 222
+ ],
+ "alt": ""
+ },
+ "termine_snapshot": [
+ {
+ "date": "2026-09-04",
+ "date_end": "2026-09-06",
+ "title": "Sportfest 2026",
+ "department": "verein",
+ "type": "event",
+ "meta": "16:30 Uhr · Hans-Rausch-Sportanlage",
+ "href": "/veranstaltungen/sportfest-2026",
+ "time": "16:30",
+ "location": "Hans-Rausch-Sportanlage",
+ "raw": {
+ "slug": "sportfest-2026",
+ "title": "Sportfest 2026",
+ "date_start": "2026-09-04",
+ "date_end": "2026-09-06",
+ "time": "16:30",
+ "location": "Hans-Rausch-Sportanlage",
+ "department": "verein",
+ "status": "veroeffentlicht",
+ "text": "Drei Tage Fußball, Essen, Trinken, Kaffee und Kuchen: bei uns ist für groß und klein alles dabei, für die Kinder steht eine Hüpfburg.",
+ "ankuendigung": null,
+ "rueckblick": null,
+ "karte": "club",
+ "anfahrt": [],
+ "programm": [
+ {
+ "tag": "2026-09-04",
+ "punkte": [
+ {
+ "zeit": "16:30",
+ "titel": "Spiel unserer E2-Jugend"
+ },
+ {
+ "zeit": "18:00",
+ "titel": "Unsere erste Mannschaft gegen ATS Wartenfels",
+ "text": "Kreisklasse"
+ }
+ ]
+ },
+ {
+ "tag": "2026-09-05",
+ "punkte": [
+ {
+ "zeit": "09:00",
+ "titel": "Turnier der G-Jugend"
+ },
+ {
+ "zeit": "12:00",
+ "titel": "Turnier der F1-Jugend"
+ },
+ {
+ "zeit": "12:00",
+ "titel": "Turnier der F2-Jugend"
+ },
+ {
+ "zeit": "14:30",
+ "titel": "Kinderolympiade",
+ "text": "bis 16:30 Uhr in der Turnhalle Maximum Gym"
+ },
+ {
+ "zeit": "15:00",
+ "titel": "Turnier der D1-Jugend"
+ },
+ {
+ "zeit": "18:00",
+ "titel": "Unsere zweite Mannschaft gegen SV Weidenberg III/SSV Warmensteinach II",
+ "text": "Reserve"
+ }
+ ]
+ },
+ {
+ "tag": "2026-09-06",
+ "punkte": [
+ {
+ "zeit": "11:00",
+ "titel": "Unsere Frauenmannschaft gegen 1. FC 1928 Schwarzenbach/Saale",
+ "text": "Kreisliga Ost"
+ },
+ {
+ "zeit": "13:00",
+ "titel": "Turnier der E1-Jugend"
+ },
+ {
+ "zeit": "15:00",
+ "titel": "Spiel unserer D2-Jugend"
+ },
+ {
+ "zeit": "16:30",
+ "titel": "Spiel unserer C/B-Jugend"
+ }
+ ]
+ }
+ ],
+ "turniere": [
+ {
+ "key": "f1",
+ "title": "F1-Jugend",
+ "date": "2026-09-05",
+ "start": "12:00",
+ "spieldauer": 10,
+ "modus": "Jeder gegen jeden mit Hin und Rückspiel",
+ "teams": [
+ "VfR Katschenreuth 1",
+ "SV Motschenbach 1",
+ "TSV Neudrossenfeld 1"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "12:00",
+ "heim": "SV Motschenbach 1",
+ "gast": "TSV Neudrossenfeld 1"
+ },
+ {
+ "nr": 2,
+ "zeit": "12:15",
+ "heim": "VfR Katschenreuth 1",
+ "gast": "SV Motschenbach 1"
+ },
+ {
+ "nr": 3,
+ "zeit": "12:30",
+ "heim": "TSV Neudrossenfeld 1",
+ "gast": "VfR Katschenreuth 1"
+ },
+ {
+ "nr": 4,
+ "zeit": "12:45",
+ "heim": "TSV Neudrossenfeld 1",
+ "gast": "SV Motschenbach 1"
+ },
+ {
+ "nr": 5,
+ "zeit": "13:00",
+ "heim": "SV Motschenbach 1",
+ "gast": "VfR Katschenreuth 1"
+ },
+ {
+ "nr": 6,
+ "zeit": "13:15",
+ "heim": "VfR Katschenreuth 1",
+ "gast": "TSV Neudrossenfeld 1"
+ }
+ ]
+ },
+ {
+ "key": "f2",
+ "title": "F2-Jugend",
+ "date": "2026-09-05",
+ "start": "12:00",
+ "spieldauer": 15,
+ "modus": "Jeder gegen jeden",
+ "teams": [
+ "TSV 08 Kulmbach",
+ "SG Neuenmarkt",
+ "VfR Katschenreuth 2",
+ "SV Motschenbach 2"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "12:00",
+ "heim": "SG Neuenmarkt",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 2,
+ "zeit": "12:16",
+ "heim": "TSV 08 Kulmbach",
+ "gast": "VfR Katschenreuth 2"
+ },
+ {
+ "nr": 3,
+ "zeit": "12:32",
+ "heim": "SG Neuenmarkt",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 4,
+ "zeit": "12:48",
+ "heim": "VfR Katschenreuth 2",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 5,
+ "zeit": "13:04",
+ "heim": "SV Motschenbach 2",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 6,
+ "zeit": "13:20",
+ "heim": "VfR Katschenreuth 2",
+ "gast": "SG Neuenmarkt"
+ }
+ ]
+ },
+ {
+ "key": "d1",
+ "title": "D1-Jugend",
+ "date": "2026-09-05",
+ "start": "15:00",
+ "spieldauer": 35,
+ "modus": "Jeder gegen jeden",
+ "teams": [
+ "TSV 08 Kulmbach",
+ "JFG Maintal/Friesenbachtal",
+ "VfB Kulmbach"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "15:00",
+ "heim": "TSV 08 Kulmbach",
+ "gast": "JFG Maintal/Friesenbachtal"
+ },
+ {
+ "nr": 2,
+ "zeit": "15:45",
+ "heim": "JFG Maintal/Friesenbachtal",
+ "gast": "VfB Kulmbach"
+ },
+ {
+ "nr": 3,
+ "zeit": "16:30",
+ "heim": "VfB Kulmbach",
+ "gast": "TSV 08 Kulmbach"
+ }
+ ]
+ },
+ {
+ "key": "e1",
+ "title": "E1-Jugend",
+ "date": "2026-09-06",
+ "start": "13:00",
+ "spieldauer": 12,
+ "felder": 2,
+ "modus": "Jeder gegen jeden",
+ "teams": [
+ "TSV 08 Kulmbach",
+ "SV Motschenbach 2",
+ "VfB Kulmbach 2",
+ "VfR Katschenreuth 2",
+ "SG Wirsberg"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "13:00",
+ "feld": 1,
+ "heim": "TSV 08 Kulmbach",
+ "gast": "SG Wirsberg"
+ },
+ {
+ "nr": 2,
+ "zeit": "13:00",
+ "feld": 2,
+ "heim": "SV Motschenbach 2",
+ "gast": "VfB Kulmbach 2"
+ },
+ {
+ "nr": 3,
+ "zeit": "13:16",
+ "feld": 1,
+ "heim": "VfR Katschenreuth 2",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 4,
+ "zeit": "13:16",
+ "feld": 2,
+ "heim": "VfB Kulmbach 2",
+ "gast": "SG Wirsberg"
+ },
+ {
+ "nr": 5,
+ "zeit": "13:32",
+ "feld": 1,
+ "heim": "VfR Katschenreuth 2",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 6,
+ "zeit": "13:32",
+ "feld": 2,
+ "heim": "VfB Kulmbach 2",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 7,
+ "zeit": "13:48",
+ "feld": 1,
+ "heim": "SG Wirsberg",
+ "gast": "VfR Katschenreuth 2"
+ },
+ {
+ "nr": 8,
+ "zeit": "13:48",
+ "feld": 2,
+ "heim": "TSV 08 Kulmbach",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 9,
+ "zeit": "14:04",
+ "feld": 1,
+ "heim": "VfR Katschenreuth 2",
+ "gast": "VfB Kulmbach 2"
+ },
+ {
+ "nr": 10,
+ "zeit": "14:04",
+ "feld": 2,
+ "heim": "SV Motschenbach 2",
+ "gast": "SG Wirsberg"
+ }
+ ]
+ }
+ ],
+ "plakate": [
+ {
+ "base": "img/veranstaltungen/sportfest-2026/plakat-01",
+ "widths": [
+ 640,
+ 1000,
+ 1587
+ ],
+ "alt": "Plakat zum Sportfest 2026 mit Luftbild unserer Sportanlage und den Terminen vom 4. bis 6. September",
+ "source": "1-ankuendigung.png"
+ },
+ {
+ "base": "img/veranstaltungen/sportfest-2026/plakat-02",
+ "widths": [
+ 640,
+ 1000,
+ 1587
+ ],
+ "alt": "Programmplakat zum Sportfest 2026 mit allen Spielen, Turnieren und der Kinderolympiade am Samstag",
+ "source": "2-programm.png"
+ }
+ ],
+ "galerie": []
+ }
+ },
+ {
+ "date": "2026-09-05",
+ "date_end": null,
+ "title": "(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II – (SG 2) SV Weidenberg III/SSV Warmensteinach II (flex)",
+ "department": "fussball",
+ "type": "spiel",
+ "meta": "Heimspiel · Reserve BK 5 BT-KU",
+ "href": "/matchcenter",
+ "kickoff": "2026-09-05T18:00",
+ "team_name": "2. Mannschaft",
+ "league": "Reserve BK 5 BT-KU",
+ "raw": {
+ "kickoff": "2026-09-05T18:00",
+ "date_display": "Sa, 05.09.",
+ "time_display": "18:00 Uhr",
+ "competition": "Liga",
+ "home": "(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II",
+ "away": "(SG 2) SV Weidenberg III/SSV Warmensteinach II (flex)",
+ "home_crest": "img/crests/00ES8GNK4O000002VV0AG08LVUPGND5I.png",
+ "away_crest": "img/crests/00ES8GNK4O000013VV0AG08LVUPGND5I.png",
+ "is_home": true,
+ "team_key": "zweite-mannschaft",
+ "team_name": "2. Mannschaft"
+ }
+ },
+ {
+ "date": "2026-09-06",
+ "date_end": null,
+ "title": "TSV 08 Kulmbach – 1. FC 1928 Schwarzenbach/Saale",
+ "department": "fussball",
+ "type": "spiel",
+ "meta": "Heimspiel · Frauen KL Ost",
+ "href": "/matchcenter",
+ "kickoff": "2026-09-06T11:00",
+ "team_name": "Frauenmannschaft",
+ "league": "Frauen KL Ost",
+ "raw": {
+ "kickoff": "2026-09-06T11:00",
+ "date_display": "So, 06.09.",
+ "time_display": "11:00 Uhr",
+ "competition": "Liga",
+ "home": "TSV 08 Kulmbach",
+ "away": "1. FC 1928 Schwarzenbach/Saale",
+ "home_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png",
+ "away_crest": "img/crests/00ES8GNK9O00000QVV0AG08LVUPGND5I.png",
+ "is_home": true,
+ "team_key": "damen",
+ "team_name": "Frauenmannschaft"
+ }
+ },
+ {
+ "date": "2026-09-13",
+ "date_end": null,
+ "title": "TSV 08 Kulmbach – TSV Engelmannsreuth",
+ "department": "fussball",
+ "type": "spiel",
+ "meta": "Heimspiel · Frauen KL Ost",
+ "href": "/matchcenter",
+ "kickoff": "2026-09-13T10:30",
+ "team_name": "Frauenmannschaft",
+ "league": "Frauen KL Ost",
+ "raw": {
+ "kickoff": "2026-09-13T10:30",
+ "date_display": "So, 13.09.",
+ "time_display": "10:30 Uhr",
+ "competition": "Liga",
+ "home": "TSV 08 Kulmbach",
+ "away": "TSV Engelmannsreuth",
+ "home_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png",
+ "away_crest": "img/crests/00ES8GNK3G000009VV0AG08LVUPGND5I.png",
+ "is_home": true,
+ "team_key": "damen",
+ "team_name": "Frauenmannschaft"
+ }
+ },
+ {
+ "date": "2026-09-13",
+ "date_end": null,
+ "title": "SV Lindenhardt II flex – (SG1) TSV Melkendorf III/ TSV 08 Kulmbach II",
+ "department": "fussball",
+ "type": "spiel",
+ "meta": "Auswärtsspiel · Reserve BK 5 BT-KU",
+ "href": "/matchcenter",
+ "kickoff": "2026-09-13T13:00",
+ "team_name": "2. Mannschaft",
+ "league": "Reserve BK 5 BT-KU",
+ "raw": {
+ "kickoff": "2026-09-13T13:00",
+ "date_display": "So, 13.09.",
+ "time_display": "13:00 Uhr",
+ "competition": "Liga",
+ "home": "SV Lindenhardt II flex",
+ "away": "(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II",
+ "home_crest": "img/crests/00ES8GNK3G00001AVV0AG08LVUPGND5I.png",
+ "away_crest": "img/crests/00ES8GNK4O000002VV0AG08LVUPGND5I.png",
+ "is_home": false,
+ "team_key": "zweite-mannschaft",
+ "team_name": "2. Mannschaft"
+ }
+ }
+ ],
+ "images": [
+ {
+ "type": "live",
+ "page": "cover"
+ },
+ {
+ "type": "live",
+ "page": "vorwort"
+ },
+ {
+ "type": "live",
+ "page": "programm"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/eldorado",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: eldorado Radsporttechnik Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "turnier",
+ "turnier": "f1"
+ },
+ {
+ "type": "live",
+ "page": "turnier",
+ "turnier": "f2"
+ },
+ {
+ "type": "live",
+ "page": "turnier",
+ "turnier": "d1"
+ },
+ {
+ "type": "live",
+ "page": "turnier",
+ "turnier": "e1"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/egu",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Erfrischungsgetränke-Union Kulmbacher Gruppe GmbH"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/hofmann-graef",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Buchhandlung Hofmann, Kulmbach, und Automobilwerkstatt Gräf, Thurnau"
+ },
+ {
+ "type": "live",
+ "page": "ergebnisse"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/muench-richterstegner",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Planungsbüro Münch und Richter & Stegner Steuerungstechnik"
+ },
+ {
+ "type": "live",
+ "page": "gegner",
+ "team": "erste-mannschaft"
+ },
+ {
+ "type": "live",
+ "page": "gegner",
+ "team": "zweite-mannschaft"
+ },
+ {
+ "type": "live",
+ "page": "gegner",
+ "team": "damen"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/malteser-herrmann",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Malteser-Hilfsdienst und Schlosserei Herrmann, Kulmbach"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/apotheken-wenkel",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Apotheke im Fritz, Stadtpark Apotheke und Schlüsseldienst Wenkel, Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "tabelle",
+ "team": "erste-mannschaft"
+ },
+ {
+ "type": "live",
+ "page": "tabelle",
+ "team": "zweite-mannschaft"
+ },
+ {
+ "type": "live",
+ "page": "tabelle",
+ "team": "damen"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/karl-geyer",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Geyer Farbe + Raum, Kulmbach, Starthilfe Boden"
+ },
+ {
+ "type": "live",
+ "page": "vorschau"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/gsb",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: GSB Security und GSB Gebäude Service, Bindlach"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/physiofit-wohlfart",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Physio Fit Neudrossenfeld und Textilpflege Wohlfart, Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "termine"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/sport2000-pizza",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Sport 2000 Downstairs und Kulmbacher Pizza-Service"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/schulz-csu",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Schreinerei Schulz, Mainleus, und CSU Stadtverband Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "news"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/mattis-ka2",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Karl Mattis Flaschnerei und KA2 Fitness, Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "kontakt"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/motor-nuetzel",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Motor-Nützel Vertriebs-GmbH, Bayreuth"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/vereinskarte-edeka",
+ "widths": [
+ 480,
+ 960,
+ 1410
+ ],
+ "alt": "Eigenanzeige: So funktioniert die Vereinskarte beim E center Seidl Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "mitglied"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/trapper-verkehrsakademie",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Trapper GmbH Entsorgungsfachbetrieb und Verkehrsakademie, Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "historie"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/ohnemueller-dippold",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Gaststätte und Metzgerei Ohne-Müller, Kulmbach-Weiher, und Bäckerei Dippold, Melkendorf"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/hanft",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Hanft Bürotechnik GmbH, Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "sportheim"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/elma-kleinheinz",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: elma GmbH Bayreuth und Metzgerei Kleinheinz, Kulmbach"
+ },
+ {
+ "type": "live",
+ "page": "partner"
+ },
+ {
+ "type": "live",
+ "page": "danke",
+ "anzeige": {
+ "base": "img/anzeigen/emons",
+ "widths": [
+ 480,
+ 960,
+ 1141
+ ],
+ "alt": "Anzeige: Emons Spedition Himmelkron, Ihr Partner für nationale und internationale Transporte"
+ }
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/stadionzeitung-digital",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Eigenanzeige: Unsere Stadionzeitung gibt es jetzt auch online, mit QR-Code zum Scannen"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/gagel-pickelmann",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Gagel Bäder und Mineralölhandel Pickelmann"
+ },
+ {
+ "type": "live",
+ "page": "momente"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/schramm",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Anzeige: Landgasthof und Metzgerei Schramm, Forstlahm"
+ },
+ {
+ "type": "image",
+ "base": "img/anzeigen/tagesskifahrt-2027",
+ "widths": [
+ 480,
+ 960,
+ 1440
+ ],
+ "alt": "Eigenanzeige: Unsere Tagesskifahrt ins Hochzillertal am 20. Februar 2027"
+ },
+ {
+ "type": "live",
+ "page": "impressum"
+ },
+ {
+ "type": "image",
+ "base": "img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite",
+ "widths": [
+ 480,
+ 960,
+ 1410
+ ],
+ "alt": "Rückseite der Stadionzeitung gegen ATS Wartenfels"
+ }
+ ]
+ },
{
"slug": "2026-08-29-asv-marktschorgast",
"opponent": "ASV Marktschorgast",
diff --git a/data/veranstaltungen.json b/data/veranstaltungen.json
index 3bb7797..0b9202e 100644
--- a/data/veranstaltungen.json
+++ b/data/veranstaltungen.json
@@ -45,6 +45,301 @@
"rueckblick": null,
"karte": "club",
"anfahrt": [],
+ "programm": [
+ {
+ "tag": "2026-09-04",
+ "punkte": [
+ {
+ "zeit": "16:30",
+ "titel": "Spiel unserer E2-Jugend"
+ },
+ {
+ "zeit": "18:00",
+ "titel": "Unsere erste Mannschaft gegen ATS Wartenfels",
+ "text": "Kreisklasse"
+ }
+ ]
+ },
+ {
+ "tag": "2026-09-05",
+ "punkte": [
+ {
+ "zeit": "09:00",
+ "titel": "Turnier der G-Jugend"
+ },
+ {
+ "zeit": "12:00",
+ "titel": "Turnier der F1-Jugend"
+ },
+ {
+ "zeit": "12:00",
+ "titel": "Turnier der F2-Jugend"
+ },
+ {
+ "zeit": "14:30",
+ "titel": "Kinderolympiade",
+ "text": "bis 16:30 Uhr in der Turnhalle Maximum Gym"
+ },
+ {
+ "zeit": "15:00",
+ "titel": "Turnier der D1-Jugend"
+ },
+ {
+ "zeit": "18:00",
+ "titel": "Unsere zweite Mannschaft gegen SV Weidenberg III/SSV Warmensteinach II",
+ "text": "Reserve"
+ }
+ ]
+ },
+ {
+ "tag": "2026-09-06",
+ "punkte": [
+ {
+ "zeit": "11:00",
+ "titel": "Unsere Frauenmannschaft gegen 1. FC 1928 Schwarzenbach/Saale",
+ "text": "Kreisliga Ost"
+ },
+ {
+ "zeit": "13:00",
+ "titel": "Turnier der E1-Jugend"
+ },
+ {
+ "zeit": "15:00",
+ "titel": "Spiel unserer D2-Jugend"
+ },
+ {
+ "zeit": "16:30",
+ "titel": "Spiel unserer C/B-Jugend"
+ }
+ ]
+ }
+ ],
+ "turniere": [
+ {
+ "key": "f1",
+ "title": "F1-Jugend",
+ "date": "2026-09-05",
+ "start": "12:00",
+ "spieldauer": 10,
+ "modus": "Jeder gegen jeden mit Hin und Rückspiel",
+ "teams": [
+ "VfR Katschenreuth 1",
+ "SV Motschenbach 1",
+ "TSV Neudrossenfeld 1"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "12:00",
+ "heim": "SV Motschenbach 1",
+ "gast": "TSV Neudrossenfeld 1"
+ },
+ {
+ "nr": 2,
+ "zeit": "12:15",
+ "heim": "VfR Katschenreuth 1",
+ "gast": "SV Motschenbach 1"
+ },
+ {
+ "nr": 3,
+ "zeit": "12:30",
+ "heim": "TSV Neudrossenfeld 1",
+ "gast": "VfR Katschenreuth 1"
+ },
+ {
+ "nr": 4,
+ "zeit": "12:45",
+ "heim": "TSV Neudrossenfeld 1",
+ "gast": "SV Motschenbach 1"
+ },
+ {
+ "nr": 5,
+ "zeit": "13:00",
+ "heim": "SV Motschenbach 1",
+ "gast": "VfR Katschenreuth 1"
+ },
+ {
+ "nr": 6,
+ "zeit": "13:15",
+ "heim": "VfR Katschenreuth 1",
+ "gast": "TSV Neudrossenfeld 1"
+ }
+ ]
+ },
+ {
+ "key": "f2",
+ "title": "F2-Jugend",
+ "date": "2026-09-05",
+ "start": "12:00",
+ "spieldauer": 15,
+ "modus": "Jeder gegen jeden",
+ "teams": [
+ "TSV 08 Kulmbach",
+ "SG Neuenmarkt",
+ "VfR Katschenreuth 2",
+ "SV Motschenbach 2"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "12:00",
+ "heim": "SG Neuenmarkt",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 2,
+ "zeit": "12:16",
+ "heim": "TSV 08 Kulmbach",
+ "gast": "VfR Katschenreuth 2"
+ },
+ {
+ "nr": 3,
+ "zeit": "12:32",
+ "heim": "SG Neuenmarkt",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 4,
+ "zeit": "12:48",
+ "heim": "VfR Katschenreuth 2",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 5,
+ "zeit": "13:04",
+ "heim": "SV Motschenbach 2",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 6,
+ "zeit": "13:20",
+ "heim": "VfR Katschenreuth 2",
+ "gast": "SG Neuenmarkt"
+ }
+ ]
+ },
+ {
+ "key": "d1",
+ "title": "D1-Jugend",
+ "date": "2026-09-05",
+ "start": "15:00",
+ "spieldauer": 35,
+ "modus": "Jeder gegen jeden",
+ "teams": [
+ "TSV 08 Kulmbach",
+ "JFG Maintal/Friesenbachtal",
+ "VfB Kulmbach"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "15:00",
+ "heim": "TSV 08 Kulmbach",
+ "gast": "JFG Maintal/Friesenbachtal"
+ },
+ {
+ "nr": 2,
+ "zeit": "15:45",
+ "heim": "JFG Maintal/Friesenbachtal",
+ "gast": "VfB Kulmbach"
+ },
+ {
+ "nr": 3,
+ "zeit": "16:30",
+ "heim": "VfB Kulmbach",
+ "gast": "TSV 08 Kulmbach"
+ }
+ ]
+ },
+ {
+ "key": "e1",
+ "title": "E1-Jugend",
+ "date": "2026-09-06",
+ "start": "13:00",
+ "spieldauer": 12,
+ "felder": 2,
+ "modus": "Jeder gegen jeden",
+ "teams": [
+ "TSV 08 Kulmbach",
+ "SV Motschenbach 2",
+ "VfB Kulmbach 2",
+ "VfR Katschenreuth 2",
+ "SG Wirsberg"
+ ],
+ "spiele": [
+ {
+ "nr": 1,
+ "zeit": "13:00",
+ "feld": 1,
+ "heim": "TSV 08 Kulmbach",
+ "gast": "SG Wirsberg"
+ },
+ {
+ "nr": 2,
+ "zeit": "13:00",
+ "feld": 2,
+ "heim": "SV Motschenbach 2",
+ "gast": "VfB Kulmbach 2"
+ },
+ {
+ "nr": 3,
+ "zeit": "13:16",
+ "feld": 1,
+ "heim": "VfR Katschenreuth 2",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 4,
+ "zeit": "13:16",
+ "feld": 2,
+ "heim": "VfB Kulmbach 2",
+ "gast": "SG Wirsberg"
+ },
+ {
+ "nr": 5,
+ "zeit": "13:32",
+ "feld": 1,
+ "heim": "VfR Katschenreuth 2",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 6,
+ "zeit": "13:32",
+ "feld": 2,
+ "heim": "VfB Kulmbach 2",
+ "gast": "TSV 08 Kulmbach"
+ },
+ {
+ "nr": 7,
+ "zeit": "13:48",
+ "feld": 1,
+ "heim": "SG Wirsberg",
+ "gast": "VfR Katschenreuth 2"
+ },
+ {
+ "nr": 8,
+ "zeit": "13:48",
+ "feld": 2,
+ "heim": "TSV 08 Kulmbach",
+ "gast": "SV Motschenbach 2"
+ },
+ {
+ "nr": 9,
+ "zeit": "14:04",
+ "feld": 1,
+ "heim": "VfR Katschenreuth 2",
+ "gast": "VfB Kulmbach 2"
+ },
+ {
+ "nr": 10,
+ "zeit": "14:04",
+ "feld": 2,
+ "heim": "SV Motschenbach 2",
+ "gast": "SG Wirsberg"
+ }
+ ]
+ }
+ ],
"plakate": [
{
"base": "img/veranstaltungen/sportfest-2026/plakat-01",
@@ -370,4 +665,4 @@
]
}
]
-}
\ No newline at end of file
+}
diff --git a/public/assets/css/components.css b/public/assets/css/components.css
index 5457dbc..b204b90 100644
--- a/public/assets/css/components.css
+++ b/public/assets/css/components.css
@@ -6097,6 +6097,25 @@ body.is-sz-full .back-to-top {
}
+/* Titelgeschichte einer Sonderausgabe: das Fest statt der Begegnung. Coolvetica
+ in Versalien wie der Masthead, aber kleiner — es soll die zweite Stimme auf
+ dem Cover sein, nicht mit der Wortmarke konkurrieren. */
+.stadionzeitung-cover__feature {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1.7cqi;
+ text-align: left;
+}
+
+.stadionzeitung-cover__feature-title {
+ font-family: var(--ff-heading);
+ font-size: 13cqi;
+ line-height: 0.88;
+ text-transform: uppercase;
+ text-shadow: 0 2px 10px rgb(0 0 0 / 0.5);
+}
+
/* Vereins-Hero-Bild als Hintergrund (dieselbe Quelle wie die Startseite). */
.stadionzeitung-cover__bg {
position: absolute;
@@ -6135,6 +6154,63 @@ body.is-sz-full .back-to-top {
width: auto;
}
+/* Hauptplakat einer Sonderausgabe an der Stelle des Spielerfotos: rechts
+ randabfallend, leicht schräg gelegt und an zwei gegenüberliegenden Ecken
+ gerundet (links oben, rechts unten) — wie ein aufs Cover gelegter Aushang,
+ nicht wie ein eingesetztes Bild (Felix, 03.09.2026).
+
+ Warum kleiner als das Spielerfoto (98cqi hoch): ein Freisteller ist schmal,
+ ein Plakat im Hochformat ist bei gleicher Höhe knapp 60cqi breit und läge damit
+ über der Typografie unten links. Bei 66cqi Höhe belegt es die rechte Bildhälfte
+ und lässt Masthead und Fest-Titel frei — die Textspalte wird dafür schmaler
+ (siehe --poster .stadionzeitung-cover__corner).
+
+ Schatten statt Rahmen, damit das Plakat auf dem Foto-Hintergrund abhebt:
+ box-shadow ist unbedenklich, die Druck-Falle im Projekt betrifft text-shadow
+ auf umbrechendem Text (siehe .stadionzeitung-cover__corner). */
+.stadionzeitung-cover__poster {
+ position: absolute;
+ z-index: 1;
+ /* VOLLSTÄNDIG im Bild, nicht randabfallend wie das Spielerfoto: ein Plakat
+ trägt Text (Datum, Ort), und angeschnitten fehlte rechts genau der. Die
+ Rotation lässt die Ecken trotzdem über die Passepartout-Linie laufen, das
+ Plakat liegt also sichtbar „auf" der Seite. */
+ /* Unten rechts randabfallend („angeschnitten", Felix 03.09.2026): das Plakat
+ läuft über zwei Kanten hinaus und wirkt dadurch aufgelegt statt eingesetzt.
+ Angeschnitten wird nur die untere rechte Ecke, nicht die Kopfzeile mit den
+ Terminen — die bleibt lesbar. */
+ right: -1.5cqi;
+ /* bottom: 0 statt negativ — die Box endet an der Blattkante, angeschnitten
+ wird nur noch die gedrehte Ecke (4 Grad auf 53cqi Breite = knapp 4cqi
+ Überhang). Das hält den Anschnitt-Look und rückt das Plakat gleichzeitig
+ aus dem unteren Bereich nach oben (Felix, 03.09.2026). */
+ bottom: 0;
+ /* Höhe ist NICHT frei wählbar: das Plakat darf die Textspalte links nicht
+ berühren. Rechnung bei 4 Grad Drehung — belegte Breite = Höhe / 1,414
+ (Hochformat) + sin(4°) × Höhe (Überhang der gedrehten Ecken) ≈ 0,78 × Höhe.
+ Bei 72cqi sind das 56,2cqi; mit 1,5cqi Überhang rechts liegt die linke Kante
+ bei 45,3% und die Textspalte endet bei 44%. Der Überhang ist bewusst winzig:
+ vom Plakat bleiben so 97% sichtbar und seine Schlagzeile „Sportfest" steht
+ ganz im Bild (Felix, 03.09.2026) — bei 9cqi Überhang fehlte rechts ein
+ Siebtel und das Wort lief aus dem Bild. Angeschnitten wirkt es trotzdem,
+ das macht die gedrehte Ecke unten. Höhe und Überhang hängen also
+ zusammen: weiter nach links (kleinerer Überhang) heißt kleiner, größer
+ heißt weiter nach rechts — oder die Textspalte muss schmaler
+ (--poster .stadionzeitung-cover__corner). Nicht nur eine Zahl drehen. */
+ height: 72cqi;
+ width: auto;
+ border-radius: 5cqi 0 5cqi 0;
+ transform: rotate(4deg);
+ box-shadow: 0 3cqi 6cqi rgb(0 0 0 / 0.45);
+}
+
+/* Schmaler als das Standard-Cover (56cqi): das Plakat belegt die rechte Hälfte.
+ „Sportfest 2026" bricht dadurch zweizeilig — auf einem Cover trägt das eher,
+ als es schadet. */
+.stadionzeitung-cover--poster .stadionzeitung-cover__corner {
+ max-width: 38cqi;
+}
+
.stadionzeitung-cover__inner {
position: relative;
z-index: 2;
@@ -7452,6 +7528,225 @@ a.news-feature__link:hover .news-feature__more {
color: var(--clr-accent);
}
+/* === stadionzeitung-programm (Live-Seite, Sportfest-Ausgabe) === */
+/* Tagesprogramm aus data/veranstaltungen.json → programm[] (dieselbe Liste wie
+ die Website-Detailseite, hier fürs A5-Blatt gesetzt): je Tag ein Abel-Caps-
+ Tagestitel, darunter Zeit | Titel. Die Zeit trägt links den roten Strich wie
+ .veranstaltung-programm__zeit auf der Website — gleiche Sprache, andere
+ Skala. Maße in cqi (Selbst-Containment, siehe .stadionzeitung-page). */
+/* Abstände bewusst straff: ein dreitägiges Fest bringt gut zwölf Punkte plus
+ drei Tagestitel auf EIN A5-Blatt (gemessen: mit den üblichen 3.3cqi lief die
+ Seite 59px über die Blattkante). Wer hier lockerer setzt, misst die
+ Seitenhöhe nach. */
+.stadionzeitung-programm__tag {
+ margin-bottom: 2.2cqi;
+}
+
+.stadionzeitung-programm__tag:last-child {
+ margin-bottom: 0;
+}
+
+.stadionzeitung-programm__tag-titel {
+ margin-bottom: 0.9cqi;
+ padding-bottom: 0.6cqi;
+ border-bottom: 1px solid var(--clr-glass-border);
+ font-family: var(--ff-body);
+ font-weight: var(--fw-bold);
+ font-size: 2.9cqi;
+ text-transform: uppercase;
+ letter-spacing: var(--ls-caps);
+ color: var(--clr-text-muted);
+}
+
+.stadionzeitung-programm__liste {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.stadionzeitung-programm__punkt {
+ display: grid;
+ grid-template-columns: 11cqi minmax(0, 1fr);
+ gap: 2.2cqi;
+ align-items: baseline;
+ padding-block: 0.5cqi;
+}
+
+.stadionzeitung-programm__zeit {
+ padding-left: 1.4cqi;
+ border-left: 2px solid var(--clr-accent);
+ font-family: var(--ff-body);
+ font-weight: var(--fw-bold);
+ font-size: 3.3cqi;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.stadionzeitung-programm__titel {
+ display: block;
+ font-size: 3.3cqi;
+ line-height: 1.3;
+}
+
+.stadionzeitung-programm__text {
+ display: block;
+ margin-top: 0.3cqi;
+ font-size: 2.7cqi;
+ line-height: 1.3;
+ color: var(--clr-text-muted);
+}
+
+/* === stadionzeitung-turnier (Live-Seite, Sportfest-Ausgabe) === */
+/* Ein Jugendturnier pro Blatt: Spielplan mit LEERER Ergebnisspalte
+ (Schreiblinien) und leere Abschlusstabelle — das Blatt ist zum Eintragen am
+ Platz gedacht, deshalb echte Schreibhöhe in den leeren Zellen. Eigenes Team
+ wie league-table tr.is-own (rote Tönung + Abel-Fett). Tabellenmaße an
+ .stadionzeitung-page .league-table angelehnt (2.7/2.3cqi). Der Modifier
+ --dicht (ab neun Spielen, E-Jugend) rückt die Zeilen enger, sonst läuft die
+ Abschlusstabelle über die Blattkante. */
+.stadionzeitung-turnier__label {
+ caption-side: top;
+ text-align: left;
+ margin: 3.3cqi 0 1.2cqi;
+ font-family: var(--ff-body);
+ font-weight: var(--fw-bold);
+ font-size: 2.9cqi;
+ text-transform: uppercase;
+ letter-spacing: var(--ls-caps);
+ color: var(--clr-text-muted);
+}
+
+p.stadionzeitung-turnier__label:first-of-type {
+ margin-top: 0;
+}
+
+.stadionzeitung-turnier__teams {
+ font-size: 3.1cqi;
+ line-height: 1.5;
+}
+
+.stadionzeitung-turnier__spiele,
+.stadionzeitung-turnier__tabelle {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 2.9cqi;
+ font-variant-numeric: tabular-nums;
+}
+
+.stadionzeitung-turnier__spiele thead th,
+.stadionzeitung-turnier__tabelle thead th {
+ padding: 0.8cqi 0.9cqi;
+ border-bottom: 1px solid var(--clr-glass-border-strong);
+ font-family: var(--ff-body);
+ font-weight: var(--fw-bold);
+ font-size: 2.3cqi;
+ text-transform: uppercase;
+ letter-spacing: var(--ls-caps);
+ color: var(--clr-text-muted);
+ text-align: left;
+}
+
+.stadionzeitung-turnier__spiele td,
+.stadionzeitung-turnier__tabelle td {
+ padding: 1cqi 0.9cqi;
+ border-bottom: 1px solid var(--clr-glass-border);
+ vertical-align: middle;
+ line-height: 1.25;
+}
+
+/* Dichte Variante ab neun Spielen: die E-Jugend spielt zehn Partien auf zwei
+ Feldern und braucht darunter noch eine Tabelle mit fünf Zeilen. Mit den
+ normalen Abständen lief das Blatt 69px über. Alle vier Stellen unten gehören
+ zusammen — eine allein reicht nicht. */
+.stadionzeitung-turnier--dicht .stadionzeitung-turnier__spiele td {
+ padding-block: 0.3cqi;
+}
+
+/* Die Zeilenhöhe des Spielplans macht NICHT die Schriftgröße, sondern die
+ Schreiblinie: sie ist das höchste Element in der Zeile. Bei zehn Spielen
+ zählt deshalb nur diese Stelle (eine kleinere Schrift brachte gemessen
+ einen einzigen Pixel). 2.8cqi sind auf A5 gut 4mm über der Linie, genug
+ für zwei Ziffern mit dem Stift. */
+.stadionzeitung-turnier--dicht .stadionzeitung-turnier__blank {
+ height: 2.8cqi;
+}
+
+.stadionzeitung-turnier--dicht .stadionzeitung-turnier__tabelle td {
+ padding-block: 0.6cqi;
+}
+
+/* 4cqi Zellhöhe sind auf A5 knapp 6mm plus Innenabstand — genug, um Tore und
+ Punkte mit dem Stift einzutragen, und es passt neben zehn Spielzeilen. */
+.stadionzeitung-turnier--dicht .stadionzeitung-turnier__tabelle td:empty {
+ height: 4cqi;
+}
+
+.stadionzeitung-turnier--dicht .stadionzeitung-turnier__label {
+ margin: 1.8cqi 0 0.7cqi;
+}
+
+.stadionzeitung-turnier--dicht .stadionzeitung-turnier__teams {
+ font-size: 2.9cqi;
+ line-height: 1.35;
+}
+
+.stadionzeitung-turnier__nr {
+ width: 6cqi;
+ color: var(--clr-text-muted);
+}
+
+.stadionzeitung-turnier__zeit {
+ width: 9cqi;
+ white-space: nowrap;
+}
+
+.stadionzeitung-turnier__paarung {
+ width: 100%;
+}
+
+.stadionzeitung-turnier__vs {
+ color: var(--clr-text-muted);
+}
+
+.stadionzeitung-turnier__team.is-own {
+ padding-inline: 0.6cqi;
+ border-radius: 0.6cqi;
+ background: rgb(var(--clr-accent-rgb) / 0.16);
+ font-weight: var(--fw-bold);
+}
+
+/* Leere Ergebnisspalte: zwei Schreiblinien um den Doppelpunkt, unten
+ ausgerichtet wie eine Zeile zum Ausfüllen. */
+.stadionzeitung-turnier__ergebnis {
+ white-space: nowrap;
+ text-align: center;
+}
+
+.stadionzeitung-turnier__blank {
+ display: inline-block;
+ width: 6cqi;
+ height: 3.4cqi;
+ border-bottom: 1px solid var(--clr-text-faint);
+ vertical-align: baseline;
+}
+
+.stadionzeitung-turnier__colon {
+ display: inline-block;
+ padding-inline: 0.8cqi;
+ font-weight: var(--fw-bold);
+}
+
+/* Schreibhöhe der leeren Tabellenzellen (Platzierung wird von Hand eingetragen). */
+.stadionzeitung-turnier__tabelle td:empty {
+ height: 5.6cqi;
+}
+
+.stadionzeitung-turnier__tabelle th:nth-child(n + 3),
+.stadionzeitung-turnier__tabelle td:nth-child(n + 3) {
+ width: 9cqi;
+ text-align: center;
+}
+
/* === stadionzeitung-news (Live-Seite) === */
/* Eigene Liste statt des geteilten news-feed von /news: feste Medienspalte
(Wappen/Foto/Icon-Badge in EINER Größe), damit alle Titel bündig stehen —
@@ -7864,6 +8159,57 @@ a.news-feature__link:hover .news-feature__more {
color: var(--clr-text);
}
+/* === stadionzeitung-danke (Live-Seite) === */
+/* Dank an die Partner: oben die platzierte Anzeige (Motiv aus dem Seiteneintrag,
+ siehe stadionzeitung-danke.php), darunter Kopf, Logo-Wand und der QR auf
+ /partner-werden. Vier Logos pro Reihe in EINER festen Zellhöhe, damit
+ unterschiedlich hohe Logos auf einer Linie sitzen — dieselbe Idee wie die
+ feste Medienspalte auf der News-Seite. Maße in cqi (Selbst-Containment). */
+.stadionzeitung-danke {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+}
+
+.stadionzeitung-danke__anzeige {
+ width: 100%;
+ height: auto;
+ margin-bottom: 5cqi;
+ border: 1px solid var(--clr-glass-border);
+}
+
+.stadionzeitung-danke__head {
+ margin-bottom: 3.3cqi;
+}
+
+.stadionzeitung-danke__logos {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 3.3cqi 2.5cqi;
+ margin: 0 0 auto;
+ padding: 0;
+ list-style: none;
+}
+
+.stadionzeitung-danke__logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ /* Feste Zellhöhe: Logos sind teils quer, teils hoch — ohne sie tanzt die Reihe. */
+ height: 11cqi;
+}
+
+/* Logos liegen hier auf Weiß statt auf dem dunklen Website-Grund: contain statt
+ cover, damit nichts beschnitten wird, und keine Tönung — ein Sponsorenlogo
+ wird nicht umgefärbt. */
+.stadionzeitung-danke__logo img {
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+}
+
/* === stadionzeitung-momente (Live-Seite, Heft-Abschluss) === */
/* 2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-Ticket — quadratische
Kacheln wie im Instagram-Raster, bewusst ohne Bildunterschriften: die
diff --git a/public/assets/img/anzeigen/emons-1141.jpg b/public/assets/img/anzeigen/emons-1141.jpg
new file mode 100644
index 0000000..7db2ecd
Binary files /dev/null and b/public/assets/img/anzeigen/emons-1141.jpg differ
diff --git a/public/assets/img/anzeigen/emons-480.jpg b/public/assets/img/anzeigen/emons-480.jpg
new file mode 100644
index 0000000..27a13d6
Binary files /dev/null and b/public/assets/img/anzeigen/emons-480.jpg differ
diff --git a/public/assets/img/anzeigen/emons-960.jpg b/public/assets/img/anzeigen/emons-960.jpg
new file mode 100644
index 0000000..b5b7240
Binary files /dev/null and b/public/assets/img/anzeigen/emons-960.jpg differ
diff --git a/public/assets/img/anzeigen/emons-partnerdank-1440.jpg b/public/assets/img/anzeigen/emons-partnerdank-1440.jpg
deleted file mode 100644
index e03c069..0000000
Binary files a/public/assets/img/anzeigen/emons-partnerdank-1440.jpg and /dev/null differ
diff --git a/public/assets/img/anzeigen/emons-partnerdank-480.jpg b/public/assets/img/anzeigen/emons-partnerdank-480.jpg
deleted file mode 100644
index 770c24e..0000000
Binary files a/public/assets/img/anzeigen/emons-partnerdank-480.jpg and /dev/null differ
diff --git a/public/assets/img/anzeigen/emons-partnerdank-960.jpg b/public/assets/img/anzeigen/emons-partnerdank-960.jpg
deleted file mode 100644
index 7d0a418..0000000
Binary files a/public/assets/img/anzeigen/emons-partnerdank-960.jpg and /dev/null differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png
new file mode 100644
index 0000000..92e5efd
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-1410.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-1410.jpg
new file mode 100644
index 0000000..4db91d9
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-1410.jpg differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-480.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-480.jpg
new file mode 100644
index 0000000..3288000
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-480.jpg differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-960.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-960.jpg
new file mode 100644
index 0000000..ccf82c9
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-960.jpg differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-1000.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-1000.png
new file mode 100644
index 0000000..c64c1a0
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-1000.png differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-320.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-320.png
new file mode 100644
index 0000000..6d807e2
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-320.png differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-640.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-640.png
new file mode 100644
index 0000000..cfcd852
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-640.png differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-120.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-120.jpg
new file mode 100644
index 0000000..0dd08af
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-120.jpg differ
diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-222.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-222.jpg
new file mode 100644
index 0000000..aafa29d
Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-222.jpg differ
diff --git a/public/assets/img/stadionzeitung/qr-historie.png b/public/assets/img/stadionzeitung/qr-historie.png
index cb107e9..f32b602 100644
Binary files a/public/assets/img/stadionzeitung/qr-historie.png and b/public/assets/img/stadionzeitung/qr-historie.png differ
diff --git a/public/assets/img/stadionzeitung/qr-instagram.png b/public/assets/img/stadionzeitung/qr-instagram.png
index 28388ab..cbb26be 100644
Binary files a/public/assets/img/stadionzeitung/qr-instagram.png and b/public/assets/img/stadionzeitung/qr-instagram.png differ
diff --git a/public/assets/img/stadionzeitung/qr-mitmachen.png b/public/assets/img/stadionzeitung/qr-mitmachen.png
index adaf047..9f991db 100644
Binary files a/public/assets/img/stadionzeitung/qr-mitmachen.png and b/public/assets/img/stadionzeitung/qr-mitmachen.png differ
diff --git a/public/assets/img/stadionzeitung/qr-news.png b/public/assets/img/stadionzeitung/qr-news.png
index db6f20b..f17e231 100644
Binary files a/public/assets/img/stadionzeitung/qr-news.png and b/public/assets/img/stadionzeitung/qr-news.png differ
diff --git a/public/assets/img/stadionzeitung/qr-partner-werden.png b/public/assets/img/stadionzeitung/qr-partner-werden.png
index 941103b..f3528cb 100644
Binary files a/public/assets/img/stadionzeitung/qr-partner-werden.png and b/public/assets/img/stadionzeitung/qr-partner-werden.png differ
diff --git a/public/assets/img/stadionzeitung/qr-sportheimbuchung.png b/public/assets/img/stadionzeitung/qr-sportheimbuchung.png
index f44d290..f1d77e6 100644
Binary files a/public/assets/img/stadionzeitung/qr-sportheimbuchung.png and b/public/assets/img/stadionzeitung/qr-sportheimbuchung.png differ
diff --git a/public/assets/img/stadionzeitung/qr-termine.png b/public/assets/img/stadionzeitung/qr-termine.png
index 2d90275..cef1dce 100644
Binary files a/public/assets/img/stadionzeitung/qr-termine.png and b/public/assets/img/stadionzeitung/qr-termine.png differ