Compare commits

..

15 Commits

Author SHA1 Message Date
fs
e761231442 Regel ergaenzen: nach dem Veroeffentlichen auf dem Server committen und pushen
Der Snapshot und die eingefrorenen fix-*-Bilder existieren nach
bin/stadionzeitung-publish.php zunaechst nur auf dem Server. Bleiben sie dort
uncommittet, scheitert der naechste Pull, und wer die Aenderung verwirft,
verliert veroeffentlichte Ausgaben. Am 03.09.2026 mit drei Ausgaben passiert;
der Reparaturweg (Server als Wahrheit, Zusammenfuehren je Slug) steht jetzt mit
dabei.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 11:13:33 +02:00
0a01134583 Sportfest-Ausgabe veroeffentlicht 2026-09-03 11:12:31 +02:00
5854e452f7 Veroeffentlichte Ausgaben und eingefrorene Bilder vom Server sichern 2026-09-03 11:11:29 +02:00
fs
f2f193b223 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
2026-09-03 10:57:52 +02:00
fs
b4d2cde149 Jugend-Bento: Abteilungsfoto mobil ungeschnitten (3:4 statt 16:10)
Das hochkante Gruppenfoto (1000x1333) wurde auf dem Handy in eine
16:10-Querkachel gecovert und dadurch auf einen Mittelstreifen
zusammengeschnitten. Die Mobil-Kachel bekommt jetzt das echte 3:4-Format,
sodass object-fit: cover passgenau fuellt und niemand beschnitten wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WACMqwVB4JpPW5Wkz1Y1DU
2026-09-01 08:53:39 +02:00
fs
0d80f69a76 Veranstaltung Tagesskifahrt 2027 anlegen (Hochzillertal, 20.02.)
Auswärts-Event ohne Karte (kein karte:club), Preise und Anmeldung als
lesbarer Text für Screenreader/Suche, Plakat über bin/veranstaltung-bilder.php.
Direkt veröffentlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tTL6VE17FgQhhaH4Pc8h
2026-08-31 21:05:26 +02:00
fs
6313b98f9e Jugend: neue Ansprechpartner-Fotos, Abteilungsbild, aktuelle Trainingszeiten
- Fabi & Kerstin als 480x600-Porträts (Kopf/Schultern, oben ausgerichtet),
  Kerstin in ansprechpartner.json verknüpft
- gemeinsames Jugendbild als Intro-Bento-Bild (ersetzt die Hero-Dublette)
- Trainingszeiten auf Fabis Stand, C/B-Jugend zusammengefasst, B-Karte entfällt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tTL6VE17FgQhhaH4Pc8h
2026-08-31 20:59:51 +02:00
fs
f88b5f1dd1 Stadionzeitung gegen ASV Marktschorgast vorbereiten 2026-08-28 13:11:43 +02:00
fs
736a588a88 Turnen: neue Fotos + Fitness/Pilates zu Functional Fitness I/II umgebaut
Feedback der Turnabteilung (Batch 13.–18.08.):
- Kinderturnen, Erwachsenenturnen und Fitness/Pilates bekommen echte
  (Symbolbild-)Fotos in Kachel UND Detail-Hero (statt Luftbild-Platzhalter),
  responsive 640/1000/1536, og_image der drei Seiten mitgezogen.
- fitness-pilates: „Fitness & Stretching" -> „Functional Fitness I" (Mi),
  neuer Kurs „Functional Fitness II" (Do, Uhrzeit folgt), Beschreibungen für
  Pilates und beide Functional-Fitness-Kurse, gestapeltes Layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GueFkHGX17MqfsoraXwGM
2026-08-19 22:29:59 +02:00
fs
550616aacf Stadionzeitung: langes Interview passt wieder auf die A5-Seite
Die vergrößerte Interview-Frage (4.6cqi) schob eine Ausgabe mit drei Fragen um
8px über die Blattkante (gemessen). Frage auf 4.2cqi gedeckelt (bleibt größer
als die Antwort) und Abstände von Frage/Absätzen/Trennlinie leicht gestrafft —
Text unverändert, passt jetzt mit Reserve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFnFYLm2MoBskyk9mc84ZW
2026-08-13 17:35:44 +02:00
fs
c688d155cc Stadionzeitung Ausgabe 2 (TSV Ködnitz, 15.08.): Entwurf + neue Aufruf-Seite
- Neue generische Live-Seite `aufruf` (erste Nutzung: Sportfest-Helferaufruf):
  Komponente, Dispatcher, Skript-Einbindung (Planung im Aufschlag, QR aus
  aufruf.link, Feld-Carryover), CSS, optionales kleines Poster rechts.
- Ausgabe 2026-08-15-tsv-koednitz als ENTWURF: Vorwort (Benedikt, Revanche
  dezent), Interview (Ali, 3 Fragen), Sportfest-Helferseite mit Kontakten aus
  ansprechpartner.json; Rückseite/Cover wie Hutschdorf wiederverwendet.
- Interview-Frage größer/prominenter gesetzt.
- Sportfest-Plakat-Anzeige deaktiviert (redundant zur Helferseite) -> 40 Seiten.

Sync-Dateien (instagram/matchcenter) bleiben gitignored, Snapshot wird beim
Veröffentlichen auf dem Server eingefroren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFnFYLm2MoBskyk9mc84ZW
2026-08-13 17:27:41 +02:00
fs
1839632b66 Turnen: schlichte Gruppen als Bodenreihe direkt ins Bento (groupsInDash)
Neue opt-in-Prop groupsInDash in group-schedule zieht schlichte Gruppen
(ohne intro/details) als volle Bodenreihe MIT ins Dashboard-Bento, ohne
eigene Sektion und ohne "Unsere Gruppen"-Ueberschrift. Trampolin nutzt
das, damit Hinweis, Trainingsort, Kontakt und die drei Gruppen-Karten ein
zusammenhaengender Block sind statt einer losgeloesten Kartenreihe darunter.
Wiederverwendung von .group-list--grid/.group-card, Karten-Markup DRY in
renderSimpleCard. Wettkampfturnen/Kinderturnen bleiben unberuehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXDvgRDHQ9iGjs4KCXykEj
2026-08-03 19:58:39 +02:00
fs
d410c0bedf Trampolin: echte Trainingszeiten, Trainerinnen und Kontakt vom Hallen-Aushang
Drei Karten nach Zielgruppe (Kinder+Erwachsene Mo, Kinder ab 5 Do,
Kinder ab 10 und Fortgeschrittene Do) mit Tag/Zeit und Trainerteam je
Session, Schnupper-Hinweis auf "nach Absprache" umgestellt, Fußnote zu
den Schulferien. group-schedule bekommt zwei optionale Props
(contactEmail/contactPhone) fuer Tinas persoenlichen Kontakt in der
Kontaktkachel (tel:-Muster wie person-card, keine neue CSS-Regel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXDvgRDHQ9iGjs4KCXykEj
2026-08-03 19:49:25 +02:00
fs
133c3f926e „Trainerteam" statt „Übungsleitung", Eva Wünsch mit Sternchen
Eva Wünsch aus der Turnabteilung am 31.07.2026: „Der Sternchentext müsste
eigentlich sein, dass die Person mit dem Sternchen Übungsleiter ist. Von daher
müsste vor den Namen 'Trainerteam' stehen. Einige sind auch nicht ÜL."

Ich hatte davon nur die Fußnote erledigt. Das Label blieb „Übungsleitung" und
damit stand die falsche Aussage weiter auf der Seite: neun namentlich genannte
Personen liefen unter „Übungsleitung", vier davon sind es. Das ist keine
Formulierungsfrage, die Seite behauptete etwas über echte Menschen.

Label jetzt „Trainerteam:" an beiden Stellen (Trainingszeit und Gruppe), der
Stern bleibt die Auszeichnung und die Fußnote erklärt ihn. So stand es auch auf
der alten Seite: „TRAINERTEAM (* = ÜBUNGSLEITER)". Ein Kommentar an der Stelle
sagt, warum es nicht zurückbenannt werden darf.

Dabei fiel ein zweiter Datenfehler auf: die alte Seite führt „Annelie Frisque*,
Eva Wünsch*", bei uns stand Eva Wünsch ohne Stern. Sie ist Übungsleiterin.

Geprüft: „Übungsleitung" kommt auf keiner der sechs Turn-Seiten mehr vor, die
Grashüpfer-Zeile lautet jetzt Zeichen für Zeichen wie in Evas Screenshot. Die
Fußnote erscheint nur in Sektionen mit mehreren Gruppen — nachgezählt, dass
beide Disziplinen mit Sternchen-Namen mehrere Gruppen haben, die
Einzelgruppen-Disziplinen tragen keine Sterne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:57:31 +02:00
fs
146471bae8 Wettkampfturnen: Karten offen untereinander statt hinter Tabs
Bei zwei Gruppen verstecken Tabs mehr Inhalt als sie ordnen, und die offene
Kartenreihe hält den Bento-Eindruck der Seite (Entscheidung Felix). Das
Kinderturnen bleibt bei Tabs, dort sind es drei textreiche Gruppen — die
Flamingos-Karte allein ist am Handy 2142px hoch.

.group-list--stacked lag vollständig im CSS, wurde aber von KEINEM PHP
ausgegeben: totes CSS aus der Zeit vor den Tabs, das heute morgen sogar noch
meine minmax(0, …)-Korrektur mitbekommen hat, ohne dass es jemand sehen konnte.
Jetzt ist es lebendig. Dritter toter Rest heute: "layout": "blocks" in
data/turnen.json las ebenfalls niemand, ist raus.

Der Karteninhalt steht dafür jetzt einmal statt zweimal. Er wird in zwei
Fassungen gebraucht (Tab-Panel mit role/aria und gestapelt ohne) und ist
deshalb eine Closure geworden — eine zweite Kopie wäre die Stelle, an der beide
in einem halben Jahr auseinanderlaufen.

Die Überschrift ist visually-hidden, nicht entfernt: sonst hätte die Sektion
keinen zugänglichen Namen und die Überschriften-Gliederung eine Lücke. Dasselbe
Muster wie $title_hidden in team-cards. Damit braucht die Seite auch kein
group-tabs.js mehr, das ich zwei Commits vorher erst dazugeschrieben hatte.

Zwei Nachbesserungen, beide gemessen:
- Die versteckte Überschrift hinterließ ein Loch. Zwischen Dashboard und erster
  Karte standen 160px (80 unten von der einen Sektion, 80 oben von der anderen),
  in denen nur nackte Hintergrund-Textur zu sehen war — vorher füllte die
  Überschrift diesen Raum. .group-list-section--tight nimmt das obere Padding
  weg, jetzt 80px.
- Die gestapelten Karten hatten feste 40px Innenabstand; auf --pad-card-lg
  umgestellt, dieselbe Korrektur wie heute morgen beim Tab-Panel. Am Desktop
  unverändert 40px, am Handy 28px.

Nicht behoben, weil kein Fehler: die Hintergrund-Textur wirkt in leeren Flächen
weich. Sie ist 1920x2545 groß und wird bei 1478px Fensterbreite auf Faktor 0,77
VERKLEINERT, ist also nicht unscharf gerechnet. Sichtbar wurde sie durch den
Leerraum plus background-attachment: fixed, das sie beim Scrollen stehen lässt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:56:21 +02:00
212 changed files with 6427 additions and 208 deletions

125
CLAUDE.md
View File

@@ -54,10 +54,10 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Partner/Sponsoren | `data/partners.json` | — | | Partner/Sponsoren | `data/partners.json` | — |
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** | | 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`) | | 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 | | 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` 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 | | 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) | | 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[]` | | 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.*`) | | 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 | | 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,17 +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 **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): 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 ⌷ 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 [je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine
Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷ Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis + News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten 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 Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim
Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite 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 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 **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 (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 die Tabellen nebeneinander stehen. **Das rechnet das Skript selbst** (seit 31.07.2026): es verschiebt
@@ -547,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. Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf.
Ein `images[]`-Eintrag hat Ein `images[]`-Eintrag hat
`"type": "image"` (wie gehabt, `base`/`widths`/`alt`) oder `"type": "live"` (`page`: `cover` | `"type": "image"` (wie gehabt, `base`/`widths`/`alt`) oder `"type": "live"` (`page`: `cover` |
`vorwort` | `interview` | `tabelle` | `termine` | `news` | `ergebnisse` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `momente` | `impressum`, kein Bild; `vorwort` | `interview` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `impressum`, kein Bild;
`tabelle` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der Team-Key `tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der
der Ausgabe). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert 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 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. 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 / 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 `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 (zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen
@@ -565,6 +589,22 @@ sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync — **auf
liegt der frische Sync-Stand (`data/matchcenter.json` ist gitignored, lokal also älter). liegt der frische Sync-Stand (`data/matchcenter.json` ist gitignored, lokal also älter).
`--entwurf` zieht zurück (Snapshot wird verworfen). `--entwurf` zieht zurück (Snapshot wird verworfen).
**Nach dem Veröffentlichen auf dem Server SOFORT committen und pushen** — das ist keine Kür,
sondern Teil des Vorgangs. `bin/stadionzeitung-publish.php` schreibt den Snapshot in
`data/stadionzeitung.json` und legt die eingefrorenen `fix-*`-Bilder an; beides existiert danach
zunächst **nur auf dem Server**. Bleibt es dort liegen, passieren zwei Dinge: ein `git pull` auf dem
Server scheitert beim nächsten Deploy an der geänderten Datei, und wer sie dort verwirft, verliert
die veröffentlichten Ausgaben ersatzlos (ein lokaler Stand setzt sie auf `entwurf` zurück, weil er
die Snapshots nie gesehen hat).
**Am 03.09.2026 genau so eingetreten:** drei veröffentlichte Ausgaben (01.08., 15.08., 29.08.)
lagen samt Snapshots und 70 `fix-*`-Dateien ausschließlich auf dem Server, der Pull der
Sportfest-Ausgabe brach mit „local changes would be overwritten" ab. Reparatur, in dieser
Reihenfolge: Server-Datei außerhalb des Repos sichern → `git checkout --` + `git pull` →
Ausgaben-Listen **je Slug** zusammenführen, **Server als Wahrheit**, aus dem Repo nur ergänzen was
dort fehlt → `git add data/stadionzeitung.json public/assets/img/stadionzeitung` → committen und
pushen. Ein Merge über den Dateiinhalt wäre keine Option gewesen: 1600 geänderte Zeilen JSON,
Konflikte mitten in eingefrorenen Datensätzen.
**Mit dem Snapshot werden auch die BILDER eingefroren** (seit 31.07.2026): das Skript kopiert **Mit dem Snapshot werden auch die BILDER eingefroren** (seit 31.07.2026): das Skript kopiert
alles, worauf er zeigt (`img/crests/`, `img/instagram/`, `img/news/`), als `fix-*` in den alles, worauf er zeigt (`img/crests/`, `img/instagram/`, `img/news/`), als `fix-*` in den
Ausgaben-Ordner und schreibt die Pfade um. **Grund:** vorher fror nur die Daten ein, die Pfade Ausgaben-Ordner und schreibt die Pfade um. **Grund:** vorher fror nur die Daten ein, die Pfade
@@ -634,6 +674,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 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). 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 — **Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite —
`app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein** `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 Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das

View File

@@ -5,7 +5,12 @@ declare(strict_types=1);
/** /**
* Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props: * Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props:
* $location array{venue, street?, zip?, city?} (optional) — Trainingsort + „Route planen" * $location array{venue, street?, zip?, city?} (optional) — Trainingsort + „Route planen"
* $lead string (optional) — verantwortliche Übungsleitung (Akzent-Kachel) * $lead string (optional) — Ansprechperson der Disziplin (Akzent-Kachel)
* $contactEmail string (optional) — persönliche E-Mail der Ansprechperson für die
* Kontakt-Kachel. Überschreibt die Abteilungs-Mail (club.email_turnen), falls
* gesetzt — genutzt, wenn eine Disziplin einen eigenen direkten Draht angibt.
* $contactPhone string (optional) — Telefonnummer der Ansprechperson (Anzeige-Form). Ergibt
* einen zusätzlichen tel:-Link in der Kontakt-Kachel. tel:-Href wie in person-card.php.
* $notice string (optional) — hervorgehobener Hinweis (z. B. Wartelisten-Status) * $notice string (optional) — hervorgehobener Hinweis (z. B. Wartelisten-Status)
* $noticeTitle string (optional) — Überschrift dazu * $noticeTitle string (optional) — Überschrift dazu
* $noticeTone 'info' (Default) | 'stop' — Tonlage, siehe components/notice.php * $noticeTone 'info' (Default) | 'stop' — Tonlage, siehe components/notice.php
@@ -24,6 +29,19 @@ declare(strict_types=1);
* das Label kennt das Geschlecht der Person nicht (Haus-Stil Paarformen). * das Label kennt das Geschlecht der Person nicht (Haus-Stil Paarformen).
* $footnote string (optional) — Erläuterung (z. B. zur Sternchen-Kennzeichnung) * $footnote string (optional) — Erläuterung (z. B. zur Sternchen-Kennzeichnung)
* $groupsTitle string (optional, Default 'Unsere Gruppen') * $groupsTitle string (optional, Default 'Unsere Gruppen')
* $groupsTitleHidden bool (optional) — H2 bleibt im DOM (Dokumentgliederung für
* Screenreader, Sektion behält ihren Namen), ist aber visuell versteckt.
* Dasselbe Muster wie $title_hidden in team-cards. NICHT die Überschrift
* weglassen: dann hätte die Sektion keinen zugänglichen Namen mehr.
* $layout 'tabs' (Default) | 'stacked' — greift nur bei beschreibenden Gruppen.
* 'stacked' zeigt alle Karten offen untereinander (wenige Gruppen),
* 'tabs' ordnet viele textreiche Gruppen (Kinderturnen).
* $groupsInDash bool (optional) — nur für SCHLICHTE Gruppen (ohne intro/details): zieht die
* Gruppen-Karten als volle Bodenreihe MIT INS Bento-Dashboard, ohne eigene Sektion
* und ohne „Unsere Gruppen"-Überschrift. Für Disziplinen mit wenigen kurzen Gruppen
* (Trampolin), damit das Dashboard ein zusammenhängender Block bleibt statt einer
* losgelösten Kartenreihe darunter. Bei beschreibenden Gruppen (Wettkampfturnen,
* Kinderturnen) wirkungslos — die brauchen Platz und ihre eigene Sektion.
* $anchor string (optional, Default 'training') * $anchor string (optional, Default 'training')
* *
* Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard. * Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard.
@@ -32,12 +50,17 @@ declare(strict_types=1);
*/ */
$anchor = $anchor ?? 'training'; $anchor = $anchor ?? 'training';
$groupsTitle = $groupsTitle ?? 'Unsere Gruppen'; $groupsTitle = $groupsTitle ?? 'Unsere Gruppen';
$groupsTitleHidden = $groupsTitleHidden ?? false;
$layout = ($layout ?? 'tabs') === 'stacked' ? 'stacked' : 'tabs';
$lead = $lead ?? ''; $lead = $lead ?? '';
$contactEmail = $contactEmail ?? '';
$contactPhone = $contactPhone ?? '';
$notice = $notice ?? ''; $notice = $notice ?? '';
$noticeTitle = $noticeTitle ?? ''; $noticeTitle = $noticeTitle ?? '';
$noticeTone = $noticeTone ?? 'info'; $noticeTone = $noticeTone ?? 'info';
$noticeIcon = $noticeIcon ?? 'info-circle'; $noticeIcon = $noticeIcon ?? 'info-circle';
$footnote = $footnote ?? ''; $footnote = $footnote ?? '';
$groupsInDash = !empty($groupsInDash);
$groups = $groups ?? []; $groups = $groups ?? [];
$location = $location ?? []; $location = $location ?? [];
@@ -46,7 +69,8 @@ if ($groups === []) {
} }
$club = json_load('club'); $club = json_load('club');
$email = $club['email_turnen'] ?? ($club['email'] ?? ''); $email = $contactEmail !== '' ? $contactEmail : ($club['email_turnen'] ?? ($club['email'] ?? ''));
$telHref = $contactPhone !== '' ? 'tel:' . preg_replace('/[^+\d]/', '', $contactPhone) : '';
$single = count($groups) === 1; $single = count($groups) === 1;
$rich = false; $rich = false;
@@ -57,6 +81,9 @@ foreach ($groups as $g) {
} }
} }
// Schlichte Gruppen direkt ins Dashboard ziehen (nur wenn mehrere und keine „reich").
$groupsInline = $groupsInDash && !$single && !$rich;
$hasVenue = !empty($location['venue']); $hasVenue = !empty($location['venue']);
$mapsQuery = $hasVenue ? rawurlencode(trim( $mapsQuery = $hasVenue ? rawurlencode(trim(
($location['venue'] ?? '') . ', ' ($location['venue'] ?? '') . ', '
@@ -65,7 +92,14 @@ $mapsQuery = $hasVenue ? rawurlencode(trim(
', ' ', '
)) : ''; )) : '';
/** Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Übungsleitung) ausgeben. */ /**
* Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Trainerteam) ausgeben.
*
* Das Label heißt „Trainerteam", nicht „Übungsleitung": in der Liste stehen auch
* Personen, die KEINE Übungsleitung sind (Eva Wünsch aus der Abteilung, 31.07.2026).
* Wer eine ist, trägt ein Sternchen, und das erklärt die Fußnote der Sektion. Die alte
* Seite schrieb dafür „TRAINERTEAM (* = ÜBUNGSLEITER)". Nicht zurückbenennen.
*/
$renderSession = static function (array $s): void { $renderSession = static function (array $s): void {
?> ?>
<li class="schedule-row"> <li class="schedule-row">
@@ -80,96 +114,20 @@ $renderSession = static function (array $s): void {
<span class="schedule-row__note text-muted"><?= e($s['note']) ?></span> <span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($s['trainers'])): ?> <?php if (!empty($s['trainers'])): ?>
<span class="schedule-row__trainers"><span class="schedule-row__trainers-label">Übungsleitung:</span> <?= e(implode(', ', $s['trainers'])) ?></span> <span class="schedule-row__trainers"><span class="schedule-row__trainers-label">Trainerteam:</span> <?= e(implode(', ', $s['trainers'])) ?></span>
<?php endif; ?> <?php endif; ?>
</li> </li>
<?php <?php
}; };
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : []; /**
$soleNote = $single ? ($groups[0]['note'] ?? '') : ''; * Inhalt einer beschreibenden Gruppen-Karte (ohne das <article> drumherum). Als Closure,
?> * weil es diese Karte in zwei Fassungen gibt: als Tab-Panel (mit role/aria) und gestapelt
<section class="section turnen-dash-section" id="<?= e($anchor) ?>"> * (ohne). Vorher stand das Markup nur in der Tab-Fassung; eine zweite Kopie daneben wäre
<div class="container"> * die Stelle, an der beide auseinanderlaufen.
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?>"> */
<?php if ($notice !== ''): ?> $renderCardBody = static function (array $g) use ($renderSession): void {
<?php component('notice', [ ?>
'title' => $noticeTitle,
'text' => $notice,
'tone' => $noticeTone,
'icon' => $noticeIcon,
'class' => 'turnen-dash__notice',
]); ?>
<?php endif; ?>
<?php if ($single): ?>
<article class="bento__tile turnen-dash__training">
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
<ul class="bento__sessions">
<?php foreach ($soleSessions as $s): ?>
<li>
<span class="bento__day"><?= e($s['day']) ?></span>
<span class="bento__time"><?= e($s['time']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php if ($soleNote !== ''): ?>
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
<?php endif; ?>
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
</article>
<?php endif; ?>
<?php if ($lead !== '' || $email !== ''): ?>
<article class="bento__tile bento__tile--accent turnen-dash__contact">
<span class="bento__stat-label">Kontakt</span>
<?php if ($lead !== ''): ?>
<span class="bento__stat-value"><?= e($lead) ?></span>
<?php endif; ?>
<?php if ($email !== ''): ?>
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
<?php endif; ?>
</article>
<?php endif; ?>
<?php if ($hasVenue): ?>
<article class="bento__tile turnen-dash__venue">
<div class="turnen-dash__venue-main">
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
<p class="bento__address">
<strong><?= e($location['venue']) ?></strong>
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
</p>
</div>
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<?php endif; ?>
</div>
</div>
</section>
<?php if (!$single): ?>
<section class="section group-list-section" id="gruppen">
<div class="container">
<h2 class="group-list__title"><?= e($groupsTitle) ?></h2>
<?php if ($rich): ?>
<div class="group-tabs" data-group-tabs>
<div class="group-tabs__bar" role="tablist" aria-label="<?= e($groupsTitle) ?>">
<?php foreach ($groups as $i => $g): ?>
<button class="tab group-tabs__tab" role="tab" type="button"
id="gtab-<?= $i ?>"
aria-controls="gpanel-<?= $i ?>"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
><?= e($g['name']) ?></button>
<?php endforeach; ?>
</div>
<?php foreach ($groups as $i => $g): ?>
<article class="group-card group-tabs__panel" role="tabpanel"
id="gpanel-<?= $i ?>"
aria-labelledby="gtab-<?= $i ?>"
tabindex="0"
>
<div class="group-card__body"> <div class="group-card__body">
<div class="group-card__header"> <div class="group-card__header">
<h3 class="group-card__name"><?= e($g['name']) ?></h3> <h3 class="group-card__name"><?= e($g['name']) ?></h3>
@@ -202,19 +160,23 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Kontakt:</span> <?= e($g['contact']) ?></p> <p class="group-card__trainers"><span class="schedule-row__trainers-label">Kontakt:</span> <?= e($g['contact']) ?></p>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($g['trainers'])): ?> <?php if (!empty($g['trainers'])): ?>
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Übungsleitung:</span> <?= e(implode(', ', $g['trainers'])) ?></p> <p class="group-card__trainers"><span class="schedule-row__trainers-label">Trainerteam:</span> <?= e(implode(', ', $g['trainers'])) ?></p>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($g['note'])): ?> <?php if (!empty($g['note'])): ?>
<p class="group-card__note text-muted"><?= e($g['note']) ?></p> <p class="group-card__note text-muted"><?= e($g['note']) ?></p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</article> <?php
<?php endforeach; ?> };
</div>
<?php else: ?> /**
<div class="group-list group-list--grid"> * Schlichte Gruppen-Karte (Name + Trainingszeiten). Als Closure, weil sie an zwei Stellen
<?php foreach ($groups as $g): ?> * gebraucht wird: im Raster der eigenen Gruppen-Sektion und — bei $groupsInline — als Kachel
* direkt im Bento-Dashboard.
*/
$renderSimpleCard = static function (array $g) use ($renderSession): void {
?>
<article class="group-card"> <article class="group-card">
<div class="group-card__body"> <div class="group-card__body">
<div class="group-card__header"> <div class="group-card__header">
@@ -232,7 +194,123 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
</div> </div>
<?php endif; ?> <?php endif; ?>
</article> </article>
<?php
};
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : [];
$soleNote = $single ? ($groups[0]['note'] ?? '') : '';
?>
<section class="section turnen-dash-section" id="<?= e($anchor) ?>">
<div class="container">
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?><?= $groupsInline ? ' turnen-dash--groups' : '' ?>">
<?php if ($notice !== ''): ?>
<?php component('notice', [
'title' => $noticeTitle,
'text' => $notice,
'tone' => $noticeTone,
'icon' => $noticeIcon,
'class' => 'turnen-dash__notice',
]); ?>
<?php endif; ?>
<?php if ($single): ?>
<article class="bento__tile turnen-dash__training">
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
<ul class="bento__sessions">
<?php foreach ($soleSessions as $s): ?>
<li>
<span class="bento__day"><?= e($s['day']) ?></span>
<span class="bento__time"><?= e($s['time']) ?></span>
</li>
<?php endforeach; ?> <?php endforeach; ?>
</ul>
<?php if ($soleNote !== ''): ?>
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
<?php endif; ?>
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
</article>
<?php endif; ?>
<?php if ($lead !== '' || $email !== '' || $telHref !== ''): ?>
<article class="bento__tile bento__tile--accent turnen-dash__contact">
<span class="bento__stat-label">Kontakt</span>
<?php if ($lead !== ''): ?>
<span class="bento__stat-value"><?= e($lead) ?></span>
<?php endif; ?>
<?php if ($email !== ''): ?>
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
<?php endif; ?>
<?php if ($telHref !== ''): ?>
<a class="turnen-dash__mail" href="<?= e($telHref) ?>"><?= icon('telephone', 'turnen-dash__mail-icon') ?><?= e($contactPhone) ?></a>
<?php endif; ?>
</article>
<?php endif; ?>
<?php if ($hasVenue): ?>
<article class="bento__tile turnen-dash__venue">
<div class="turnen-dash__venue-main">
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
<p class="bento__address">
<strong><?= e($location['venue']) ?></strong>
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
</p>
</div>
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<?php endif; ?>
<?php if ($groupsInline): ?>
<div class="turnen-dash__groups group-list--grid">
<?php foreach ($groups as $g): $renderSimpleCard($g); endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($groupsInline && $footnote !== ''): ?>
<p class="group-list__footnote text-muted"><?= e($footnote) ?></p>
<?php endif; ?>
</div>
</section>
<?php if (!$single && !$groupsInline): ?>
<section class="section group-list-section<?= $groupsTitleHidden ? ' group-list-section--tight' : '' ?>" id="gruppen">
<div class="container">
<h2 class="group-list__title<?= $groupsTitleHidden ? ' visually-hidden' : '' ?>"><?= e($groupsTitle) ?></h2>
<?php if ($rich && $layout === 'stacked'): ?>
<?php /* Karten direkt untereinander, alle sichtbar. Für Disziplinen mit wenigen Gruppen:
Tabs verstecken dort mehr Inhalt als sie ordnen, und die offene Kartenreihe hält
den Bento-Eindruck der Seite. Bei vielen textreichen Gruppen (Kinderturnen) sind
Tabs weiter richtig, sonst wird die Seite endlos. */ ?>
<div class="group-list group-list--stacked">
<?php foreach ($groups as $g): ?>
<article class="group-card">
<?php $renderCardBody($g); ?>
</article>
<?php endforeach; ?>
</div>
<?php elseif ($rich): ?>
<div class="group-tabs" data-group-tabs>
<div class="group-tabs__bar" role="tablist" aria-label="<?= e($groupsTitle) ?>">
<?php foreach ($groups as $i => $g): ?>
<button class="tab group-tabs__tab" role="tab" type="button"
id="gtab-<?= $i ?>"
aria-controls="gpanel-<?= $i ?>"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
><?= e($g['name']) ?></button>
<?php endforeach; ?>
</div>
<?php foreach ($groups as $i => $g): ?>
<article class="group-card group-tabs__panel" role="tabpanel"
id="gpanel-<?= $i ?>"
aria-labelledby="gtab-<?= $i ?>"
tabindex="0"
>
<?php $renderCardBody($g); ?>
</article>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="group-list group-list--grid">
<?php foreach ($groups as $g): $renderSimpleCard($g); endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($footnote !== ''): ?> <?php if ($footnote !== ''): ?>

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/**
* Aufruf-Seite der Stadionzeitung (Live-Seite) — eine redaktionelle Content-
* Seite für einen Handlungsaufruf, zuerst gebaut für den Helferaufruf zum
* Sportfest, bewusst GENERISCH gehalten (nicht `sportfest`), damit sie später
* auch für andere Feste (Weihnachtsfeier o. Ä.) taugt.
*
* Der Inhalt ist KEIN Skript-Input: er wird wie Vorwort/Interview im Chat
* gepflegt und lebt im `aufruf`-Feld der Ausgabe in data/stadionzeitung.json:
* kicker kleine Zeile über der Überschrift (z. B. „Sportfest 2026")
* heading Überschrift der Seite
* intro Vorspann (Lese-Serif, wie Vorwort/Interview) — String ODER
* Liste von Zeilen (mehrere Zeilen = ein Absatz mit <br>)
* tasks_title Überschrift über der Aufgaben-Liste (optional)
* tasks[] wobei Hilfe gebraucht wird
* contacts_intro Zeile über den Ansprechpartnern (optional)
* contacts[] NAMEN — Nummer/Foto zieht die Seite per Namensabgleich aus
* data/ansprechpartner.json (geteilte Quelle, nie duplizieren;
* wie die Kontakt-Seite bewusst live statt Snapshot: ein Fest
* betrifft ohnehin die Zukunft, alte Nummern nützen niemandem)
* link interner Slug fürs Ziel (z. B. „veranstaltungen/sportfest-2026"),
* liefert Link-Zeile + QR im CTA-Fuß
* poster optional, kleines Plakat rechts neben Intro + Aufgaben
* (Varianten-Set {base,widths,alt} oder {src,…}) — z. B. das
* Sportfest-Vorderplakat aus data/veranstaltungen.json
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe weiter (wie
* `vorwort`/`interview`), plant die Seite nur ein wenn das Feld existiert und
* erzeugt den QR (qr-aufruf.png im Ausgaben-Ordner) aus `link`.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json)
*/
$issue = $issue ?? [];
$aufruf = $issue['aufruf'] ?? null;
if (!is_array($aufruf)) {
return;
}
$slug = (string) ($issue['slug'] ?? '');
$heading = trim((string) ($aufruf['heading'] ?? 'Mach mit'));
$kicker = trim((string) ($aufruf['kicker'] ?? 'Mitmachen'));
// intro darf ein String ODER eine Liste von Zeilen sein — mehrere Zeilen
// werden als EIN Absatz mit weichen Umbrüchen (<br>) gesetzt, damit ein kurzer
// Aufruf bewusst zeilenweise stehen kann statt als Fließblock.
$introLines = array_values(array_filter(
array_map(static fn ($l): string => trim((string) $l), (array) ($aufruf['intro'] ?? [])),
static fn (string $l): bool => $l !== ''
));
$tasksTitle = trim((string) ($aufruf['tasks_title'] ?? ''));
$tasks = array_values(array_filter(
array_map(static fn ($t): string => trim((string) $t), $aufruf['tasks'] ?? []),
static fn (string $t): bool => $t !== ''
));
$contactsIntro = trim((string) ($aufruf['contacts_intro'] ?? ''));
$link = trim((string) ($aufruf['link'] ?? ''));
$ctaLabel = trim((string) ($aufruf['cta_label'] ?? 'Alle Infos'));
$ctaTitle = trim((string) ($aufruf['cta_title'] ?? ''));
// Kontakte per Namensabgleich aus der geteilten Quelle auflösen (Nummer/Foto),
// die Reihenfolge folgt dem `contacts`-Feld. Ein unbekannter Name bleibt als
// reiner Name stehen (kein Fehler), damit ein Tippfehler die Seite nicht kippt.
$byName = [];
foreach (json_load('ansprechpartner')['persons'] ?? [] as $person) {
$name = (string) ($person['name'] ?? '');
if ($name !== '') {
$byName[$name] = $person;
}
}
$contacts = [];
foreach ($aufruf['contacts'] ?? [] as $name) {
$name = trim((string) $name);
if ($name === '') {
continue;
}
$contacts[] = $byName[$name] ?? ['name' => $name];
}
$qr = ($slug !== '' && is_file(PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}/qr-aufruf.png"))
? "img/stadionzeitung/{$slug}/qr-aufruf.png"
: null;
$linkLabel = $link !== '' ? club_domain($link) : '';
// Optionales kleines Plakat rechts neben Intro + Aufgaben (z. B. das Sportfest-
// Vorderplakat) — Varianten-Set {base,widths,alt} oder Einzeldatei {src,…}.
$poster = $aufruf['poster'] ?? null;
if (!is_array($poster) || (empty($poster['base']) && empty($poster['src']))) {
$poster = null;
}
?>
<div class="stadionzeitung-aufruf">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<div class="stadionzeitung-aufruf__cols<?= $poster ? ' stadionzeitung-aufruf__cols--poster' : '' ?>">
<div class="stadionzeitung-aufruf__col-main">
<?php if ($introLines !== []): ?>
<p class="stadionzeitung-aufruf__intro"><?= implode('<br>', array_map('e', $introLines)) ?></p>
<?php endif; ?>
<?php if ($tasks !== []): ?>
<div class="stadionzeitung-aufruf__tasks">
<?php if ($tasksTitle !== ''): ?>
<p class="stadionzeitung-aufruf__tasks-title"><?= e($tasksTitle) ?></p>
<?php endif; ?>
<ul class="stadionzeitung-aufruf__task-list">
<?php foreach ($tasks as $task): ?>
<li class="stadionzeitung-aufruf__task"><?= icon('check2', 'stadionzeitung-aufruf__task-icon') ?><span><?= e($task) ?></span></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<?php if ($poster): ?>
<?php component('img', ['image' => $poster, 'sizes' => '150px', 'class' => 'stadionzeitung-aufruf__poster']); ?>
<?php endif; ?>
</div>
<?php if ($contacts !== []): ?>
<div class="stadionzeitung-aufruf__contacts">
<?php if ($contactsIntro !== ''): ?>
<p class="stadionzeitung-aufruf__contacts-intro"><?= e($contactsIntro) ?></p>
<?php endif; ?>
<ul class="stadionzeitung-aufruf__contact-list">
<?php foreach ($contacts as $contact): ?>
<li class="stadionzeitung-aufruf__contact">
<span class="stadionzeitung-aufruf__contact-name"><?= e((string) ($contact['name'] ?? '')) ?></span>
<?php if (!empty($contact['phone'])): ?>
<span class="stadionzeitung-aufruf__contact-phone"><?= icon('telephone', 'stadionzeitung-aufruf__contact-icon') ?><?= e((string) $contact['phone']) ?></span>
<?php elseif (!empty($contact['email'])): ?>
<span class="stadionzeitung-aufruf__contact-phone"><?= icon('envelope', 'stadionzeitung-aufruf__contact-icon') ?><?= e((string) $contact['email']) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($linkLabel !== ''): ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label"><?= e($ctaLabel) ?></p>
<?php if ($ctaTitle !== ''): ?>
<p class="stadionzeitung-page__cta-title"><?= e($ctaTitle) ?></p>
<?php endif; ?>
<p class="stadionzeitung-page__cta-link"><?= e($linkLabel) ?></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>
<?php endif; ?>
</div>

View File

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

View File

@@ -69,18 +69,64 @@ if ($match !== null) {
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()). // Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
$linkText = 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) — // Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —
// erscheint automatisch, sobald die Ausgabe ein Interview trägt // aus stadionzeitung_teaser(): `cover.teaser` einer Sonderausgabe, sonst die
// (interview-Feld, chat-gepflegt wie das Vorwort). // Coverzeile des Interviews. Dieselbe Funktion speist die Archivkarte.
$teaser = trim((string) ($issue['interview']['teaser'] ?? '')); $teaser = stadionzeitung_teaser($issue);
?> ?>
<div class="stadionzeitung-cover"> <div class="stadionzeitung-cover<?= $poster !== null ? ' stadionzeitung-cover--poster' : '' ?>">
<?php if ($heroImage): ?> <?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?> <?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
<?php endif; ?> <?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 <?php
/* Varianten-Set (base/widths/ext=png) statt der Originaldatei. Das Original ist /* Varianten-Set (base/widths/ext=png) statt der Originaldatei. Das Original ist
ein Freisteller mit 2584x5093 Pixeln und 12 MB: iOS Safari verwirft Bilder, 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> <p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
</header> </header>
<div class="stadionzeitung-cover__corner"> <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"> <div class="stadionzeitung-cover__headline">
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026: <?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
ruhiger) — die beiden Logos sprechen für sich. */ ?> 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> <p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?>
<?php if ($teaser !== ''): ?> <?php if ($teaser !== ''): ?>
<p class="stadionzeitung-cover__teaser"> <p class="stadionzeitung-cover__teaser">
<span class="stadionzeitung-cover__teaser-label">Im Heft</span> <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

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Gegnerstatistik der Stadionzeitung. Alle Werte stammen aus dem bereits
* aufgebauten Stadionzeitungs-Snapshot; die Komponente rendert nur. Dadurch
* sind Entwurf und veröffentlichte Druckausgabe konsistent und archivfest.
*
* 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;
$opponentName = (string) ($info['opponent'] ?? 'Unser Gegner');
$kickoff = (string) ($fixture['kickoff'] ?? '');
$kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : '';
$kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : '';
$rows = [
['label' => 'Tabellenplatz', 'key' => 'rank'],
['label' => 'Spiele', 'key' => 'played'],
['label' => 'Siege', 'key' => 'won'],
['label' => 'Unentschieden', 'key' => 'drawn'],
['label' => 'Niederlagen', 'key' => 'lost'],
['label' => 'Tordifferenz', 'key' => 'goal_diff', 'signed' => true],
['label' => 'Punkte', 'key' => 'points'],
];
$format = static function (?array $row, string $key, bool $signed = false): string {
if ($row === null || !array_key_exists($key, $row)) {
return '';
}
$value = (int) $row[$key];
return $signed && $value > 0 ? '+' . $value : (string) $value;
};
?>
<div class="stadionzeitung-gegner">
<header class="stadionzeitung-page__head">
<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>
<?php endif; ?>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<div class="stadionzeitung-gegner__duell">
<div class="stadionzeitung-gegner__club stadionzeitung-gegner__club--own">
<?php component('crest', ['src' => $fixture['home_crest'] ?? $own['crest'] ?? null, 'name' => $fixture['home'] ?? 'TSV 08 Kulmbach']); ?>
<strong><?= e((string) ($fixture['home'] ?? 'TSV 08 Kulmbach')) ?></strong>
</div>
<div class="stadionzeitung-gegner__match">
<span>gegen</span>
<?php if ($kickoffDate !== ''): ?>
<strong><?= e(german_date($kickoffDate)) ?></strong>
<?php endif; ?>
<?php if ($kickoffTime !== ''): ?>
<span><?= e($kickoffTime) ?> Uhr</span>
<?php endif; ?>
</div>
<div class="stadionzeitung-gegner__club">
<?php component('crest', ['src' => $fixture['away_crest'] ?? $opponent['crest'] ?? null, 'name' => $fixture['away'] ?? $opponentName]); ?>
<strong><?= e((string) ($fixture['away'] ?? $opponentName)) ?></strong>
</div>
</div>
<?php if ($own !== null && $opponent !== null): ?>
<div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich">
<div class="stadionzeitung-gegner__vergleich-head" role="row">
<?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">Gegner</span>
</div>
<?php foreach ($rows as $row): ?>
<div class="stadionzeitung-gegner__vergleich-row" role="row">
<strong role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
<span role="rowheader"><?= e((string) $row['label']) ?></span>
<strong role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p>
<?php endif; ?>
</div>

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: * anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
* $entry Eintrag aus issue.images[]: * $entry Eintrag aus issue.images[]:
* type 'image' (base/widths/alt), * type 'image' (base/widths/alt),
* type 'live' (page: cover|vorwort|interview|tabelle|termine| * type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier|
* news|ergebnisse|vorschau|kontakt; tabelle optional * tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt|
* mit team: Team-Key aus matchcenter.json), * 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 * type 'leer' (weiße Füllseite, nur Druckfassung — hält die
* Seitenzahl auf einem Vielfachen von 4) * Seitenzahl auf einem Vielfachen von 4)
* $issue Eintrag aus data/stadionzeitung.json * $issue Eintrag aus data/stadionzeitung.json
@@ -46,12 +49,30 @@ $type = $entry['type'] ?? 'image';
<?php case 'interview': ?> <?php case 'interview': ?>
<?php component('stadionzeitung-interview', ['issue' => $issue]); ?> <?php component('stadionzeitung-interview', ['issue' => $issue]); ?>
<?php break; ?> <?php break; ?>
<?php case 'aufruf': ?>
<?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'termine': ?> <?php case 'termine': ?>
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?> <?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
<?php break; ?> <?php break; ?>
<?php case 'ergebnisse': ?> <?php case 'ergebnisse': ?>
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?> <?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
<?php break; ?> <?php break; ?>
<?php case 'gegner': ?>
<?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 case 'vorschau': ?>
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?> <?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
<?php break; ?> <?php break; ?>
@@ -70,6 +91,11 @@ $type = $entry['type'] ?? 'image';
<?php case 'partner': ?> <?php case 'partner': ?>
<?php component('stadionzeitung-partner', []); ?> <?php component('stadionzeitung-partner', []); ?>
<?php break; ?> <?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 case 'momente': ?>
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?> <?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
<?php break; ?> <?php break; ?>

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer * (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
* Icon-Liste statt Fließtext. * Icon-Liste statt Fließtext.
* *
* Die drei Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert * Die Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert
* sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort * sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was * und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht. * am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
@@ -60,6 +60,8 @@ $termineLink = club_domain('termine');
} elseif (!$isMatch && !empty($item['location'])) { } elseif (!$isMatch && !empty($item['location'])) {
$parts[] = (string) $item['location']; $parts[] = (string) $item['location'];
} }
$title = (string) ($item['title'] ?? '');
$meta = implode(' · ', $parts);
?> ?>
<li class="stadionzeitung-termine__item"> <li class="stadionzeitung-termine__item">
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die <?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
@@ -67,8 +69,8 @@ $termineLink = club_domain('termine');
Blick unterscheidbar. */ ?> Blick unterscheidbar. */ ?>
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span> <span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
<div class="stadionzeitung-termine__body"> <div class="stadionzeitung-termine__body">
<p class="stadionzeitung-termine__title"><?= e((string) ($item['title'] ?? '')) ?></p> <p class="stadionzeitung-termine__title" title="<?= e($title) ?>"><?= e($title) ?></p>
<p class="stadionzeitung-termine__meta"><?= e(implode(' · ', $parts)) ?></p> <p class="stadionzeitung-termine__meta" title="<?= e($meta) ?>"><?= e($meta) ?></p>
</div> </div>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>

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: * Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props:
* $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status, * $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) — * Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
* voll lesbar, sequentiell auch per Screenreader. Mit JS * voll lesbar, sequentiell auch per Screenreader. Mit JS

View File

@@ -568,6 +568,27 @@ function veranstaltungen_load(): array
return $items; 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 * 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 * 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'; 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 * 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 * Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
@@ -992,6 +1093,11 @@ function stadionzeitung_build_snapshot(array $issue): array
$tabellen = []; $tabellen = [];
$ergebnisse = []; $ergebnisse = [];
$vorschau = []; $vorschau = [];
// 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) { foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
$key = (string) ($team['key'] ?? ''); $key = (string) ($team['key'] ?? '');
if ($key === '') { if ($key === '') {
@@ -1004,6 +1110,47 @@ function stadionzeitung_build_snapshot(array $issue): array
'table' => $team['table'] ?? [], 'table' => $team['table'] ?? [],
]; ];
// 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;
foreach ($table as $row) {
if (!empty($row['is_own_club'])) {
$ownRow = $row;
}
if (strcasecmp(trim((string) ($row['club'] ?? '')), $gegnerName) === 0) {
$opponentRow = $row;
}
}
$fixture = null;
foreach ($team['next_matches'] ?? [] as $match) {
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
$clubs = [(string) ($match['home'] ?? ''), (string) ($match['away'] ?? '')];
if ($matchDate === $gegnerDate && in_array($gegnerName, $clubs, true)) {
$fixture = $match;
break;
}
}
$gegner[$key] = [
'team_name' => (string) ($team['name'] ?? ''),
'opponent' => $gegnerName,
'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''),
'fixture' => $fixture,
'own' => $ownRow,
'opponent_stats' => $opponentRow,
];
}
// Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist // Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist
// kein Rückblick (dieselbe Regel wie im News-Feed). // kein Rückblick (dieselbe Regel wie im News-Feed).
$withScore = array_values(array_filter( $withScore = array_values(array_filter(
@@ -1024,7 +1171,11 @@ function stadionzeitung_build_snapshot(array $issue): array
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der // Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
// Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es // 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) { foreach ($team['next_matches'] ?? [] as $match) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen // Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen. // "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
@@ -1038,6 +1189,9 @@ function stadionzeitung_build_snapshot(array $issue): array
if ($matchDate === $date && $key === $issueTeam) { if ($matchDate === $date && $key === $issueTeam) {
continue; continue;
} }
if ($gegnerDate !== null && $matchDate === $gegnerDate) {
continue;
}
$vorschau[] = [ $vorschau[] = [
'team_key' => $key, 'team_key' => $key,
'team_name' => (string) ($team['name'] ?? ''), 'team_name' => (string) ($team['name'] ?? ''),
@@ -1058,6 +1212,7 @@ function stadionzeitung_build_snapshot(array $issue): array
return [ return [
'tabellen' => $tabellen, 'tabellen' => $tabellen,
'gegner' => $gegner,
'ergebnisse' => $ergebnisse, 'ergebnisse' => $ergebnisse,
'vorschau' => $vorschau, 'vorschau' => $vorschau,
// Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht // Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht

View File

@@ -40,7 +40,7 @@ component('hero', ['hero' => $page['hero']]);
<div class="container"> <div class="container">
<div class="intro-bento"> <div class="intro-bento">
<div class="bento__tile intro-bento__image"> <div class="bento__tile intro-bento__image">
<?php component('img', ['image' => $page['hero']['image'], 'sizes' => '(max-width: 900px) 100vw, 40vw', 'class' => 'intro-bento__img']); ?> <?php component('img', ['image' => $page['team_photo'] ?? $page['hero']['image'], 'sizes' => '(max-width: 900px) 100vw, 40vw', 'class' => 'intro-bento__img']); ?>
</div> </div>
<article class="bento__tile intro-bento__about"> <article class="bento__tile intro-bento__about">

View File

@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
$meta = [ $meta = [
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach', 'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
'description' => $d['hero']['text'], 'description' => $d['hero']['text'],
'og_image' => 'img/pages/vereinsgelaende-1600.jpg', 'og_image' => 'img/pages/turnen-eltern-kind-1536.jpg',
'scripts' => ['group-tabs.js'], 'scripts' => ['group-tabs.js'],
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],

View File

@@ -12,7 +12,7 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
$meta = [ $meta = [
'title' => 'Erwachsenenturnen in Kulmbach', 'title' => 'Erwachsenenturnen in Kulmbach',
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.', 'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg', 'og_image' => 'img/pages/turnen-erwachsenenturnen-1536.jpg',
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -8,9 +8,10 @@ declare(strict_types=1);
* *
* Hieß bis 31.07.2026 „Step Aerobic, Fitness & Pilates" unter dem Slug * Hieß bis 31.07.2026 „Step Aerobic, Fitness & Pilates" unter dem Slug
* turnen/step-aerobic-fitness-pilates. Step Aerobic findet nicht mehr statt * turnen/step-aerobic-fitness-pilates. Step Aerobic findet nicht mehr statt
* (Rückmeldung der Turnabteilung), die Gruppe ist aus data/turnen.json entfernt * (Rückmeldung der Turnabteilung), die Gruppe ist aus data/turnen.json entfernt.
* Pilates und Fitness & Stretching bei Karola Lorz laufen weiter. Der alte Slug * Seit 19.08.2026 laufen hier Pilates, Functional Fitness I (Mittwoch) und der neue
* leitet per 301 hierher (public/.htaccess). * Functional Fitness II (Donnerstag). Der alte Slug leitet per 301 hierher
* (public/.htaccess).
*/ */
$slug = 'turnen/fitness-pilates'; $slug = 'turnen/fitness-pilates';
$d = json_load('turnen')['disciplines']['fitness-pilates']; $d = json_load('turnen')['disciplines']['fitness-pilates'];
@@ -18,7 +19,7 @@ $d = json_load('turnen')['disciplines']['fitness-pilates'];
$meta = [ $meta = [
'title' => 'Fitness & Pilates in Kulmbach', 'title' => 'Fitness & Pilates in Kulmbach',
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Fitness und Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.', 'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Fitness und Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg', 'og_image' => 'img/pages/turnen-fitness-pilates-1536.jpg',
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],
@@ -32,4 +33,6 @@ component('group-schedule', [
'location' => $d['location'] ?? [], 'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '', 'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [], 'groups' => $d['groups'] ?? [],
// Wenige, beschreibende Kurse → offen untereinander statt Tabs (siehe group-schedule.php).
'layout' => 'stacked',
]); ]);

View File

@@ -22,11 +22,15 @@ $meta = [
component('hero', ['hero' => $d['hero']]); component('hero', ['hero' => $d['hero']]);
component('group-schedule', [ component('group-schedule', [
'location' => $d['location'] ?? [], 'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '', 'lead' => $d['lead'] ?? '',
'notice' => $d['notice'] ?? '', 'contactEmail' => $d['contact_email'] ?? '',
'noticeTitle' => $d['notice_title'] ?? '', 'contactPhone' => $d['contact_phone'] ?? '',
'noticeTone' => $d['notice_tone'] ?? 'info', 'notice' => $d['notice'] ?? '',
'noticeIcon' => $d['notice_icon'] ?? 'info-circle', 'noticeTitle' => $d['notice_title'] ?? '',
'groups' => $d['groups'] ?? [], 'noticeTone' => $d['notice_tone'] ?? 'info',
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
'groups' => $d['groups'] ?? [],
'groupsInDash' => true,
'footnote' => $d['footnote'] ?? '',
]); ]);

View File

@@ -6,10 +6,10 @@ declare(strict_types=1);
* Turnen → Wettkampfturnen. Inhalt: data/turnen.json (disciplines.wettkampfturnen). * Turnen → Wettkampfturnen. Inhalt: data/turnen.json (disciplines.wettkampfturnen).
* Struktur: Hero → Gruppen/Trainingszeiten. * Struktur: Hero → Gruppen/Trainingszeiten.
* *
* Zwei Gruppen: das Wettkampftraining selbst und die Flamingos, die am 31.07.2026 vom * Zwei Gruppen: die Flamingos, die am 31.07.2026 vom Kinderturnen hierher gezogen sind,
* Kinderturnen hierher gezogen sind. Dadurch läuft die Seite nicht mehr im * und das Leistungsturnen. Dadurch läuft die Seite nicht mehr im Einzelgruppen-Layout
* Einzelgruppen-Layout (Trainingszeiten als Kachel im Dashboard), sondern zeigt die * (Trainingszeiten als Kachel im Dashboard), sondern zeigt die Gruppen darunter als
* Gruppen darunter als Tabs — deshalb group-tabs.js und die Sternchen-Fußnote. * offene Kartenreihe (layout => 'stacked') — deshalb die Sternchen-Fußnote.
*/ */
$slug = 'turnen/wettkampfturnen'; $slug = 'turnen/wettkampfturnen';
$data = json_load('turnen'); $data = json_load('turnen');
@@ -19,10 +19,6 @@ $meta = [
'title' => 'Wettkampfturnen in Kulmbach', 'title' => 'Wettkampfturnen in Kulmbach',
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen: Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.', 'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen: Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg', 'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
// Seit die Flamingos hier stehen, sind es zwei Gruppen, davon eine mit
// Beschreibung — group-schedule rendert dann eine Tab-Leiste, und die braucht
// dieses Skript. Ohne es wären die Tab-Knöpfe tote Schaltflächen.
'scripts' => ['group-tabs.js'],
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],
@@ -35,6 +31,15 @@ component('group-schedule', [
'location' => $d['location'] ?? [], 'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '', 'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [], 'groups' => $d['groups'] ?? [],
// Zwei Gruppen, beide offen untereinander statt hinter Tabs: bei so wenigen
// Gruppen verstecken Tabs mehr Inhalt als sie ordnen, und die offene Kartenreihe
// hält den Bento-Eindruck der Seite. Damit braucht die Seite auch kein
// group-tabs.js mehr. Das Kinderturnen bleibt bei Tabs, dort sind es drei
// textreiche Gruppen.
'layout' => 'stacked',
// Überschrift „Unsere Gruppen" nur für Screenreader: die Karten erklären sich
// selbst, und ohne die Zeile bleibt der Bento-Fluss ruhiger.
'groupsTitleHidden' => true,
// Drei Flamingos-Trainerinnen tragen ein Sternchen. Ohne die Fußnote stünden // Drei Flamingos-Trainerinnen tragen ein Sternchen. Ohne die Fußnote stünden
// hier drei unerklärte Sterne auf der Seite. // hier drei unerklärte Sterne auf der Seite.
'footnote' => $data['footnote'] ?? '', 'footnote' => $data['footnote'] ?? '',

View File

@@ -42,16 +42,15 @@ $austrittText = strtr(
['{mail}' => '<a href="mailto:' . e($mailTurnen) . '">' . e($mailTurnen) . '</a>'] ['{mail}' => '<a href="mailto:' . e($mailTurnen) . '">' . e($mailTurnen) . '</a>']
); );
// Heros aller Turn-Seiten zeigen VORÜBERGEHEND das Drohnen-Luftbild des // Eltern-Kind-, Erwachsenen- und Fitness/Pilates-Seite haben seit 19.08.2026 echte
// Vereinsgeländes (img/pages/vereinsgelaende, Quelle: data/turnen.json) statt des // (Symbolbild-)Fotos aus der Turnabteilung, in Hero UND Kachel. Trampolin- und
// Fußballplatz-Bildes. Ehrlicher wäre ein Bild aus der Turnhalle — liegt eines // Wettkampf-Hero zeigen weiter VORÜBERGEHEND das Drohnen-Luftbild des Vereinsgeländes
// vor, tauschen die sechs Hero-Blöcke in data/turnen.json und das og_image der // (img/pages/vereinsgelaende, Quelle: data/turnen.json); liegt dafür ein Hallenbild vor,
// sechs Seiten-Dateien. Die Übersichts-Kacheln zeigen weiter die (vorläufigen) // den Hero-Block in data/turnen.json und das og_image der Seiten-Datei mittauschen.
// themenbezogenen Platzhalter aus public/assets/img/pages/.
$cards = [ $cards = [
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1600], 'alt' => 'Erwachsenenturnen beim TSV 08 Kulmbach']], ['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1536], 'alt' => 'Turnerinnen üben Gleichgewicht auf dem Schwebebalken (Symbolbild)']],
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1600], 'alt' => 'Eltern-Kind- und Kinderturnen beim TSV 08 Kulmbach']], ['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1536], 'alt' => 'Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)']],
['name' => 'Fitness & Pilates', 'slug' => 'turnen/fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-fitness-pilates', 'widths' => [640, 1000, 1600], 'alt' => 'Fitness und Pilates beim TSV 08 Kulmbach']], ['name' => 'Fitness & Pilates', 'slug' => 'turnen/fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-fitness-pilates', 'widths' => [640, 1000, 1536], 'alt' => 'Gruppe trainiert mit Kniebeugen auf Matten im Sportraum (Symbolbild)']],
// Einziges echtes Foto unter den Kacheln (31.07.2026). Nur 640 breit, weil die // Einziges echtes Foto unter den Kacheln (31.07.2026). Nur 640 breit, weil die
// Vorlage über WhatsApp kam und dort auf 718 px eingedampft wurde; // Vorlage über WhatsApp kam und dort auf 718 px eingedampft wurde;
// resize_image_variants() skaliert nie hoch. Liegt das Original vom Handy vor: // resize_image_variants() skaliert nie hoch. Liegt das Original vom Handy vor:

View File

@@ -9,9 +9,9 @@ declare(strict_types=1);
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie` * data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
* schreibt bin/veranstaltung-bilder.php. * schreibt bin/veranstaltung-bilder.php.
* *
* Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()): * Geladen wird hier bewusst UNGEFILTERT (veranstaltung_find(), nicht
* ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er * veranstaltungen_load()): ein Entwurf muss unter seiner direkten URL lesbar
* gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er * bleiben, damit er gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
* trägt noindex. * trägt noindex.
* *
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe): * 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), '/'); $urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/')); $veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
$v = null; $v = veranstaltung_find($veranstaltungSlug);
foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
if (($entry['slug'] ?? '') === $veranstaltungSlug) {
$v = $entry;
break;
}
}
if ($v === null) { if ($v === null) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie // 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 // Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild // Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
// nicht mehr passen. // 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); 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) --- // --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der // Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf // Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf

View File

@@ -51,6 +51,23 @@ declare(strict_types=1);
* die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des * die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
* Fallback-Bilds `vorwort.*`. * Fallback-Bilds `vorwort.*`.
* *
* Interview (`interview`-Feld) und die generische Aufruf-Seite (`aufruf`-Feld,
* z. B. Sportfest-Helferaufruf) sind wie das Vorwort chat-gepflegt: das Skript
* trägt sie über erneute Läufe weiter und plant die Live-Seite nur ein, wenn
* das Feld existiert. Reihenfolge im anzeigenfreien Aufschlag: Vorwort →
* 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 * Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen. * Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
*/ */
@@ -179,6 +196,21 @@ foreach ($existing['issues'] ?? [] as $entry) {
$oldVorwort = $oldIssue['vorwort'] ?? null; $oldVorwort = $oldIssue['vorwort'] ?? null;
// Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso. // Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso.
$oldInterview = $oldIssue['interview'] ?? 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 --- // --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}"; $destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
@@ -206,6 +238,28 @@ $widths = [480, 960, 1440];
// erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und // erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die // im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
// Bilder liegen schon. --- // 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( $anzeigenSeiten = array_values(array_map(
static function (array $a): array { static function (array $a): array {
$sponsor = trim((string) ($a['sponsor'] ?? '')); $sponsor = trim((string) ($a['sponsor'] ?? ''));
@@ -228,6 +282,7 @@ $anzeigenSeiten = array_values(array_map(
// nächsten Handgriff im Heft zu bleiben. // nächsten Handgriff im Heft zu bleiben.
static fn (array $a): bool => ($a['aktiv'] ?? true) static fn (array $a): bool => ($a['aktiv'] ?? true)
&& !empty($a['base']) && !empty($a['base'])
&& empty($a['platzierung'])
&& (empty($a['bis']) || (string) $a['bis'] >= $date) && (empty($a['bis']) || (string) $a['bis'] >= $date)
) )
)); ));
@@ -236,12 +291,8 @@ $anzeigenSeiten = array_values(array_map(
// Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE // Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich // Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
// bei jeder Umverteilung. --- // bei jeder Umverteilung. ---
$live = static function (string $page, ?string $team = null): array { $live = static function (string $page, array $extra = []): array {
$entry = ['type' => 'live', 'page' => $page]; return ['type' => 'live', 'page' => $page] + $extra;
if ($team !== null) {
$entry['team'] = $team;
}
return $entry;
}; };
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array { $bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
@@ -284,32 +335,89 @@ try {
} elseif ($vorwort !== null) { } elseif ($vorwort !== null) {
$aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung'); $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 // Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes
// Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu. // Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
if ($oldInterview !== null) { if ($oldInterview !== null) {
$aufschlag[] = $live('interview'); $aufschlag[] = $live('interview');
} }
// Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien
// Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller
// Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld).
if ($oldAufruf !== null) {
$aufschlag[] = $live('aufruf');
}
if ($aufschlag !== []) { if ($aufschlag !== []) {
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true]; $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' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], '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 // Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die // (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
// SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch. // SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
$tabellen = []; $tabellen = [];
foreach ($mc['teams'] ?? [] as $team) { foreach ($mc['teams'] ?? [] as $team) {
if (!empty($team['key'])) { if (!empty($team['key'])) {
$tabellen[] = $live('tabelle', (string) $team['key']); $tabellen[] = $live('tabelle', ['team' => (string) $team['key']]);
} }
} }
if ($tabellen !== []) { if ($tabellen !== []) {
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true]; $bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
} }
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) { foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'danke', 'momente'] as $seite) {
$bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true]; // 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]; $bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];
@@ -553,6 +661,20 @@ foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'his
} }
} }
// Aufruf-Seite: QR auf das interne Ziel (z. B. /veranstaltungen/sportfest-2026)
// aus dem chat-gepflegten `aufruf.link`-Feld. Anders als die geteilten CTA-QRs
// ist das Ziel ausgabenspezifisch, deshalb liegt der Code im Ausgaben-Ordner
// (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url()
// wie überall, damit der gedruckte Code auf die echte Domain zeigt.
$aufrufLink = trim((string) ($oldAufruf['link'] ?? ''));
if ($aufrufLink !== '') {
if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) {
$log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt.");
} else {
$log('QR-Code für die Aufruf-Seite konnte nicht erzeugt werden — die Seite rendert ohne QR.', 'WARN');
}
}
// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die // Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code, // einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
// keine Einbindung, Regel 1 bleibt unberührt). // keine Einbindung, Regel 1 bleibt unberührt).
@@ -620,6 +742,9 @@ $issues[] = [
'qr_image' => $qrImage, 'qr_image' => $qrImage,
'vorwort' => $oldVorwort, 'vorwort' => $oldVorwort,
'interview' => $oldInterview, 'interview' => $oldInterview,
'aufruf' => $oldAufruf,
'veranstaltung' => $oldVeranstaltung,
'cover' => $oldCover,
'verfasser_photo' => $verfasserPhoto, 'verfasser_photo' => $verfasserPhoto,
'termine_snapshot' => $termineSnapshot, 'termine_snapshot' => $termineSnapshot,
'images' => $images, 'images' => $images,

View File

@@ -40,7 +40,7 @@
"name": "Kerstin Tauber", "name": "Kerstin Tauber",
"phone": "+49 176 38957526", "phone": "+49 176 38957526",
"whatsapp": true, "whatsapp": true,
"image": null "image": "img/persons/kerstin-tauber.jpg"
}, },
{ {
"group": "Turnen", "group": "Turnen",

View File

@@ -124,7 +124,7 @@
{ {
"slug": "emons-stuebinger", "slug": "emons-stuebinger",
"sponsor": "Emons Spedition und AST Stübinger Hoch-, Tief- und Straßenbau, Kulmbach", "sponsor": "Emons Spedition und AST Stübinger Hoch-, Tief- und Straßenbau, Kulmbach",
"aktiv": true, "aktiv": false,
"base": "img/anzeigen/emons-stuebinger", "base": "img/anzeigen/emons-stuebinger",
"widths": [ "widths": [
480, 480,
@@ -225,7 +225,7 @@
{ {
"slug": "sportfest-2026-plakat", "slug": "sportfest-2026-plakat",
"sponsor": "Sportfest 2026", "sponsor": "Sportfest 2026",
"aktiv": true, "aktiv": false,
"bis": "2026-09-06", "bis": "2026-09-06",
"alt": "Eigenanzeige: Sportfest 2026 vom 4. bis 6. September auf unserer Sportanlage", "alt": "Eigenanzeige: Sportfest 2026 vom 4. bis 6. September auf unserer Sportanlage",
"base": "img/anzeigen/sportfest-2026-plakat", "base": "img/anzeigen/sportfest-2026-plakat",
@@ -239,7 +239,7 @@
"slug": "sportfest-2026-programm", "slug": "sportfest-2026-programm",
"sponsor": "Programm Sportfest 2026", "sponsor": "Programm Sportfest 2026",
"aktiv": true, "aktiv": true,
"bis": "2026-09-06", "bis": "2026-09-03",
"alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade", "alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade",
"base": "img/anzeigen/sportfest-2026-programm", "base": "img/anzeigen/sportfest-2026-programm",
"widths": [ "widths": [
@@ -247,6 +247,54 @@
960, 960,
1440 1440
] ]
},
{
"slug": "gagel-pickelmann",
"sponsor": "Gagel Bäder und Mineralölhandel Pickelmann",
"aktiv": true,
"base": "img/anzeigen/gagel-pickelmann",
"widths": [
480,
960,
1440
]
},
{
"slug": "schramm",
"sponsor": "Landgasthof und Metzgerei Schramm, Forstlahm",
"aktiv": true,
"base": "img/anzeigen/schramm",
"widths": [
480,
960,
1440
]
},
{
"slug": "tagesskifahrt-2027",
"sponsor": "Unsere Tagesskifahrt ins Hochzillertal 2027",
"aktiv": true,
"bis": "2027-02-20",
"alt": "Eigenanzeige: Unsere Tagesskifahrt ins Hochzillertal am 20. Februar 2027",
"base": "img/anzeigen/tagesskifahrt-2027",
"widths": [
480,
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

@@ -11,17 +11,17 @@
}, },
"ctas": [] "ctas": []
}, },
"team_photo": { "base": "img/pages/jugend-team", "widths": [640, 1000], "alt": "Unsere Jugendabteilung des TSV 08 Kulmbach" },
"about": { "about": {
"title": "Mitmachen leicht gemacht", "title": "Mitmachen leicht gemacht",
"text": "Wenn deine Kinder Lust haben, bei uns Fußball auszuprobieren oder mitzuspielen, meldet euch gerne ganz unkompliziert. Wir freuen uns über jedes neue Gesicht und bieten Fußball in familiärer Atmosphäre, vom ersten Ballkontakt bis zum echten Mannschaftsspiel." "text": "Wenn deine Kinder Lust haben, bei uns Fußball auszuprobieren oder mitzuspielen, meldet euch gerne ganz unkompliziert. Wir freuen uns über jedes neue Gesicht und bieten Fußball in familiärer Atmosphäre, vom ersten Ballkontakt bis zum echten Mannschaftsspiel."
}, },
"groups": [ "groups": [
{ "code": "G", "name": "G-Jugend (Bambini)", "training": ["Freitag 16:3017:30 Uhr"], "photo": { "base": "img/teams/jugend-g", "widths": [400, 800], "alt": "G-Jugend des TSV 08 Kulmbach" } }, { "code": "G", "name": "G-Jugend (Bambini)", "training": ["Freitag 16:0017:00 Uhr"], "photo": { "base": "img/teams/jugend-g", "widths": [400, 800], "alt": "G-Jugend des TSV 08 Kulmbach" } },
{ "code": "F", "name": "F-Jugend", "training": ["Montag 17:0018:30 Uhr", "Freitag 16:3017:30 Uhr"], "photo": { "base": "img/teams/jugend-f", "widths": [400, 800], "alt": "F-Jugend des TSV 08 Kulmbach" } }, { "code": "F", "name": "F-Jugend", "training": ["Dienstag 16:3017:40 Uhr", "Freitag 16:3017:40 Uhr"], "photo": { "base": "img/teams/jugend-f", "widths": [400, 800], "alt": "F-Jugend des TSV 08 Kulmbach" } },
{ "code": "E", "name": "E-Jugend", "training": ["Montag 17:3018:30 Uhr", "Mittwoch 17:3019:00 Uhr"], "photo": { "base": "img/teams/jugend-e", "widths": [400, 800], "alt": "E-Jugend des TSV 08 Kulmbach" } }, { "code": "E", "name": "E-Jugend", "training": ["Montag 18:1519:30 Uhr", "Mittwoch 17:4519:00 Uhr"], "photo": { "base": "img/teams/jugend-e", "widths": [400, 800], "alt": "E-Jugend des TSV 08 Kulmbach" } },
{ "code": "D", "name": "D-Jugend", "training": ["Dienstag 17:3018:30 Uhr", "Freitag 17:3018:30 Uhr"], "photo": { "base": "img/teams/jugend-d", "widths": [400, 800], "alt": "D-Jugend des TSV 08 Kulmbach" } }, { "code": "D", "name": "D-Jugend", "training": ["Dienstag 17:4019:00 Uhr", "Freitag 17:4019:00 Uhr"], "photo": { "base": "img/teams/jugend-d", "widths": [400, 800], "alt": "D-Jugend des TSV 08 Kulmbach" } },
{ "code": "C", "name": "C-Jugend", "training": ["Montag 18:3019:30 Uhr", "Freitag 17:3019:00 Uhr"], "photo": { "base": "img/teams/jugend-c", "widths": [400, 800], "alt": "C-Jugend des TSV 08 Kulmbach" } }, { "code": "C/B", "name": "C/B-Jugend", "training": ["Montag 17:0018:00 Uhr", "Donnerstag 17:0018:00 Uhr"], "photo": { "base": "img/teams/jugend-c", "widths": [400, 800], "alt": "C/B-Jugend des TSV 08 Kulmbach" } },
{ "code": "B", "name": "B-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } },
{ "code": "A", "name": "A-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } } { "code": "A", "name": "A-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } }
], ],
"faq": [ "faq": [

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,7 @@
"compact": true, "compact": true,
"title": "Erwachsenenturnen", "title": "Erwachsenenturnen",
"text": "Beweglich bleiben, Kraft und Koordination trainieren: Turnen für Erwachsene im Mäximum Gym.", "text": "Beweglich bleiben, Kraft und Koordination trainieren: Turnen für Erwachsene im Mäximum Gym.",
"image": { "base": "img/pages/vereinsgelaende", "widths": [640, 1000, 1600], "alt": "Luftaufnahme unserer Hans-Rausch-Sportanlage im Katzbachtal" }, "image": { "base": "img/pages/turnen-erwachsenenturnen", "widths": [640, 1000, 1536], "alt": "Turnerinnen üben Gleichgewicht auf dem Schwebebalken (Symbolbild)" },
"ctas": [] "ctas": []
}, },
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" }, "location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
@@ -98,10 +98,9 @@
"compact": true, "compact": true,
"title": "Eltern-Kind- & Kinderturnen", "title": "Eltern-Kind- & Kinderturnen",
"text": "Bei uns starten die Jüngsten bereits ab 2 Jahren gemeinsam mit ihren Eltern und turnen sich über die Breitensportgruppen Schritt für Schritt nach oben.", "text": "Bei uns starten die Jüngsten bereits ab 2 Jahren gemeinsam mit ihren Eltern und turnen sich über die Breitensportgruppen Schritt für Schritt nach oben.",
"image": { "base": "img/pages/vereinsgelaende", "widths": [640, 1000, 1600], "alt": "Luftaufnahme unserer Hans-Rausch-Sportanlage im Katzbachtal" }, "image": { "base": "img/pages/turnen-eltern-kind", "widths": [640, 1000, 1536], "alt": "Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)" },
"ctas": [] "ctas": []
}, },
"layout": "blocks",
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" }, "location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
"lead": "Annelie Frisque", "lead": "Annelie Frisque",
"notice_tone": "stop", "notice_tone": "stop",
@@ -120,7 +119,7 @@
"recommendation": "Bei besonderem Potenzial für Wettkampf- oder Leistungsgruppen sprechen unsere Trainer eine Empfehlung aus.", "recommendation": "Bei besonderem Potenzial für Wettkampf- oder Leistungsgruppen sprechen unsere Trainer eine Empfehlung aus.",
"sessions": [ "sessions": [
{ "label": "Kängurus", "day": "Dienstag", "time": "16:3017:15 Uhr", "trainers": ["Carina Schleicher*"] }, { "label": "Kängurus", "day": "Dienstag", "time": "16:3017:15 Uhr", "trainers": ["Carina Schleicher*"] },
{ "label": "Grashüpfer", "day": "Donnerstag", "time": "15:3016:15 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch"] } { "label": "Grashüpfer", "day": "Donnerstag", "time": "15:3016:15 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch*"] }
] ]
}, },
{ {
@@ -160,7 +159,7 @@
"compact": true, "compact": true,
"title": "Fitness & Pilates", "title": "Fitness & Pilates",
"text": "Ausdauer, Kräftigung und Beweglichkeit für Erwachsene, in der Max-Hundt-Schule.", "text": "Ausdauer, Kräftigung und Beweglichkeit für Erwachsene, in der Max-Hundt-Schule.",
"image": { "base": "img/pages/vereinsgelaende", "widths": [640, 1000, 1600], "alt": "Luftaufnahme unserer Hans-Rausch-Sportanlage im Katzbachtal" }, "image": { "base": "img/pages/turnen-fitness-pilates", "widths": [640, 1000, 1536], "alt": "Gruppe trainiert mit Kniebeugen auf Matten im Sportraum (Symbolbild)" },
"ctas": [] "ctas": []
}, },
"location": { "venue": "Max-Hundt-Schule", "city": "Kulmbach" }, "location": { "venue": "Max-Hundt-Schule", "city": "Kulmbach" },
@@ -168,13 +167,33 @@
"groups": [ "groups": [
{ {
"name": "Pilates", "name": "Pilates",
"intro": "Pilates stärkt den Körper von innen heraus und verbindet Kraft, Beweglichkeit und Körperbewusstsein. Im Mittelpunkt stehen die klassischen Pilates-Prinzipien: bewusste Atmung, Kontrolle, Konzentration, Präzision, Bewegungsfluss und Zentrierung.",
"details": [
{ "heading": "Was dich erwartet", "text": "Durch kontrollierte, fließende Bewegungen kräftigen wir vor allem die tiefliegende Rumpfmuskulatur, verbessern Stabilität, Koordination und Haltung und fördern eine bewusste Körperwahrnehmung. Klassische Übungen werden dabei gezielt durch moderne und abwechslungsreiche Varianten ergänzt." },
{ "heading": "Dein Ziel", "text": "Mehr Kraft aus der Körpermitte, bessere Bewegungsqualität und ein wohltuender Ausgleich zum Alltag." }
],
"sessions": [ { "day": "Mittwoch", "time": "17:3018:30 Uhr" } ], "sessions": [ { "day": "Mittwoch", "time": "17:3018:30 Uhr" } ],
"trainers": ["Karola Lorz"] "trainers": ["Karola Lorz"]
}, },
{ {
"name": "Fitness & Stretching", "name": "Functional Fitness I",
"intro": "Wir starten mit einem lockeren Warm-up aus einfachen Schrittkombinationen, das Muskeln und Gelenke mobilisiert und den Körper auf das Training vorbereitet.",
"details": [
{ "heading": "Was dich erwartet", "text": "Danach erwartet dich ein vielseitiges Ganzkörper-Workout mit abwechslungsreichen Übungen, Kleingeräten und instabilen Unterlagen. Dabei trainieren wir nicht nur Kraft, sondern auch Tiefenmuskulatur, Stabilität, Koordination und Balance." },
{ "heading": "Dein Ziel", "text": "Den Körper rundum stärken, beweglich bleiben und langfristig fit und belastbar durchs Leben gehen. Die Übungen lassen sich an unterschiedliche Trainingslevel anpassen." }
],
"sessions": [ { "day": "Mittwoch", "time": "18:4519:45 Uhr" } ], "sessions": [ { "day": "Mittwoch", "time": "18:4519:45 Uhr" } ],
"trainers": ["Karola Lorz"] "trainers": ["Karola Lorz"]
},
{
"name": "Functional Fitness II",
"intro": "Jede Stunde ist anders, aber das Ziel bleibt gleich: ein starker, beweglicher und belastbarer Körper für deinen Alltag.",
"details": [
{ "heading": "Was dich erwartet", "text": "Dich erwartet ein abwechslungsreicher Mix aus Pilates, Mobility, funktionellem Krafttraining und Bodyweight-Übungen. Je nach Schwerpunkt kommen auch Kleingeräte wie Kurzhanteln oder Pilates-Bälle zum Einsatz." },
{ "heading": "Im Mittelpunkt", "text": "Stabilität, Balance und absolute Körperkontrolle. Alle Übungen lassen sich flexibel an dein individuelles Trainingslevel anpassen, für mehr Kraft, Beweglichkeit und ein rundum gutes Körpergefühl." }
],
"sessions": [ { "day": "Donnerstag", "time": "abends", "note": "Die genaue Uhrzeit geben wir in Kürze bekannt." } ],
"trainers": ["Karola Lorz", "Eva Wünsch"]
} }
] ]
}, },
@@ -189,18 +208,30 @@
}, },
"location": { "venue": "Dreifachturnhalle der Carl-von-Linde-Realschule", "city": "Kulmbach" }, "location": { "venue": "Dreifachturnhalle der Carl-von-Linde-Realschule", "city": "Kulmbach" },
"lead": "Tina Kornatz", "lead": "Tina Kornatz",
"notice_icon": "calendar-event", "contact_email": "tina.kornatz@gmx.de",
"notice_title": "Schnuppern ab September", "contact_phone": "0163 6032410",
"notice": "Mit dem Schulbeginn im September können neue Kinder bei uns gerne zum Schnuppern vorbeikommen. Schreib uns davor einfach kurz eine Nachricht, dann sagen wir dir, wann es am besten passt.", "footnote": "In den Schulferien findet kein Training statt.",
"notice_icon": "calendar-event",
"notice_title": "Schnuppern nach Absprache",
"notice": "Schnuppertraining ist bei uns jederzeit nach vorheriger Absprache möglich. Melde dich davor einfach kurz bei Tina, dann finden wir einen passenden Termin.",
"groups": [ "groups": [
{ {
"name": "Trampolinturnen", "name": "Kinder und Erwachsene",
"sessions": [ "sessions": [
{ "day": "Montag", "time": "17:30 / 18:0020:30 Uhr" }, { "day": "Montag", "time": "17:3019:30 / 20:30 Uhr", "trainers": ["Sabine Hofmann"] }
{ "day": "Donnerstag", "time": "17:30 / 18:0020:30 Uhr" } ]
], },
"note": "Der Einstieg erfolgt gestaffelt um 17:30 bzw. 18:00 Uhr.", {
"trainers": [] "name": "Kinder ab 5 Jahren",
"sessions": [
{ "day": "Donnerstag", "time": "17:3019:00 Uhr", "trainers": ["Michaela Bollingerfehr"] }
]
},
{
"name": "Kinder ab 10 Jahren und Fortgeschrittene",
"sessions": [
{ "day": "Donnerstag", "time": "18:0020:30 Uhr", "trainers": ["Tina Kornatz", "Laura Ramming", "Christa Ramming"] }
]
} }
] ]
}, },

View File

@@ -1,5 +1,36 @@
{ {
"veranstaltungen": [ "veranstaltungen": [
{
"slug": "tagesskifahrt-2027",
"title": "Tagesskifahrt 2027",
"date_start": "2027-02-20",
"date_end": null,
"time": "",
"location": "Skigebiet Hochzillertal",
"department": "verein",
"status": "veroeffentlicht",
"text": "Wir fahren für einen Tag zum Skifahren ins Skigebiet Hochzillertal. Die Plätze sind auf 40 Personen begrenzt, angemeldet wird bei Fabian Früh.",
"ankuendigung": [
"Einmal im Winter machen wir uns gemeinsam auf und fahren für einen Tag ins Skigebiet Hochzillertal bei Kaltenbach. Ob Erwachsene, Jugendliche oder Kinder: Auf den Pisten ist für alle etwas dabei. Die Fahrt ist auf 40 Personen begrenzt, wer dabei sein möchte, meldet sich am besten früh.",
"Erwachsene zahlen 115 Euro, Jugendliche 95 Euro und Kinder 70 Euro. Anmelden könnt ihr euch bei Fabian Früh unter 0160 97516797."
],
"rueckblick": null,
"karte": "",
"anfahrt": [],
"plakate": [
{
"base": "img/veranstaltungen/tagesskifahrt-2027/plakat-01",
"widths": [
640,
1000,
1024
],
"alt": "Plakat zu unserer Tagesskifahrt am Samstag, 20. Februar 2027 ins Skigebiet Hochzillertal, mit den Preisen für Erwachsene, Jugendliche und Kinder und der Anmeldung bei Fabian Früh.",
"source": "1-ankuendigung.jpeg"
}
],
"galerie": []
},
{ {
"slug": "sportfest-2026", "slug": "sportfest-2026",
"title": "Sportfest 2026", "title": "Sportfest 2026",
@@ -14,6 +45,301 @@
"rueckblick": null, "rueckblick": null,
"karte": "club", "karte": "club",
"anfahrt": [], "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": [ "plakate": [
{ {
"base": "img/veranstaltungen/sportfest-2026/plakat-01", "base": "img/veranstaltungen/sportfest-2026/plakat-01",

View File

@@ -2676,6 +2676,24 @@
/* Nur die Position im Bento-Raster — Fläche, Farbe und Innenaufbau kommen von .notice. */ /* Nur die Position im Bento-Raster — Fläche, Farbe und Innenaufbau kommen von .notice. */
.turnen-dash__notice { grid-area: notice; } .turnen-dash__notice { grid-area: notice; }
/* Schlichte Gruppen als volle Bodenreihe MIT im Dashboard ($groupsInDash, z. B. Trampolin):
ein zusammenhängender Block statt einer losgelösten Kartenreihe darunter. Selektor als
.turnen-dash.turnen-dash--groups (nicht nur .turnen-dash--groups), damit die Spezifität die
:not(--single)-Regeln oben trifft — gleiche Klassenzahl, gewinnt durch spätere Position. */
.turnen-dash.turnen-dash--groups {
grid-template-areas:
"venue venue lead"
"groups groups groups";
}
.turnen-dash.turnen-dash--notice.turnen-dash--groups {
grid-template-areas:
"notice notice notice"
"venue venue lead"
"groups groups groups";
}
/* Die Reihe spannt alle drei Spalten; das interne Kartenraster liefert .group-list--grid. */
.turnen-dash__groups { grid-area: groups; }
/* Trainingsort: Adresse links, Route-Button rechts (wie team-bento__venue). */ /* Trainingsort: Adresse links, Route-Button rechts (wie team-bento__venue). */
.turnen-dash__venue { .turnen-dash__venue {
flex-direction: row; flex-direction: row;
@@ -2750,6 +2768,19 @@
"lead" "lead"
"venue"; "venue";
} }
.turnen-dash.turnen-dash--groups {
grid-template-areas:
"venue"
"lead"
"groups";
}
.turnen-dash.turnen-dash--notice.turnen-dash--groups {
grid-template-areas:
"notice"
"venue"
"lead"
"groups";
}
} }
/* === notice === */ /* === notice === */
@@ -2869,6 +2900,14 @@
} }
} }
/* Versteckte Überschrift: dann fehlt der Zeile, die den Raum zwischen Dashboard und
erster Karte gefüllt hat, ihr Inhalt. Übrig blieben 160px (80 unten von der
Dashboard-Sektion + 80 oben von dieser), in denen nur nackte Hintergrund-Textur stand
— gemessen und als Loch aufgefallen (31.07.2026). Die 80px des Dashboards reichen. */
.group-list-section--tight {
padding-top: 0;
}
/* Schlichte Gruppen (z. B. Kurse) als Raster, beschreibende Gruppen gestapelt. */ /* Schlichte Gruppen (z. B. Kurse) als Raster, beschreibende Gruppen gestapelt. */
.group-list--grid { .group-list--grid {
display: grid; display: grid;
@@ -2901,7 +2940,10 @@
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--space-4xl); gap: var(--space-4xl);
align-items: start; align-items: start;
padding: var(--space-3xl) var(--space-3xl); /* --pad-card-lg statt fester 40px, dieselbe Korrektur wie beim Tab-Panel:
auf schmalen Handys stapeln sich Container- und Kartenabstand sonst so, dass
dem Inhalt kaum Breite bleibt. Obergrenze ist der bisherige Wert. */
padding: var(--pad-card-lg) var(--pad-card-lg);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -4010,8 +4052,12 @@
"venue" "venue"
"stat"; "stat";
} }
/* Das Abteilungsfoto ist hochkant (1000×1333 = 3:4). Die Kachel bekommt
genau dieses Verhältnis, damit object-fit: cover es passgenau füllt und
niemand aus dem Gruppenfoto beschnitten wird (16/10 quer hatte hochkant
auf einen Mittelstreifen zusammengeschnitten). */
.intro-bento__image { .intro-bento__image {
aspect-ratio: 16 / 10; aspect-ratio: 3 / 4;
min-height: 0; min-height: 0;
} }
} }
@@ -5794,7 +5840,8 @@ a.match-row__link:hover {
.stadionzeitung-kontakt, .stadionzeitung-kontakt,
.stadionzeitung-partner, .stadionzeitung-partner,
.stadionzeitung-momente, .stadionzeitung-momente,
.stadionzeitung-historie { .stadionzeitung-historie,
.stadionzeitung-aufruf {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%; min-height: 100%;
@@ -5805,7 +5852,8 @@ a.match-row__link:hover {
.stadionzeitung-kontakt .stadionzeitung-page__cta, .stadionzeitung-kontakt .stadionzeitung-page__cta,
.stadionzeitung-partner .stadionzeitung-page__cta, .stadionzeitung-partner .stadionzeitung-page__cta,
.stadionzeitung-momente .stadionzeitung-page__cta, .stadionzeitung-momente .stadionzeitung-page__cta,
.stadionzeitung-historie .stadionzeitung-page__cta { .stadionzeitung-historie .stadionzeitung-page__cta,
.stadionzeitung-aufruf .stadionzeitung-page__cta {
margin-top: auto; margin-top: auto;
} }
@@ -6049,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). */ /* Vereins-Hero-Bild als Hintergrund (dieselbe Quelle wie die Startseite). */
.stadionzeitung-cover__bg { .stadionzeitung-cover__bg {
position: absolute; position: absolute;
@@ -6087,6 +6154,63 @@ body.is-sz-full .back-to-top {
width: auto; 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 { .stadionzeitung-cover__inner {
position: relative; position: relative;
z-index: 2; z-index: 2;
@@ -6561,11 +6685,17 @@ body.is-sz-full .back-to-top {
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
font-size: 4.4cqi; font-size: 4.4cqi;
line-height: 1.25; line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.stadionzeitung-termine__meta { .stadionzeitung-termine__meta {
font-size: 3.1cqi; font-size: 3.1cqi;
color: var(--clr-text-muted); color: var(--clr-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* === termine-list (Termine/Events-Seite) === */ /* === termine-list (Termine/Events-Seite) === */
@@ -6989,6 +7119,106 @@ a.news-feature__link:hover .news-feature__more {
line-height: 1; line-height: 1;
} }
/* === stadionzeitung-gegner (Live-Seite) === */
.stadionzeitung-gegner {
min-height: 100%;
}
.stadionzeitung-gegner__duell {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 2.5cqi;
margin-bottom: 5cqi;
padding: 3.3cqi;
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-btn);
background: color-mix(in srgb, var(--clr-accent) 5%, #fff);
}
.stadionzeitung-gegner__club {
--crest-size: 12cqi;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.7cqi;
min-width: 0;
text-align: center;
}
.stadionzeitung-gegner__club strong {
font-size: 3.2cqi;
line-height: 1.15;
}
.stadionzeitung-gegner__club--own strong {
color: var(--clr-accent);
}
.stadionzeitung-gegner__match {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5cqi;
font-size: 2.5cqi;
color: var(--clr-text-muted);
white-space: nowrap;
}
.stadionzeitung-gegner__match strong {
font-size: 2.9cqi;
color: var(--clr-text);
}
.stadionzeitung-gegner__vergleich {
border-block: 1px solid var(--clr-glass-border);
}
.stadionzeitung-gegner__vergleich-head,
.stadionzeitung-gegner__vergleich-row {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
text-align: center;
}
.stadionzeitung-gegner__vergleich-head {
padding-block: 1.7cqi;
font-size: 2.5cqi;
font-weight: var(--fw-bold);
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-muted);
}
.stadionzeitung-gegner__vergleich-row {
min-height: 6.5cqi;
border-top: 1px solid var(--clr-glass-border);
}
.stadionzeitung-gegner__vergleich-row span {
font-size: 2.8cqi;
color: var(--clr-text-muted);
}
.stadionzeitung-gegner__vergleich-row strong {
font-family: var(--ff-heading);
font-size: 4.8cqi;
line-height: 1;
}
.stadionzeitung-gegner__vergleich-row strong:first-child {
color: var(--clr-accent);
}
.stadionzeitung-gegner__empty {
padding: 4.2cqi;
border: 1px solid var(--clr-glass-border);
font-size: 3.3cqi;
color: var(--clr-text-muted);
text-align: center;
}
/* === stadionzeitung-vorschau (Live-Seite) === */ /* === stadionzeitung-vorschau (Live-Seite) === */
/* Nächstes Spiel je Mannschaft nach dem Spieltag — Meta-Zeile + Begegnung in /* Nächstes Spiel je Mannschaft nach dem Spieltag — Meta-Zeile + Begegnung in
zwei Wappen-Zeilen (dasselbe Zeilen-Muster wie der Ergebnis-Rückblick), zwei Wappen-Zeilen (dasselbe Zeilen-Muster wie der Ergebnis-Rückblick),
@@ -7140,19 +7370,31 @@ a.news-feature__link:hover .news-feature__more {
} }
.stadionzeitung-interview__qa { .stadionzeitung-interview__qa {
margin-bottom: 3.3cqi; margin-bottom: 2.1cqi;
}
/* Bei einem langen Interview den Kopf-Abstand etwas straffen, damit die drei
Fragen sicher auf eine A5-Seite passen (Trennlinie enger an den Text). */
.stadionzeitung-interview .stadionzeitung-page__rule {
margin-bottom: 2.4cqi;
} }
.stadionzeitung-interview__qa:last-child { .stadionzeitung-interview__qa:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
/* Frage größer als die Antwort, damit sie klar als Frage vorne steht (vorher
gleich groß, dadurch zu leise — Felix). Abel hat nur einen fetten Schnitt,
die Auszeichnung trägt also die GRÖSSE, nicht ein stärkeres Bold. Rot
scheidet als Textfarbe aus (Kontrast auf Weiß < 4,5:1). Größe bewusst auf
4.2cqi gedeckelt: bei einem langen Interview mit drei Fragen füllt es sonst
die A5-Seite über die Kante (gemessen: 4.6cqi ergab 8px Überhang). */
.stadionzeitung-interview__frage { .stadionzeitung-interview__frage {
margin-bottom: 0.8cqi; margin-bottom: 1cqi;
font-family: var(--ff-body); font-family: var(--ff-body);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
font-size: 3.4cqi; font-size: 4.2cqi;
line-height: 1.3; line-height: 1.15;
} }
.stadionzeitung-interview__antwort { .stadionzeitung-interview__antwort {
@@ -7161,6 +7403,350 @@ a.news-feature__link:hover .news-feature__more {
line-height: var(--lh-body); line-height: var(--lh-body);
} }
/* === stadionzeitung-aufruf (Live-Seite) === */
/* Redaktioneller Handlungsaufruf (zuerst der Sportfest-Helferaufruf): Kopf-
Muster + Trennlinie wie die Geschwister-Seiten, Vorspann in der Lese-Serif
(wie Vorwort/Interview), darunter die Aufgaben-Liste und die Ansprechpartner
(Namen + Nummern aus der geteilten Quelle), unten der geteilte CTA-Fuß mit
QR aufs Ziel. Inhalt chat-gepflegt (aufruf-Feld der Ausgabe). */
/* Intro + Aufgaben stehen normalerweise gestapelt (ohne Poster ist __cols ein
schlichter Block, keine Layout-Änderung). Trägt die Ausgabe ein Poster,
wird __cols zur Reihe: Text links, kleines Plakat rechts. */
.stadionzeitung-aufruf__cols--poster {
display: flex;
align-items: flex-start;
gap: 4.2cqi;
}
.stadionzeitung-aufruf__cols--poster .stadionzeitung-aufruf__col-main {
flex: 1;
min-width: 0;
}
/* Kleines Plakat rechts — Hochformat-Flyer, bewusst klein („weißt du was ich
meine"): ein visueller Anker fürs Fest neben dem Aufruf, keine zweite große
Anzeige. Feiner Rahmen wie ein aufgehängter Zettel, kein Schatten (druckt
zuverlässiger, siehe Cover-Kommentar). */
.stadionzeitung-aufruf__poster {
flex: none;
width: 30cqi;
height: auto;
border: 1px solid var(--clr-glass-border);
}
.stadionzeitung-aufruf__intro {
margin-bottom: 4.2cqi;
font-family: var(--ff-serif);
font-size: 3.6cqi;
line-height: var(--lh-body);
}
/* Im Zweispalter trägt die Aufgabenliste die Unterkante der linken Spalte —
kein Abstand mehr darunter, sonst steht das Plakat höher als der Text endet. */
.stadionzeitung-aufruf__cols--poster .stadionzeitung-aufruf__task-list {
margin-bottom: 0;
}
.stadionzeitung-aufruf__tasks-title,
.stadionzeitung-aufruf__contacts-intro {
margin-bottom: 1.6cqi;
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-aufruf__task-list {
margin: 0 0 4.2cqi;
padding: 0;
list-style: none;
}
.stadionzeitung-aufruf__task {
display: flex;
align-items: baseline;
gap: 2.2cqi;
padding-block: 1.9cqi;
font-size: 3.8cqi;
line-height: 1.3;
border-bottom: 1px solid var(--clr-glass-border);
}
.stadionzeitung-aufruf__task:last-child {
border-bottom: 0;
}
/* Icon in Akzentrot als Fläche/Glyph (nie roter Fließtext, Kontrastregel):
das Häkchen trägt Farbe, der Text bleibt schwarz. */
.stadionzeitung-aufruf__task-icon {
flex: none;
font-size: 3.4cqi;
color: var(--clr-accent);
transform: translateY(0.3cqi);
}
.stadionzeitung-aufruf__contact-list {
margin: 0;
padding: 0;
list-style: none;
}
.stadionzeitung-aufruf__contact {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 3.3cqi;
padding-block: 2.2cqi;
border-bottom: 1px solid var(--clr-glass-border);
}
.stadionzeitung-aufruf__contact:last-child {
border-bottom: 0;
}
.stadionzeitung-aufruf__contact-name {
font-family: var(--ff-body);
font-weight: var(--fw-bold);
font-size: 3.8cqi;
line-height: 1.25;
}
.stadionzeitung-aufruf__contact-phone {
display: inline-flex;
align-items: center;
gap: 1.4cqi;
flex: none;
font-size: 3.1cqi;
color: var(--clr-text-muted);
white-space: nowrap;
}
.stadionzeitung-aufruf__contact-icon {
font-size: 2.9cqi;
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) === */ /* === stadionzeitung-news (Live-Seite) === */
/* Eigene Liste statt des geteilten news-feed von /news: feste Medienspalte /* 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 — (Wappen/Foto/Icon-Badge in EINER Größe), damit alle Titel bündig stehen —
@@ -7573,6 +8159,57 @@ a.news-feature__link:hover .news-feature__more {
color: var(--clr-text); 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) === */ /* === stadionzeitung-momente (Live-Seite, Heft-Abschluss) === */
/* 2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-Ticket — quadratische /* 2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-Ticket — quadratische
Kacheln wie im Instagram-Raster, bewusst ohne Bildunterschriften: die Kacheln wie im Instagram-Raster, bewusst ohne Bildunterschriften: die
@@ -8404,4 +9041,3 @@ body.lightbox-open {
overflow: hidden; overflow: hidden;
} }
} }

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.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More