Sportfest-Ausgabe der Stadionzeitung zum Heimspiel gegen ATS Wartenfels

Neue Live-Seiten, geschaltet vom chat-gepflegten Feld `veranstaltung` (Slug aus
data/veranstaltungen.json) — Programm und Turnierpläne kommen damit aus derselben
Quelle wie die Website, nicht als Kopie ins Heft:

- Programm-Seite (programm[], rendert zugleich auf /veranstaltungen/sportfest-2026)
- vier Turnierplan-Seiten (turniere[]) mit leerer Ergebnisspalte und leerer
  Abschlusstabelle zum Eintragen am Platz
- Gegnerseite je Mannschaft mit Heimspiel am Fest statt nur der tragenden:
  stadionzeitung_gegner_fixtures() ist die einzige Stelle, die das entscheidet,
  Seitenplanung und Snapshot lesen beide dort. `gegner` ist deshalb eine Map je
  Team-Key; die Vorschau überspringt jedes Spiel mit eigener Gegnerseite
- Danke-Seite mit Partner-Logo-Wand aus data/partners.json und QR auf
  /partner-werden, dazu das Anzeigen-Feld `platzierung` für Motive mit festem
  Platz auf einer Live-Seite (Emons über der Logo-Wand). Vorher klebte der Dank
  als leere Fläche unten an einem Anzeigen-Bild
- Cover einer Sonderausgabe: das Fest als Titelgeschichte statt einer der drei
  Begegnungen, dazu das Hauptplakat schräg gelegt an Stelle des Spielerfotos

Ausgabe 2026-09-04-ats-wartenfels als ENTWURF angelegt, 48 Seiten (12 Bögen),
Tabellen auf gerader Seite, längster Anzeigenblock zwei. Veröffentlicht wird auf
dem Server, dort liegt der frische Matchcenter-Stand.

Nebenbei behoben: bin/anzeige-add.php räumte beim Anlegen alle Motive weg, deren
Slug mit demselben Text beginnt (`emons` löschte `emons-partnerdank` und
`emons-stuebinger`). Muster jetzt auf die Breiten-Endung eingeschränkt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
This commit is contained in:
2026-09-03 10:57:52 +02:00
parent b4d2cde149
commit f2f193b223
40 changed files with 2273 additions and 77 deletions

110
CLAUDE.md
View File

@@ -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/<slug>` 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

View File

@@ -36,11 +36,12 @@ $issues = $issues ?? [];
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
· <?= (int) $issue['pages'] ?> Seiten
</p>
<?php /* Coverzeile des Interviews als Teaser — dieselbe Quelle, die auch auf dem
Cover unter „Im Heft" steht. Sie füllt die breite Zeile mit echtem Inhalt
statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte. Trägt eine
Ausgabe kein Interview, entfällt die Zeile (wie bei .veranstaltung-card__text). */
$kartenTeaser = trim((string) ($issue['interview']['teaser'] ?? '')); ?>
<?php /* Coverzeile als Teaser — dieselbe Quelle (stadionzeitung_teaser()), die auch
auf dem Cover unter „Im Heft" steht: cover.teaser einer Sonderausgabe, sonst
die Coverzeile des Interviews. Sie füllt die breite Zeile mit echtem Inhalt
statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte. Gibt es
beides nicht, entfällt die Zeile (wie bei .veranstaltung-card__text). */
$kartenTeaser = stadionzeitung_teaser($issue); ?>
<?php if ($kartenTeaser !== ''): ?>
<p class="stadionzeitung-card__text"><?= e($kartenTeaser) ?></p>
<?php endif; ?>

View File

@@ -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/<slug>, 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);
?>
<div class="stadionzeitung-cover">
<div class="stadionzeitung-cover<?= $poster !== null ? ' stadionzeitung-cover--poster' : '' ?>">
<?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
<?php endif; ?>
<?php if (!empty($playerImage)): ?>
<?php if ($poster !== null): ?>
<?php /* Alt-Text bewusst leer: der Plakat-Alt-Text aus veranstaltungen.json
beschreibt die Termine, die drei Zeilen weiter unten als Text stehen —
auf dem Cover wäre er eine Dopplung fürs Screenreader-Ohr. */ ?>
<?php component('img', [
'image' => ['base' => $poster['base'], 'widths' => $poster['widths'] ?? [], 'alt' => ''],
'class' => 'stadionzeitung-cover__poster',
'sizes' => '(max-width: 600px) 55vw, 260px',
]); ?>
<?php elseif (!empty($playerImage)): ?>
<?php
/* Varianten-Set (base/widths/ext=png) statt der Originaldatei. Das Original ist
ein Freisteller mit 2584x5093 Pixeln und 12 MB: iOS Safari verwirft Bilder,
@@ -112,6 +158,17 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
</header>
<div class="stadionzeitung-cover__corner">
<?php if ($feature !== null): ?>
<?php /* Sonderausgabe: das Fest ist die Titelgeschichte, nicht eine der
Begegnungen. Titel in der Display-Schrift (Versalien wie der Masthead),
Zeitraum darunter als gesperrte Zeile. */ ?>
<div class="stadionzeitung-cover__feature">
<p class="stadionzeitung-cover__feature-title"><?= e($feature['title']) ?></p>
<?php if ($feature['range'] !== ''): ?>
<p class="stadionzeitung-cover__meta"><?= e($feature['range']) ?></p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="stadionzeitung-cover__headline">
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
ruhiger) — die beiden Logos sprechen für sich. */ ?>
@@ -124,6 +181,7 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($teaser !== ''): ?>
<p class="stadionzeitung-cover__teaser">
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* Danke-Seite der Stadionzeitung (Live-Seite): der Dank an unsere Partner mit
* der Logo-Wand aus data/partners.json (Single Source, dieselbe Quelle wie die
* Startseite) und dem QR auf /partner-werden. Gegenstück zur Akquise-Seite
* stadionzeitung-partner.php: hier stehen die, die schon dabei sind.
*
* Vorgeschichte: der Dank war Teil eines Canva-Bilds, das unten an die
* Emons-Anzeige geklebt war — eine leere Fläche mit Wappen und zwei Zeilen
* (Felix, 03.09.2026: „sieht schrecklich aus"). Seitdem eine echte Seite, die
* die Partner auch zeigt statt nur zu erwähnen.
*
* Die Anzeige oben ist KEIN Bestandteil der Komponente, sondern kommt als Prop:
* bin/stadionzeitung-add.php nimmt Anzeigen mit `platzierung: danke` aus dem
* Streu-Pool und schreibt das Motiv in den Seiteneintrag. Dadurch ist es in
* images[] eingefroren wie jede andere Anzeige — eine veröffentlichte Ausgabe
* ändert sich nicht mehr, wenn der Sponsor später ein neues Motiv liefert.
*
* Props: $anzeige (Varianten-Set {base,widths,alt} oder null)
*/
$anzeige = (is_array($anzeige ?? null) && !empty($anzeige['base'])) ? $anzeige : null;
$partners = json_load('partners')['partners'] ?? [];
$qr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
? 'img/stadionzeitung/qr-partner-werden.png'
: null;
?>
<div class="stadionzeitung-danke">
<?php if ($anzeige !== null): ?>
<?php component('img', ['image' => $anzeige, 'sizes' => '420px', 'class' => 'stadionzeitung-danke__anzeige']); ?>
<?php endif; ?>
<header class="stadionzeitung-page__head stadionzeitung-danke__head">
<p class="stadionzeitung-page__kicker">Unsere Partner</p>
<?php /* Überschrift bewusst OHNE Anrede: Partner sind Unternehmen, die wir mit
„Sie" ansprechen (Sprachregel, CLAUDE.md), ein „danke, dass ihr dabei
seid" würde sie duzen. „Danke für die Unterstützung" trägt den Dank
ohne diesen Konflikt, der CTA darunter bleibt in der Sie-Form. */ ?>
<h2 class="stadionzeitung-page__heading">Danke für die Unterstützung</h2>
</header>
<?php if ($partners !== []): ?>
<ul class="stadionzeitung-danke__logos">
<?php foreach ($partners as $partner): ?>
<?php if (empty($partner['logo'])) { continue; } ?>
<li class="stadionzeitung-danke__logo">
<img src="<?= e(asset((string) $partner['logo'])) ?>" alt="<?= e((string) ($partner['name'] ?? '')) ?>"<?= img_intrinsic_attrs((string) $partner['logo']) ?> loading="lazy" decoding="async">
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mitmachen</p>
<p class="stadionzeitung-page__cta-title">Werden Sie unser Partner</p>
<p class="stadionzeitung-page__cta-link"><?= e(club_domain('partner-werden')) ?></p>
</div>
<?php if ($qr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -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
?>
<div class="stadionzeitung-gegner">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Unser Gegner</p>
<p class="stadionzeitung-page__kicker"><?= e($teamName !== '' ? 'Unser Gegner · ' . $teamName : 'Unser Gegner') ?></p>
<h2 class="stadionzeitung-page__heading"><?= e($opponentName) ?></h2>
<?php if (!empty($info['league']) || !empty($info['season'])): ?>
<p class="stadionzeitung-page__sub"><?= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?></p>
@@ -70,9 +75,12 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
<?php if ($own !== null && $opponent !== null): ?>
<div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich">
<div class="stadionzeitung-gegner__vergleich-head" role="row">
<span role="columnheader">TSV 08</span>
<?php /* Spaltenköpfe generisch: vorher standen hier „TSV 08" und „ASV" fest im
Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe
mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?>
<span role="columnheader">Wir</span>
<span role="columnheader">Vergleich</span>
<span role="columnheader">ASV</span>
<span role="columnheader">Gegner</span>
</div>
<?php foreach ($rows as $row): ?>
<div class="stadionzeitung-gegner__vergleich-row" role="row">

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* Programm-Seite der Stadionzeitung (Live-Seite, Sportfest-Ausgabe): das
* Tagesprogramm einer Veranstaltung, je Tag eine Gruppe (Wochentag + Datum),
* darunter Zeit | Titel (+ Zusatz). Kopf im Muster der Geschwister-Seiten.
*
* Quelle ist AUSSCHLIESSLICH data/veranstaltungen.json → programm[] der
* Veranstaltung, auf die die Ausgabe zeigt (`veranstaltung`-Feld, Slug). Dieselbe
* Liste rendert die Website-Detailseite über veranstaltung-programm.php — eine
* Quelle für Heft und Website, hier nur eine zweite Darstellung fürs A5-Blatt
* (cqi-Maße statt Website-Tokens). Bewusst live, kein Snapshot: chat-gepflegte
* Daten wie bei kontakt/aufruf, kein Sync räumt hier etwas weg.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json)
*/
$issue = $issue ?? [];
$v = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
$programm = is_array($v['programm'] ?? null) ? $v['programm'] : [];
if ($v === null || $programm === []) {
return;
}
$zeitraum = german_date_range((string) ($v['date_start'] ?? ''), $v['date_end'] ?? null);
$sub = trim(implode(' · ', array_filter([$zeitraum, (string) ($v['location'] ?? '')])));
?>
<div class="stadionzeitung-programm">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker"><?= e((string) ($v['title'] ?? 'Veranstaltung')) ?></p>
<h2 class="stadionzeitung-page__heading">Programm</h2>
<?php if ($sub !== ''): ?>
<p class="stadionzeitung-page__sub"><?= e($sub) ?></p>
<?php endif; ?>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php foreach ($programm as $tag): ?>
<?php
$tagIso = (string) ($tag['tag'] ?? '');
$weekday = $tagIso !== '' ? german_weekday($tagIso) : '';
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . ($tagIso !== '' ? german_date($tagIso) : ''));
$punkte = is_array($tag['punkte'] ?? null) ? $tag['punkte'] : [];
if ($punkte === []) {
continue;
}
?>
<section class="stadionzeitung-programm__tag">
<?php if ($tagText !== ''): ?>
<h3 class="stadionzeitung-programm__tag-titel"><time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time></h3>
<?php endif; ?>
<ul class="stadionzeitung-programm__liste">
<?php foreach ($punkte as $punkt): ?>
<?php $zeit = trim((string) ($punkt['zeit'] ?? '')); ?>
<li class="stadionzeitung-programm__punkt">
<?php if ($zeit !== ''): ?>
<span class="stadionzeitung-programm__zeit"><time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time></span>
<?php else: ?>
<span aria-hidden="true"></span>
<?php endif; ?>
<span class="stadionzeitung-programm__body">
<span class="stadionzeitung-programm__titel"><?= e((string) ($punkt['titel'] ?? '')) ?></span>
<?php if (!empty($punkt['text'])): ?>
<span class="stadionzeitung-programm__text"><?= e((string) $punkt['text']) ?></span>
<?php endif; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
</section>
<?php endforeach; ?>
</div>

View File

@@ -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';
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
<?php break; ?>
<?php case 'gegner': ?>
<?php component('stadionzeitung-gegner', ['info' => $data['gegner'] ?? null]); ?>
<?php
// `gegner` ist eine Map je Team-Key (wie `tabellen`), seit die Sportfest-
// Ausgabe je Heimspiel eine Gegnerseite hat. Ohne team-Feld (Ausgaben vor
// dieser Änderung) greift die tragende Mannschaft.
$gegnerTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
component('stadionzeitung-gegner', ['info' => $data['gegner'][$gegnerTeam] ?? null]);
?>
<?php break; ?>
<?php case 'programm': ?>
<?php component('stadionzeitung-programm', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'turnier': ?>
<?php component('stadionzeitung-turnier', ['issue' => $issue, 'turnier' => (string) ($entry['turnier'] ?? '')]); ?>
<?php break; ?>
<?php case 'vorschau': ?>
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
@@ -76,6 +91,11 @@ $type = $entry['type'] ?? 'image';
<?php case 'partner': ?>
<?php component('stadionzeitung-partner', []); ?>
<?php break; ?>
<?php case 'danke': ?>
<?php /* Das Anzeigenmotiv steht im Seiteneintrag (platzierung: danke), damit es
wie jede andere Anzeige in images[] eingefroren ist. */ ?>
<?php component('stadionzeitung-danke', ['anzeige' => $entry['anzeige'] ?? null]); ?>
<?php break; ?>
<?php case 'momente': ?>
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
<?php break; ?>

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* Turnierplan-Seite der Stadionzeitung (Live-Seite, Sportfest-Ausgabe): EIN
* Jugendturnier pro Seite — Kopf (Veranstaltung · Tag / „Turnier F1-Jugend" /
* Beginn · Spieldauer · Felder · Modus), Teilnehmer, Spielplan mit LEERER
* Ergebnisspalte und eine leere Abschlusstabelle. Das Blatt ist zum
* Beschreiben gedacht: am Platz trägt man Ergebnisse und Plätze von Hand ein.
*
* Quelle ist AUSSCHLIESSLICH data/veranstaltungen.json → turniere[] der
* Veranstaltung, auf die die Ausgabe zeigt (`veranstaltung`-Feld); welches
* Turnier, sagt der `turnier`-Key des Seiteneintrags. Bewusst live, kein
* Snapshot (chat-gepflegte Daten, wie kontakt/aufruf/programm).
*
* Eintrag: key, title, date, start, spieldauer (Minuten), felder? (Zahl),
* modus? (Freitext), teams[], spiele[]{nr, zeit, feld?, heim, gast}
*
* Eigenes Team wie league-table tr.is-own hervorgehoben (Tönung + Abel-Fett);
* erkannt über den Vereinsnamen aus data/club.json als Präfix, damit auch
* „TSV 08 Kulmbach 2" getroffen wird.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json), $turnier (Key)
*/
$issue = $issue ?? [];
$turnierKey = trim((string) ($turnier ?? ''));
$v = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
$t = null;
foreach ($v['turniere'] ?? [] as $eintrag) {
if (is_array($eintrag) && (string) ($eintrag['key'] ?? '') === $turnierKey) {
$t = $eintrag;
break;
}
}
if ($v === null || $t === null) {
return;
}
$clubName = trim((string) (json_load('club')['name'] ?? 'TSV 08 Kulmbach'));
$isOwn = static fn (string $team): bool => $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;
?>
<div class="stadionzeitung-turnier<?= $dicht ? ' stadionzeitung-turnier--dicht' : '' ?>">
<header class="stadionzeitung-page__head">
<?php if ($kicker !== ''): ?>
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
<?php endif; ?>
<h2 class="stadionzeitung-page__heading"><?= e('Turnier ' . $title) ?></h2>
<?php if ($sub !== ''): ?>
<p class="stadionzeitung-page__sub"><?= e($sub) ?></p>
<?php endif; ?>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($teams !== []): ?>
<p class="stadionzeitung-turnier__label">Teilnehmer</p>
<p class="stadionzeitung-turnier__teams">
<?php foreach ($teams as $i => $team): ?>
<?= $i > 0 ? ' · ' : '' ?><span class="stadionzeitung-turnier__team<?= $isOwn($team) ? ' is-own' : '' ?>"><?= e($team) ?></span>
<?php endforeach; ?>
</p>
<?php endif; ?>
<?php if ($spiele !== []): ?>
<table class="stadionzeitung-turnier__spiele">
<?php /* Sichtbares Label IST die Tabellenbeschriftung (caption), nicht zusätzlich
eine versteckte: sonst liest ein Screenreader beides und damit doppelt. */ ?>
<caption class="stadionzeitung-turnier__label">Spielplan</caption>
<thead>
<tr>
<th scope="col">Nr.</th>
<th scope="col">Zeit</th>
<?php if ($hatFeld): ?>
<th scope="col">Feld</th>
<?php endif; ?>
<th scope="col">Begegnung</th>
<th scope="col" class="stadionzeitung-turnier__ergebnis">Ergebnis</th>
</tr>
</thead>
<tbody>
<?php foreach ($spiele as $i => $spiel): ?>
<?php
$heim = trim((string) ($spiel['heim'] ?? ''));
$gast = trim((string) ($spiel['gast'] ?? ''));
$zeit = trim((string) ($spiel['zeit'] ?? ''));
?>
<tr>
<td class="stadionzeitung-turnier__nr"><?= e((string) ($spiel['nr'] ?? ($i + 1))) ?></td>
<td class="stadionzeitung-turnier__zeit"><?php if ($zeit !== ''): ?><time datetime="<?= e($date !== '' ? $date . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time><?php endif; ?></td>
<?php if ($hatFeld): ?>
<td class="stadionzeitung-turnier__nr"><?= e((string) ($spiel['feld'] ?? '')) ?></td>
<?php endif; ?>
<td class="stadionzeitung-turnier__paarung"><span class="stadionzeitung-turnier__team<?= $isOwn($heim) ? ' is-own' : '' ?>"><?= e($heim) ?></span> <span class="stadionzeitung-turnier__vs">gegen</span> <span class="stadionzeitung-turnier__team<?= $isOwn($gast) ? ' is-own' : '' ?>"><?= e($gast) ?></span></td>
<td class="stadionzeitung-turnier__ergebnis"><span class="stadionzeitung-turnier__blank" aria-hidden="true"></span><span class="stadionzeitung-turnier__colon">:</span><span class="stadionzeitung-turnier__blank" aria-hidden="true"></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($teams !== []): ?>
<table class="stadionzeitung-turnier__tabelle">
<caption class="stadionzeitung-turnier__label">Platzierung zum Eintragen</caption>
<thead>
<tr>
<th scope="col">Pl.</th>
<th scope="col">Teilnehmer</th>
<th scope="col"><abbr title="Tore">T</abbr></th>
<th scope="col"><abbr title="Tordifferenz">TD</abbr></th>
<th scope="col"><abbr title="Punkte">Pkt</abbr></th>
</tr>
</thead>
<tbody>
<?php for ($i = 1; $i <= count($teams); $i++): ?>
<tr>
<td class="stadionzeitung-turnier__nr"><?= $i ?>.</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<?php endfor; ?>
</tbody>
</table>
<?php endif; ?>
</div>

View File

@@ -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

View File

@@ -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'] ?? ''),

View File

@@ -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

View File

@@ -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 (`-<zahl>.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);
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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"
}
]
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 B

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 B

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 B

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B