Compare commits

..

12 Commits

Author SHA1 Message Date
3b59a478c2 Stadionzeitung Lindau vom 24.10.2026 veröffentlichen 2026-10-24 11:57:43 +02:00
fs
fd305673aa Stadionzeitung gegen TDC Lindau 2 mit Jugendbeitrag vorbereiten 2026-10-24 11:52:57 +02:00
bd8f1dd129 Stadionzeitung Untersteinach vom 17.10.2026 veröffentlichen 2026-10-16 22:51:56 +02:00
fs
33b73087cf Stadionzeitung Untersteinach vorbereiten und Instagram aktualisieren 2026-10-16 22:50:26 +02:00
fs
5a107a955a Turnen: Trampolin nur noch Warteliste, Mäuse III und neue Trainerteams
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPeoUbtx58teuC15F6Anq1
2026-10-09 17:21:19 +02:00
5be1a25c07 Stadionzeitung 03.10. neu veröffentlicht (Instagram aktualisiert) 2026-10-03 12:36:13 +02:00
fs
2ae5ddb1ac Instagram: 5 neue Beiträge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hjEUDUjjhsvnGaKEPTfLX
2026-10-03 12:34:41 +02:00
fs
70831b9bea Stadionzeitung: Ausgabe 03.10. gegen VfB Kulmbach (Entwurf)
Stadtderby mit Vorwort, drei Fragen an Ali Kilic, Gegnerseite der Frauen
(Waldershof) und Galerie vom Spiel in Kirchleus. 44 Seiten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCMNXcfkM9RcaVjqoJjRsz
2026-10-02 11:10:57 +02:00
fs
fbb23a8732 Stadionzeitung: Galerie-Seite als Bento-Raster
Optionale Live-Seite hinter dem Ergebnis-Rückblick mit Fotos vom letzten
Spiel. bin/stadionzeitung-add.php liest sie aus <ordner>/galerie/, Kopf und
Alt-Texte sind Chat-Pflege und hängen an source. Preflight meldet fehlende
Galerie-Dateien.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCMNXcfkM9RcaVjqoJjRsz
2026-10-02 11:10:57 +02:00
7aae3d5ca9 Stadionzeitung 19.09. veroeffentlicht 2026-09-19 10:54:56 +02:00
fs
0ee75aa93b Stadionzeitung: Ergebnis- und Gegnerseite neu gestaltet
Ergebnisse laufen als Anzeigetafel (Wappen und Name je Seite, Ergebnis gross
in der Mitte) statt als Listenzeilen. Verworfen und im CSS begruendet: Tore
am rechten Blattrand, getoente Karten je Spiel, roter Strich unter der
Mannschaftszeile, graue Verlierer-Ziffern. Vereinsnamen einzeilig, sonst
ausgepunktet.

Die Gegnerseite bekommt ein symmetrisches Duell, eine aus den Tabellenwerten
abgeleitete Aufmacher-Zeile, den Vergleich als Balkenpaar aus der Mitte und
einen Tabellenausschnitt ueber die geteilte league-table-Komponente. Die
Balkenlaengen kommen aus Klassen in 5-Prozent-Stufen, nie aus einem
style-Attribut (die CSP verwirft inline styles stillschweigend).

Das Mitglied-werden-Poster sagt jetzt "Werde Teil unseres Vereins".

Alle geaenderten Seiten auf A5-Hoehe nachgemessen, kein Ueberstand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hv9SGDu3m3diiPrW9Fmj3v
2026-09-19 10:54:05 +02:00
fs
8f864368f0 Stadionzeitung: Ausgabe 19.09., Gegnerseiten ohne Fest, Gegner-Kurzname
Gegnerseiten lassen sich jetzt ueber `gegner_teams` auch ohne Veranstaltung
fuer weitere Mannschaften anlegen (Fenster zwei Tage vor bis drei Tage nach
dem Ausgabedatum, auch auswaerts), und `opponent_short` liefert den
gedruckten Anzeigenamen, waehrend `opponent` die BFV-Schreibweise fuer den
Namensvergleich behaelt.

Dazu die Ausgabe zum Heimspiel gegen Wirsberg/Kupferberg als Entwurf samt
Seitenbildern. Die gemeinsamen QR-Codes sind neu erzeugt (die vendorte
Bibliothek arbeitet nicht reproduzierbar, das Maskenmuster wechselt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hv9SGDu3m3diiPrW9Fmj3v
2026-09-19 10:53:56 +02:00
255 changed files with 6819 additions and 143 deletions

View File

@@ -54,7 +54,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Partner/Sponsoren | `data/partners.json` | — | | Partner/Sponsoren | `data/partners.json` | — |
| Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen | | Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen |
| 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`, `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 | | 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`, `beitrag` (Text/Quelle/Bildnachweis; `image` bleibt script-verwaltet), `rueckblick` (Text/Bildnachweis; `image` bleibt script-verwaltet), `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`), `cover` (`teaser` einer Sonderausgabe), `opponent_short` (gedruckter Anzeigename des Gegners) und `gegner_teams` (weitere Mannschaften mit Gegnerseite).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt |
| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen | | 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, 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`) | | 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`) |
@@ -521,6 +521,19 @@ Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
## Stadionzeitung (Ausgaben-Archiv) ## Stadionzeitung (Ausgaben-Archiv)
**CLI-Abrufe bei lokalem DNS-Ausfall:** `TSV_HTTP_DOH_URL=https://1.1.1.1/dns-query`
kann für `bin/matchcenter-sync.php` und `bin/instagram-add.php` gesetzt werden.
Optional, keine Änderung am System-DNS; Cron/Web ignoriert den Override.
**Redaktioneller Lesetipp (24.10.2026):** optionales chat-gepflegtes `beitrag`-Feld
(`kicker`, `heading`, `paragraphs[]`, `source`, `source_url` als HTTPS-Adresse,
`cta_label`, `image_credit`, `image.alt`). `bin/stadionzeitung-add.php` übernimmt
das Feld über erneute Läufe, schreibt `image` aus `<ordner>/beitrag.*` und erzeugt
`qr-beitrag.png` direkt zur externen Quelle. Die Seite steht im anzeigenfreien
Aufschlag hinter Vorwort/Interview; `cover.teaser` kündigt sie unabhängig von
einem Interview an. Rendering gemeinsam für Viewer/Druck über
`stadionzeitung-beitrag.php`; Preflight prüft Quelle, Foto und QR.
Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) als Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) als
**Liste über die volle Breite, Cover links und die Angaben rechts daneben** (dasselbe Muster wie **Liste über die volle Breite, Cover links und die Angaben rechts daneben** (dasselbe Muster wie
`.veranstaltung-card`, und aus demselben Grund: der Bestand ist klein). Vorher war das ein `.veranstaltung-card`, und aus demselben Grund: der Bestand ist klein). Vorher war das ein
@@ -548,6 +561,8 @@ Inhalt:
Rand: Freistellungs-Exporte lassen oft viel leeren, transparenten Rand: Freistellungs-Exporte lassen oft viel leeren, transparenten
Rand stehen, sonst wirkt der Spieler auf dem Cover kleiner als nötig) Rand stehen, sonst wirkt der Spieler auf dem Cover kleiner als nötig)
<ordner>/verfasser.* optional, Porträtfoto für die Signatur im automatischen Vorwort <ordner>/verfasser.* optional, Porträtfoto für die Signatur im automatischen Vorwort
<ordner>/galerie/* optional, Fotos vom letzten Spiel (natsort, das erste ist der
Aufmacher, höchstens sieben) für die Bento-Galerie-Seite
``` ```
**Keine Anzeigen im Ablieferordner** (seit 31.07.2026): die kommen aus dem dauerhaften Bestand **Keine Anzeigen im Ablieferordner** (seit 31.07.2026): die kommen aus dem dauerhaften Bestand
`data/anzeigen.json`, gepflegt mit `php bin/anzeige-add.php <bild-oder-ordner>` (Dateiname = Slug, `data/anzeigen.json`, gepflegt mit `php bin/anzeige-add.php <bild-oder-ordner>` (Dateiname = Slug,
@@ -571,6 +586,7 @@ Inhalt ist eine Liste von **Blöcken**, die Anzeigen füllen die Lücken dazwisc
``` ```
Cover → [Vorwort → Programm* → Interview → Aufruf (jedes nur wenn die Ausgabe es trägt)] ⌷ Cover → [Vorwort → Programm* → Interview → Aufruf (jedes nur wenn die Ausgabe es trägt)] ⌷
[Turnierpläne*: je Turnier eine Seite, EIN Block] ⌷ Ergebnis-Rückblick ⌷ [Turnierpläne*: je Turnier eine Seite, EIN Block] ⌷ Ergebnis-Rückblick ⌷
[Galerie, nur wenn galerie/ geliefert] ⌷
[Gegnerstatistik: normal nur die tragende Mannschaft, in der Sportfest-Ausgabe je [Gegnerstatistik: normal nur die tragende Mannschaft, in der Sportfest-Ausgabe je
Mannschaft mit Heimspiel am Fest eine — EIN Block, automatisch aus deren Tabelle] ⌷ 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
@@ -606,7 +622,7 @@ 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` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `impressum`, kein Bild; `vorwort` | `interview` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `galerie` | `impressum`, kein Bild;
`tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der `tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der
Team-Key der Ausgabe; `turnier` mit `turnier`: Key aus `turniere[]` der Ausgaben-Veranstaltung; 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 `danke` mit `anzeige`: das platzierte Anzeigenmotiv, siehe unten). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
@@ -795,6 +811,74 @@ keinen farbigen Rahmen über das Spielerfoto** — der Kicker bleibt „Saison
die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder 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. Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler.
**Gegnerseiten ohne Fest, und der Anzeigename des Gegners (17.09.2026).** Zwei Felder, beide
chat-gepflegt und von `bin/stadionzeitung-add.php` über erneute Läufe durchgetragen wie `vorwort`:
- **`gegner_teams`** (Liste von Team-Keys) gibt weiteren Mannschaften eine Gegnerseite, ohne dass es
eine Veranstaltung braucht. Der Sportfest-Weg blieb unverändert daneben stehen (Heimspiele im
Festzeitraum); dieser hier nimmt das nächste Spiel im Fenster **zwei Tage vor bis drei Tage nach**
dem Ausgabedatum, **auch auswärts** — am normalen Spielwochenende fährt eine der drei Mannschaften
fast immer weg, und ihr Gegner ist trotzdem eine Seite wert. `stadionzeitung_gegner_fixtures()`
bleibt die eine entscheidende Stelle. Findet eine benannte Mannschaft kein Spiel im Fenster,
entsteht keine Seite und das Skript **warnt** — ein Tippfehler im Team-Key sähe sonst aus wie ein
spielfreies Wochenende. Auf der Gegnerseite steht seitdem **immer unsere Mannschaft links**
(Klasse `--own`), bei einem Auswärtsspiel läuft die Zeile „… bei …" statt „… gegen …"; vorher war
die linke Spalte fest die Heimmannschaft und die Hervorhebung lag beim Gegner.
- **`opponent_short`** ist der gedruckte Anzeigename des Gegners, gelesen über
**`stadionzeitung_opponent_display()`** (Cover, Archivzeile, Ausgaben-Kopf, Titel, Meta, Alt-Text
der Rückseite). **`opponent` selbst muss die BFV-Schreibweise behalten** — die Gegnerseite sucht
damit ihre Tabellenzeile und ihre Begegnung in `matchcenter.json` über einen exakten
Namensvergleich. Bei Spielgemeinschaften ist das eine Zeile wie „(SG1) TSV Wirsberg I/ FC
Kupferberg I", die auf einem Cover nichts zu suchen hat; `opponent_short` trägt dann
„Wirsberg/Kupferberg". Fehlt das Feld, bleibt es beim vollen Namen.
Nebenbei löst das oft die Seitenzahl: drei Gegnerseiten statt einer sind **zwei Seiten mehr**, und
ein Heft, das bei 42 stand, steht damit auf 44.
**Galerie-Seite (02.10.2026): Fotos vom letzten Spiel als Bento-Raster.** Optional, hinter dem
Ergebnis-Rückblick (`stadionzeitung-galerie.php`). Das Feld `galerie` mischt zwei Pfleger, wie bei
den Veranstaltungsbildern: `kicker`, `heading`, `text` und die **Alt-Texte** sind Chat-Pflege,
`bilder[]` schreibt `bin/stadionzeitung-add.php` aus `<ordner>/galerie/` (Breiten 640/1200).
Alt-Texte hängen an `source` (Originaldateiname) und folgen ihrem Bild; neue Fotos bekommen die
Überschrift als Platzhalter, das Skript nennt sie per WARN. **Der Ordner ist der komplette
Bestand:** ein Lauf ohne `galerie/` lässt die Seite entfallen, weil der Ausgaben-Ordner geleert
wurde. Kein Snapshot nötig, die Bilder liegen im Ausgaben-Ordner und kein Sync fasst sie an.
Raster: 6×4-Gitter, das erste Foto 4×2, die Anordnung je Anzahl (1 bis 7) als Klasse
`__bento--nN` mit `grid-template-areas`. Entstanden als Füllseite, die eine 43-seitige Ausgabe
auf 44 brachte.
**Redaktioneller Spielrückblick (16.10.2026).** Optionales Feld `rueckblick` einer Ausgabe:
`kicker`, `heading`, `meta`, `result`, `paragraphs[]`, `credit`, `source_url`
sind Chat-Pflege; `image` (inklusive chat-gepflegtem Alt-Text) verwaltet das Add-Skript aus
`<ordner>/rueckblick.*`. Die Live-Seite steht nach den Ergebnissen, vor der Galerie. Das Foto
liegt dauerhaft im Ausgaben-Ordner. Bei erneutem Erzeugen muss die Quelle wieder mitgeliefert
werden; fehlt sie, bricht das Skript vor dem Leeren des Zielordners ab. Preflight prüft Foto
und Bildnachweis. Der gemeinsame Bild-/Textnachweis steht einmal direkt unter dem Foto
(im Web zur Quelle verlinkt, auch im Druck lesbar). Kein Bild hochskalieren oder fremd einbinden.
Für die Untersteinach-Ausgabe: öffentliches Derby-Vorschaubild
`https://www.anpfiff.info/upload/images/Teaser8/843447.jpg` (560 × 270), Bildquelle anpfiff.info.
Felix hat die Nutzungsgenehmigung im Chat am 16.10.2026 bestätigt. Ein individueller Fotograf
ist am frei sichtbaren Bild nicht ausgewiesen; deshalb keinen Namen vermuten.
**Gegner- und Ergebnisseite neu gestaltet (19.09.2026).** Beide Seiten waren inhaltlich richtig und
gestalterisch leer, der Umbau lief über mehrere verworfene Entwürfe. Was davon bleibt:
- **Ergebnisse als Anzeigetafel** (Vorbild kicker/Sofascore): Wappen und Name je Seite, das Ergebnis
groß in der Mitte. Vorher waren es Listenzeilen, und eine Zeile stellt Heim und Gast untereinander
statt gegenüber. Verworfen und nicht wieder einzubauen: Tore am rechten Blattrand (halbe
Seitenbreite Leere zwischen Name und Ziffer), eine getönte Karte je Spiel („altbacken"), ein roter
Strich unter der Mannschaftszeile (lauter als die Ergebnisse) und graue Verlierer-Ziffern (auf
Papier schlechter lesbar). Vereinsnamen laufen **einzeilig** und werden sonst ausgepunktet.
- **Gegnerseite**: Duell symmetrisch und leise (beide Wappen gleich groß, der Name des Gegners steht
nur in der Überschrift laut da), darunter eine abgeleitete Aufmacher-Zeile („Sieben Siege aus acht
Spielen", aus den Tabellenwerten gebaut), der Vergleich als **Balkenpaar aus der Mitte** und zum
Schluss ein **Tabellenausschnitt** über die geteilte `league-table`-Komponente. Der Tabellenplatz
steht bewusst dort und nicht als Balken (klein ist besser, der Balken läge falsch herum) und auch
nicht als lose Ziffer im Kopf (war gebaut, wirkte unruhig).
- **Balkenlängen kommen aus Klassen in 5-Prozent-Stufen**, nie aus einem `style`-Attribut — die CSP
verwirft inline styles stillschweigend, die Balken wären erst nach dem Deploy unsichtbar.
- Die Gegnerseite druckt **`opponent_display`** (Kurzname der Ausgabe, wenn die Seite den Gegner der
Ausgabe zeigt); `opponent` bleibt die BFV-Schreibweise für den Namensvergleich.
**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

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/** Redaktioneller Lesetipp. Text/Quelle: Chat-Pflege; Foto/QR: add-Skript. */
$beitrag = $issue['beitrag'] ?? null;
if (!is_array($beitrag)) {
return;
}
$sourceUrl = (string) ($beitrag['source_url'] ?? '');
$validUrl = filter_var($sourceUrl, FILTER_VALIDATE_URL)
&& parse_url($sourceUrl, PHP_URL_SCHEME) === 'https';
$qrPath = 'img/stadionzeitung/' . (string) ($issue['slug'] ?? '') . '/qr-beitrag.png';
?>
<div class="stadionzeitung-beitrag">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker"><?= e((string) ($beitrag['kicker'] ?? 'Lesetipp')) ?></p>
<h2 class="stadionzeitung-page__heading"><?= e((string) ($beitrag['heading'] ?? '')) ?></h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<figure class="stadionzeitung-beitrag__figure">
<?php component('img', ['image' => $beitrag['image'] ?? [], 'sizes' => '(min-width: 520px) 430px, 90vw', 'class' => 'stadionzeitung-beitrag__foto', 'eager' => $eager ?? false]); ?>
<figcaption class="stadionzeitung-rueckblick__credit"><?= e((string) ($beitrag['image_credit'] ?? '')) ?></figcaption>
</figure>
<?php foreach ($beitrag['paragraphs'] ?? [] as $paragraph): ?>
<p class="stadionzeitung-vorwort__text"><?= e((string) $paragraph) ?></p>
<?php endforeach; ?>
<p class="stadionzeitung-beitrag__source"><?= e((string) ($beitrag['source'] ?? '')) ?></p>
<?php if ($validUrl): ?>
<a class="stadionzeitung-page__cta" href="<?= e($sourceUrl) ?>" target="_blank" rel="noopener noreferrer">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label"><?= e((string) ($beitrag['cta_label'] ?? 'Lesetipp')) ?></p>
<p class="stadionzeitung-page__cta-title">Den vollständigen Beitrag lesen</p>
<p class="stadionzeitung-page__cta-link"><?= e((string) parse_url($sourceUrl, PHP_URL_HOST)) ?></p>
</div>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $qrPath)): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qrPath)) ?>" alt="QR-Code zum Originalartikel" width="100" height="100" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Zum Artikel</span>
</div>
<?php endif; ?>
</a>
<?php endif; ?>
</div>

View File

@@ -31,7 +31,7 @@ $issues = $issues ?? [];
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="stadionzeitung-card__body"> <div class="stadionzeitung-card__body">
<h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e($issue['opponent']) ?></h3> <h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e(stadionzeitung_opponent_display($issue)) ?></h3>
<p class="stadionzeitung-card__meta"> <p class="stadionzeitung-card__meta">
<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

View File

@@ -32,7 +32,7 @@ declare(strict_types=1);
* der Gegner läuft über den Initialen-Fallback der crest-Komponente. * der Gegner läuft über den Initialen-Fallback der crest-Komponente.
*/ */
$issue = $issue ?? []; $issue = $issue ?? [];
$opponent = (string) ($issue['opponent'] ?? ''); $opponent = stadionzeitung_opponent_display($issue);
$date = (string) ($issue['date'] ?? ''); $date = (string) ($issue['date'] ?? '');
$teamKey = (string) ($issue['team_key'] ?? ''); $teamKey = (string) ($issue['team_key'] ?? '');
$playerImage = $issue['player_image'] ?? null; $playerImage = $issue['player_image'] ?? null;

View File

@@ -40,19 +40,28 @@ $matchcenterLink = club_domain('matchcenter');
<section class="stadionzeitung-ergebnisse__team"> <section class="stadionzeitung-ergebnisse__team">
<h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3> <h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3>
<?php foreach ($group['matches'] ?? [] as $match): ?> <?php foreach ($group['matches'] ?? [] as $match): ?>
<div class="stadionzeitung-ergebnisse__match"> <?php
<p class="stadionzeitung-ergebnisse__meta"> $sides = [
<?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?> ['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null],
</p> ['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null],
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null], ];
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null]] as $side): ?> ?>
<div class="stadionzeitung-ergebnisse__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__row--own' : '' ?>"> <article class="stadionzeitung-ergebnisse__match">
<?php foreach ($sides as $i => $side): ?>
<?php if ($i === 1): ?>
<div class="stadionzeitung-ergebnisse__score">
<p class="stadionzeitung-ergebnisse__result">
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $sides[0]['goals']) ?></span><span class="stadionzeitung-ergebnisse__colon">:</span><span class="stadionzeitung-ergebnisse__goals"><?= e((string) $sides[1]['goals']) ?></span>
</p>
<p class="stadionzeitung-ergebnisse__meta"><?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?></p>
</div>
<?php endif; ?>
<div class="stadionzeitung-ergebnisse__side<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__side--own' : '' ?>">
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?> <?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
<span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span> <span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span>
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $side['goals']) ?></span>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </article>
<?php endforeach; ?> <?php endforeach; ?>
</section> </section>
<?php endforeach; ?> <?php endforeach; ?>

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Galerie-Seite der Stadionzeitung (Live-Seite, optional) — Fotos vom letzten
* Spiel als Bento-Raster: ein großes Aufmacherfoto, die übrigen in kleineren
* Kacheln darum. Das Raster füllt die ganze Blattfläche unter dem Kopf, die
* Fotos werden per object-fit auf ihre Kachel beschnitten.
*
* Props: $issue Eintrag aus data/stadionzeitung.json. Gelesen wird das Feld
* `galerie`: `kicker`, `heading`, `text?` sind Chat-Pflege, `bilder[]`
* (base/widths/alt/source) schreibt bin/stadionzeitung-add.php aus
* <ordner>/galerie/. Alt-Texte kommen aus dem Chat und hängen an `source`.
*
* Die Bilder liegen im Ausgaben-Ordner und werden von keinem Sync angefasst,
* deshalb braucht die Seite keinen Snapshot.
*/
$galerie = is_array($issue['galerie'] ?? null) ? $issue['galerie'] : [];
$bilder = array_slice(array_values(array_filter(
$galerie['bilder'] ?? [],
static fn ($b): bool => is_array($b) && !empty($b['base']) && !empty($b['widths'])
)), 0, 7);
$kicker = trim((string) ($galerie['kicker'] ?? 'Rückblick'));
$heading = trim((string) ($galerie['heading'] ?? 'Fotos vom letzten Spiel'));
$text = trim((string) ($galerie['text'] ?? ''));
?>
<div class="stadionzeitung-galerie">
<header class="stadionzeitung-page__head">
<?php if ($kicker !== ''): ?>
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
<?php endif; ?>
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
<?php if ($text !== ''): ?>
<p class="stadionzeitung-galerie__text"><?= e($text) ?></p>
<?php endif; ?>
</header>
<?php if ($bilder !== []): ?>
<div class="stadionzeitung-galerie__bento stadionzeitung-galerie__bento--n<?= count($bilder) ?>">
<?php foreach ($bilder as $i => $bild): ?>
<?php component('img', [
'image' => $bild,
// Aufmacher gut zwei Drittel der Seitenbreite, die Kacheln ein Drittel.
'sizes' => $i === 0 ? '(min-width: 520px) 340px, 70vw' : '(min-width: 520px) 240px, 50vw',
'class' => 'stadionzeitung-galerie__foto',
]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>

View File

@@ -18,15 +18,29 @@ $teamName = trim((string) ($info['team_name'] ?? ''));
$fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : []; $fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : [];
$own = is_array($info['own'] ?? null) ? $info['own'] : null; $own = is_array($info['own'] ?? null) ? $info['own'] : null;
$opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null; $opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null;
$opponentName = (string) ($info['opponent'] ?? 'Unser Gegner'); // Gedruckt wird der Anzeigename (Kurzname der Ausgabe, wenn gepflegt) — die
// BFV-Schreibweise „(SG1) TSV Wirsberg I/ FC Kupferberg I" ist ein Suchschlüssel,
// keine Überschrift.
$ausschnitt = is_array($info['table_excerpt'] ?? null) ? $info['table_excerpt'] : [];
$opponentName = (string) ($info['opponent_display'] ?? $info['opponent'] ?? 'Unser Gegner');
// Links steht IMMER unsere Mannschaft (Klasse --own hebt sie hervor), rechts der
// Gegner. Bei einem Heimspiel ist das ohnehin die Reihenfolge Heim/Gast; fährt die
// Mannschaft aber auswärts, trüge die Hervorhebung sonst der gegnerische Verein.
$istHeim = !empty($fixture['is_home']);
$ownName = (string) (($istHeim ? ($fixture['home'] ?? null) : ($fixture['away'] ?? null)) ?? 'TSV 08 Kulmbach');
$ownCrest = ($istHeim ? ($fixture['home_crest'] ?? null) : ($fixture['away_crest'] ?? null)) ?? $own['crest'] ?? null;
$gegnerLabel = $opponentName;
$gegnerCrest = ($istHeim ? ($fixture['away_crest'] ?? null) : ($fixture['home_crest'] ?? null)) ?? $opponent['crest'] ?? null;
$kickoff = (string) ($fixture['kickoff'] ?? ''); $kickoff = (string) ($fixture['kickoff'] ?? '');
$kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : ''; $kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : '';
$kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : ''; $kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : '';
// Der Tabellenplatz steht NICHT in der Balkenliste: klein ist dort besser,
// ein Balken läge genau falsch herum. Er gehört nach oben neben die Wappen.
// „Spiele“ fällt ebenfalls raus (für beide gleich, steht in der Kopfzeile).
$rows = [ $rows = [
['label' => 'Tabellenplatz', 'key' => 'rank'],
['label' => 'Spiele', 'key' => 'played'],
['label' => 'Siege', 'key' => 'won'], ['label' => 'Siege', 'key' => 'won'],
['label' => 'Unentschieden', 'key' => 'drawn'], ['label' => 'Unentschieden', 'key' => 'drawn'],
['label' => 'Niederlagen', 'key' => 'lost'], ['label' => 'Niederlagen', 'key' => 'lost'],
@@ -41,6 +55,57 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
$value = (int) $row[$key]; $value = (int) $row[$key];
return $signed && $value > 0 ? '+' . $value : (string) $value; return $signed && $value > 0 ? '+' . $value : (string) $value;
}; };
// Balkenlänge als KLASSE, nie als style-Attribut: die CSP verwirft inline
// styles stillschweigend (siehe CLAUDE.md), der Balken wäre in Produktion
// unsichtbar. Deshalb 5-Prozent-Stufen, die components.css ausdefiniert.
$barStep = static function (?array $row, ?array $other, string $key): int {
if ($row === null) {
return 0;
}
$value = abs((int) ($row[$key] ?? 0));
$scale = max($value, $other === null ? 0 : abs((int) ($other[$key] ?? 0)));
if ($scale <= 0) {
return 0;
}
return (int) round($value / $scale * 20);
};
// Ein Satz statt einer Zahlenkolonne: derselbe Bestand, nur in Sprache. Die
// erste zutreffende Regel gewinnt, damit immer genau eine Aussage dasteht.
$zahlwort = static function (int $n): string {
$woerter = [0 => 'Kein', 1 => 'Ein', 2 => 'Zwei', 3 => 'Drei', 4 => 'Vier', 5 => 'Fünf',
6 => 'Sechs', 7 => 'Sieben', 8 => 'Acht', 9 => 'Neun', 10 => 'Zehn', 11 => 'Elf',
12 => 'Zwölf', 13 => 'Dreizehn', 14 => 'Vierzehn', 15 => 'Fünfzehn'];
return $woerter[$n] ?? (string) $n;
};
$aufmacher = '';
if ($opponent !== null) {
$played = (int) ($opponent['played'] ?? 0);
$won = (int) ($opponent['won'] ?? 0);
$lost = (int) ($opponent['lost'] ?? 0);
$rank = (int) ($opponent['rank'] ?? 0);
$points = (int) ($opponent['points'] ?? 0);
$diff = (int) ($opponent['goal_diff'] ?? 0);
if ($played > 0 && $lost === 0) {
$aufmacher = 'Noch ohne Niederlage';
} elseif ($played > 0 && $won === 0) {
$aufmacher = 'Noch ohne Sieg';
} elseif ($played > 0 && $won / $played >= 0.7) {
$aufmacher = $zahlwort($won) . ' Siege aus ' . mb_strtolower($zahlwort($played)) . ' Spielen';
} elseif ($rank === 1) {
$aufmacher = 'Tabellenführer mit ' . $points . ' Punkten';
} elseif ($played > 0 && $diff <= -8) {
$aufmacher = $zahlwort(abs($diff)) . ' Tore mehr kassiert als geschossen';
} elseif ($played > 0 && $diff >= 8) {
$aufmacher = $zahlwort($diff) . ' Tore mehr geschossen als kassiert';
} elseif ($played > 0) {
$aufmacher = $points . ' Punkte aus ' . $played . ' Spielen';
}
}
$spieltagInfo = $opponent !== null && (int) ($opponent['played'] ?? 0) > 0
? 'Stand nach ' . (int) $opponent['played'] . ' Spieltagen'
: '';
?> ?>
<div class="stadionzeitung-gegner"> <div class="stadionzeitung-gegner">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">
@@ -53,25 +118,26 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
<span class="stadionzeitung-page__rule" aria-hidden="true"></span> <span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<div class="stadionzeitung-gegner__duell"> <div class="stadionzeitung-gegner__duell">
<div class="stadionzeitung-gegner__club stadionzeitung-gegner__club--own"> <div class="stadionzeitung-gegner__club">
<?php component('crest', ['src' => $fixture['home_crest'] ?? $own['crest'] ?? null, 'name' => $fixture['home'] ?? 'TSV 08 Kulmbach']); ?> <?php component('crest', ['src' => $ownCrest, 'name' => $ownName]); ?>
<strong><?= e((string) ($fixture['home'] ?? 'TSV 08 Kulmbach')) ?></strong> <span class="stadionzeitung-gegner__club-name"><?= e($ownName) ?></span>
</div> </div>
<div class="stadionzeitung-gegner__match"> <div class="stadionzeitung-gegner__vs">
<span>gegen</span> <p class="stadionzeitung-gegner__vs-wort"><?= $istHeim ? 'gegen' : 'bei' ?></p>
<?php if ($kickoffDate !== ''): ?> <?php if ($kickoffDate !== ''): ?>
<strong><?= e(german_date($kickoffDate)) ?></strong> <p class="stadionzeitung-gegner__anstoss"><?= e(german_date($kickoffDate)) ?><?php if ($kickoffTime !== ''): ?><br><?= e($kickoffTime) ?> Uhr<?php endif; ?></p>
<?php endif; ?>
<?php if ($kickoffTime !== ''): ?>
<span><?= e($kickoffTime) ?> Uhr</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="stadionzeitung-gegner__club"> <div class="stadionzeitung-gegner__club">
<?php component('crest', ['src' => $fixture['away_crest'] ?? $opponent['crest'] ?? null, 'name' => $fixture['away'] ?? $opponentName]); ?> <?php component('crest', ['src' => $gegnerCrest, 'name' => $gegnerLabel]); ?>
<strong><?= e((string) ($fixture['away'] ?? $opponentName)) ?></strong> <span class="stadionzeitung-gegner__club-name"><?= e($gegnerLabel) ?></span>
</div> </div>
</div> </div>
<?php if ($aufmacher !== ''): ?>
<p class="stadionzeitung-gegner__aufmacher"><?= e($aufmacher) ?></p>
<?php endif; ?>
<?php if ($own !== null && $opponent !== null): ?> <?php if ($own !== null && $opponent !== null): ?>
<div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich"> <div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich">
<div class="stadionzeitung-gegner__vergleich-head" role="row"> <div class="stadionzeitung-gegner__vergleich-head" role="row">
@@ -79,18 +145,33 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe
mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?> mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?>
<span role="columnheader">Wir</span> <span role="columnheader">Wir</span>
<span role="columnheader">Vergleich</span> <span role="columnheader"><?= e($spieltagInfo !== '' ? $spieltagInfo : 'Vergleich') ?></span>
<span role="columnheader">Gegner</span> <span role="columnheader">Gegner</span>
</div> </div>
<?php foreach ($rows as $row): ?> <?php foreach ($rows as $row): ?>
<div class="stadionzeitung-gegner__vergleich-row" role="row"> <div class="stadionzeitung-gegner__vergleich-row" role="row">
<strong role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong> <strong class="stadionzeitung-gegner__wert" role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
<span role="rowheader"><?= e((string) $row['label']) ?></span> <span class="stadionzeitung-gegner__balken stadionzeitung-gegner__balken--own" aria-hidden="true"><span class="stadionzeitung-gegner__balken-fuellung stadionzeitung-gegner__balken-fuellung--s<?= $barStep($own, $opponent, (string) $row['key']) ?>"></span></span>
<strong role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong> <span class="stadionzeitung-gegner__label" role="rowheader"><?= e((string) $row['label']) ?></span>
<span class="stadionzeitung-gegner__balken" aria-hidden="true"><span class="stadionzeitung-gegner__balken-fuellung stadionzeitung-gegner__balken-fuellung--s<?= $barStep($opponent, $own, (string) $row['key']) ?>"></span></span>
<strong class="stadionzeitung-gegner__wert" role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php else: ?> <?php else: ?>
<p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p> <p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p>
<?php endif; ?> <?php endif; ?>
<?php /* Der Tabellenplatz stand kurz als zwei lose Ziffern oben im Duell und
wirkte dort unruhig (Felix, 19.09.2026). Hier zeigt ihn der Ausschnitt
im Zusammenhang: wer steht zwischen uns und ihnen. Gerendert mit der
geteilten league-table-Komponente, nie mit einer zweiten Tabelle. */ ?>
<?php if ($ausschnitt !== []): ?>
<div class="stadionzeitung-gegner__ausschnitt">
<p class="stadionzeitung-gegner__ausschnitt-titel">Die Tabelle dazwischen</p>
<?php foreach ($ausschnitt as $block): ?>
<?php component('league-table', ['table' => $block, 'caption' => 'Tabellenausschnitt ' . (string) ($info['league'] ?? '')]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/** /**
* Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste: * Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste:
* riesige zentrierte Coolvetica-Headline („Werde Teil des Nullachters", das * riesige zentrierte Coolvetica-Headline („Werde Teil unseres Vereins", das
* letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile, * letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile,
* unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein * unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein
* jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den * jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den
@@ -25,8 +25,8 @@ $mitgliedLink = club_domain('mitglied-werden');
<img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97"> <img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<h2 class="stadionzeitung-mitglied__headline"> <h2 class="stadionzeitung-mitglied__headline">
<span class="stadionzeitung-mitglied__line">Werde Teil</span> <span class="stadionzeitung-mitglied__line">Werde Teil</span>
<span class="stadionzeitung-mitglied__line">des</span> <span class="stadionzeitung-mitglied__line">unseres</span>
<span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Nullachters</span> <span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Vereins</span>
</h2> </h2>
<p class="stadionzeitung-mitglied__claim">Seit 1908 in Kulmbach zuhause. Egal ob auf dem Platz, in der Halle oder am Spielfeldrand: bei uns ist Platz für dich.</p> <p class="stadionzeitung-mitglied__claim">Seit 1908 in Kulmbach zuhause. Egal ob auf dem Platz, in der Halle oder am Spielfeldrand: bei uns ist Platz für dich.</p>
<p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p> <p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/** Redaktioneller Spielrückblick. Text/Quelle: Chat-Pflege; image: add-Skript. */
$rueckblick = $issue['rueckblick'] ?? [];
$sourceUrl = (string) ($rueckblick['source_url'] ?? '');
$sourceLink = filter_var($sourceUrl, FILTER_VALIDATE_URL)
&& in_array(parse_url($sourceUrl, PHP_URL_SCHEME), ['https', 'http'], true);
?>
<div class="stadionzeitung-rueckblick">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker"><?= e((string) ($rueckblick['kicker'] ?? 'Rückblick')) ?></p>
<h2 class="stadionzeitung-page__heading"><?= e((string) ($rueckblick['heading'] ?? '')) ?></h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<div class="stadionzeitung-rueckblick__scoreline">
<p class="stadionzeitung-ergebnisse__result"><?= e((string) ($rueckblick['result'] ?? '')) ?></p>
<p class="stadionzeitung-page__sub"><?= e((string) ($rueckblick['meta'] ?? '')) ?></p>
</div>
<figure class="stadionzeitung-rueckblick__figure">
<?php component('img', [
'image' => $rueckblick['image'],
'sizes' => '(min-width: 520px) 430px, 90vw',
'class' => 'stadionzeitung-rueckblick__foto',
'eager' => $eager ?? false,
]); ?>
<figcaption class="stadionzeitung-rueckblick__credit">
<?php if ($sourceLink): ?>
<a href="<?= e($sourceUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e((string) ($rueckblick['credit'] ?? '')) ?></a>
<?php else: ?>
<?= e((string) ($rueckblick['credit'] ?? '')) ?>
<?php endif; ?>
</figcaption>
</figure>
<div class="stadionzeitung-rueckblick__text">
<?php foreach ($rueckblick['paragraphs'] ?? [] as $paragraph): ?>
<p class="stadionzeitung-vorwort__text"><?= e((string) $paragraph) ?></p>
<?php endforeach; ?>
</div>
</div>

View File

@@ -11,7 +11,8 @@ declare(strict_types=1);
* type 'image' (base/widths/alt), * type 'image' (base/widths/alt),
* type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier| * type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier|
* tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt| * tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt|
* mitglied|historie|sportheim|partner|danke|momente|impressum; * mitglied|historie|sportheim|partner|danke|momente|galerie|
* impressum;
* tabelle und gegner optional mit team: Team-Key aus * tabelle und gegner optional mit team: Team-Key aus
* matchcenter.json; turnier mit turnier: Key aus * matchcenter.json; turnier mit turnier: Key aus
* veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung), * veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung),
@@ -49,6 +50,9 @@ $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 'beitrag': ?>
<?php component('stadionzeitung-beitrag', ['issue' => $issue, 'eager' => $eager]); ?>
<?php break; ?>
<?php case 'aufruf': ?> <?php case 'aufruf': ?>
<?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?> <?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?>
<?php break; ?> <?php break; ?>
@@ -96,6 +100,12 @@ $type = $entry['type'] ?? 'image';
wie jede andere Anzeige in images[] eingefroren ist. */ ?> wie jede andere Anzeige in images[] eingefroren ist. */ ?>
<?php component('stadionzeitung-danke', ['anzeige' => $entry['anzeige'] ?? null]); ?> <?php component('stadionzeitung-danke', ['anzeige' => $entry['anzeige'] ?? null]); ?>
<?php break; ?> <?php break; ?>
<?php case 'galerie': ?>
<?php component('stadionzeitung-galerie', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'rueckblick': ?>
<?php component('stadionzeitung-rueckblick', ['issue' => $issue, 'eager' => $eager]); ?>
<?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

@@ -26,7 +26,7 @@ $total = count($images);
if ($images === []) { if ($images === []) {
return; return;
} }
$opponent = (string) ($issue['opponent'] ?? ''); $opponent = stadionzeitung_opponent_display($issue);
// Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält. // Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält.
$hasLive = false; $hasLive = false;

View File

@@ -992,6 +992,22 @@ function stadionzeitung_teaser(array $issue): string
return trim((string) ($issue['interview']['teaser'] ?? '')); return trim((string) ($issue['interview']['teaser'] ?? ''));
} }
/**
* Der Name des Gegners, wie er GEDRUCKT wird — Cover, Archivzeile, Kopf, Meta.
*
* `opponent` muss die BFV-Schreibweise tragen, weil die Gegnerseite damit ihre
* Tabellenzeile und ihre Begegnung in matchcenter.json findet (exakter
* Namensvergleich). Bei Spielgemeinschaften ist das eine Zeile wie
* „(SG1) TSV Wirsberg I/ FC Kupferberg I", die auf einem Cover nichts zu
* suchen hat. `opponent_short` ist deshalb der im Chat gepflegte Anzeigename
* („Wirsberg/Kupferberg"); fehlt er, bleibt es beim vollen Namen.
*/
function stadionzeitung_opponent_display(array $issue): string
{
$short = trim((string) ($issue['opponent_short'] ?? ''));
return $short !== '' ? $short : trim((string) ($issue['opponent'] ?? ''));
}
/** /**
* Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE * Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE
* Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND * Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND
@@ -999,10 +1015,19 @@ function stadionzeitung_teaser(array $issue): string
* oder Daten ohne Seite entstehen. * oder Daten ohne Seite entstehen.
* *
* Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`). * Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`).
* Zusätzlich, wenn die Ausgabe eine `veranstaltung` (Slug) mit Zeitraum trägt * Zusätzlich auf zwei Wegen, die sich nicht in die Quere kommen:
* (Sportfest-Ausgabe): jede weitere Mannschaft aus matchcenter.json mit einem *
* HEIMspiel in diesem Zeitraum — drei Heimspiele am Fest, drei Gegnerseiten. * 1. `veranstaltung` (Slug) mit Zeitraum — die Sportfest-Ausgabe: jede weitere
* Der Gegner ist dann der andere Verein der Begegnung. Reihenfolge wie in * Mannschaft aus matchcenter.json mit einem HEIMspiel in diesem Zeitraum.
* Am Fest spielen alle zuhause, das ist der Aufhänger.
* 2. `gegner_teams` (Liste von Team-Keys, im Chat gepflegt) — für ein normales
* Spielwochenende, an dem auch die anderen Mannschaften antreten. Genommen
* wird ihr nächstes Spiel im Fenster von zwei Tagen vor bis drei Tage nach
* dem Ausgabedatum, AUCH auswärts: der Gegner der Frauen ist auch dann eine
* Seite wert, wenn wir zu ihm fahren. Ohne Spiel in diesem Fenster entsteht
* keine Seite; bin/stadionzeitung-add.php meldet das.
*
* Der Gegner ist jeweils der andere Verein der Begegnung. Reihenfolge wie in
* matchcenter.json (= config 'matchcenter.teams'). * matchcenter.json (= config 'matchcenter.teams').
* *
* Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']] * Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']]
@@ -1013,14 +1038,19 @@ function stadionzeitung_gegner_fixtures(array $issue): array
$issueTeam = (string) ($issue['team_key'] ?? ''); $issueTeam = (string) ($issue['team_key'] ?? '');
$issueOpponent = trim((string) ($issue['opponent'] ?? '')); $issueOpponent = trim((string) ($issue['opponent'] ?? ''));
$von = ''; $eventVon = '';
$bis = ''; $eventBis = '';
$veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? '')); $veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
if ($veranstaltung !== null) { if ($veranstaltung !== null) {
$von = (string) ($veranstaltung['date_start'] ?? ''); $eventVon = (string) ($veranstaltung['date_start'] ?? '');
$bis = (string) ($veranstaltung['date_end'] ?? $von); $eventBis = (string) ($veranstaltung['date_end'] ?? $eventVon);
} }
$extraTeams = array_values(array_filter(array_map(
static fn ($k): string => trim((string) $k),
is_array($issue['gegner_teams'] ?? null) ? $issue['gegner_teams'] : []
)));
$fixtures = []; $fixtures = [];
foreach (json_load('matchcenter')['teams'] ?? [] as $team) { foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
$key = (string) ($team['key'] ?? ''); $key = (string) ($team['key'] ?? '');
@@ -1033,18 +1063,32 @@ function stadionzeitung_gegner_fixtures(array $issue): array
} }
continue; continue;
} }
// Weg 2: ausdrücklich benannte Mannschaft, Fenster ums Ausgabedatum,
// Heim wie auswärts. Steht sie in `gegner_teams`, gilt dieses Fenster
// und nicht der Veranstaltungszeitraum.
if (in_array($key, $extraTeams, true) && $date !== '') {
$von = date('Y-m-d', strtotime($date . ' -2 days'));
$bis = date('Y-m-d', strtotime($date . ' +3 days'));
$nurHeim = false;
} else {
$von = $eventVon;
$bis = $eventBis;
$nurHeim = true;
}
if ($von === '') { if ($von === '') {
continue; continue;
} }
foreach ($team['next_matches'] ?? [] as $match) { foreach ($team['next_matches'] ?? [] as $match) {
if (empty($match['is_home'])) { $istHeim = !empty($match['is_home']);
if ($nurHeim && !$istHeim) {
continue; continue;
} }
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10); $matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
if ($matchDate < $von || $matchDate > $bis) { if ($matchDate < $von || $matchDate > $bis) {
continue; continue;
} }
$opponent = trim((string) ($match['away'] ?? '')); $opponent = trim((string) ($istHeim ? ($match['away'] ?? '') : ($match['home'] ?? '')));
if ($opponent === '' || $opponent === 'SPIELFREI') { if ($opponent === '' || $opponent === 'SPIELFREI') {
continue; continue;
} }
@@ -1140,14 +1184,53 @@ function stadionzeitung_build_snapshot(array $issue): array
} }
} }
// `opponent` behält die BFV-Schreibweise (der Namensvergleich oben
// hängt daran), gedruckt wird `opponent_display`: bei der tragenden
// Mannschaft der Kurzname der Ausgabe, sonst der volle Name.
$gegnerDisplay = strcasecmp($gegnerName, trim((string) ($issue['opponent'] ?? ''))) === 0
? stadionzeitung_opponent_display($issue)
: $gegnerName;
// Tabellenausschnitt statt zweier loser Platz-Ziffern im Kopf: ein
// Fenster um beide Mannschaften herum (mit einer Zeile Luft davor
// und dahinter). Liegen sie weit auseinander, werden es zwei
// Blöcke — gerendert wird beides mit der geteilten
// league-table-Komponente, nie mit einer zweiten Tabelle.
$ausschnitt = [];
if ($ownRow !== null && $opponentRow !== null && $table !== []) {
$ranks = [(int) ($ownRow['rank'] ?? 0), (int) ($opponentRow['rank'] ?? 0)];
sort($ranks);
$fenster = static function (int $von, int $bis) use ($table): array {
return array_values(array_filter($table, static function (array $row) use ($von, $bis): bool {
$rank = (int) ($row['rank'] ?? 0);
return $rank >= $von && $rank <= $bis;
}));
};
if ($ranks[1] - $ranks[0] <= 5) {
$block = $fenster($ranks[0], $ranks[1]);
if ($block !== []) {
$ausschnitt[] = $block;
}
} else {
foreach ($ranks as $rank) {
$block = $fenster($rank, $rank + 1);
if ($block !== []) {
$ausschnitt[] = $block;
}
}
}
}
$gegner[$key] = [ $gegner[$key] = [
'team_name' => (string) ($team['name'] ?? ''), 'team_name' => (string) ($team['name'] ?? ''),
'opponent' => $gegnerName, 'opponent' => $gegnerName,
'opponent_display' => $gegnerDisplay,
'league' => (string) ($team['league'] ?? ''), 'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''), 'season' => (string) ($team['season'] ?? ''),
'fixture' => $fixture, 'fixture' => $fixture,
'own' => $ownRow, 'own' => $ownRow,
'opponent_stats' => $opponentRow, 'opponent_stats' => $opponentRow,
'table_excerpt' => $ausschnitt,
]; ];
} }

View File

@@ -78,14 +78,14 @@ $firstImage = $issue['images'][0] ?? null;
$cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null; $cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null;
$meta = [ $meta = [
'title' => 'Stadionzeitung gegen ' . $issue['opponent'] . ($isDruck ? ' (Druckfassung)' : ''), 'title' => 'Stadionzeitung gegen ' . stadionzeitung_opponent_display($issue) . ($isDruck ? ' (Druckfassung)' : ''),
'description' => 'Stadionzeitung zum Heimspiel gegen ' . $issue['opponent'] . ' am ' . german_date($issue['date']) . ' – digital zum Durchblättern.', 'description' => 'Stadionzeitung zum Heimspiel gegen ' . stadionzeitung_opponent_display($issue) . ' am ' . german_date($issue['date']) . ' – digital zum Durchblättern.',
'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'], 'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'],
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'], ['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'], ['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
['name' => 'Gegen ' . $issue['opponent'], 'slug' => 'stadionzeitung/' . $issue['slug']], ['name' => 'Gegen ' . stadionzeitung_opponent_display($issue), 'slug' => 'stadionzeitung/' . $issue['slug']],
]), ]),
]; ];
if ($cover !== null) { if ($cover !== null) {
@@ -113,7 +113,7 @@ $druckBlaetter = (int) ceil($druckSeiten / 4);
<a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>"> <a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>">
<?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?> <?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?>
</a> </a>
<p class="stadionzeitung-issue-head__title">Gegen <?= e($issue['opponent']) ?></p> <p class="stadionzeitung-issue-head__title">Gegen <?= e(stadionzeitung_opponent_display($issue)) ?></p>
<?php if ($isDruck): ?> <?php if ($isDruck): ?>
<p class="stadionzeitung-issue-head__actions"> <p class="stadionzeitung-issue-head__actions">
<button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden> <button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden>

View File

@@ -149,6 +149,11 @@ if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
// --- HTTP-Helfer, unverändert aus bin/instagram-sync.php übernommen --- // --- HTTP-Helfer, unverändert aus bin/instagram-sync.php übernommen ---
$fetch = static function (string $url, array $headers = []): string|false { $fetch = static function (string $url, array $headers = []): string|false {
$ch = curl_init($url); $ch = curl_init($url);
// Optional wie im Matchcenter-Sync, wenn der lokale DNS-Dienst ausfällt.
$dohUrl = getenv('TSV_HTTP_DOH_URL') ?: '';
if ($dohUrl !== '') {
curl_setopt($ch, CURLOPT_DOH_URL, $dohUrl);
}
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,

View File

@@ -94,6 +94,11 @@ if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) --- // --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
$fetch = static function (string $url, bool $json = true) use ($referer): string|false { $fetch = static function (string $url, bool $json = true) use ($referer): string|false {
$ch = curl_init($url); $ch = curl_init($url);
// Optionaler CLI-Ausweg bei defektem lokalem DNS; keine Systemänderung.
$dohUrl = PHP_SAPI === 'cli' ? (getenv('TSV_HTTP_DOH_URL') ?: '') : '';
if ($dohUrl !== '') {
curl_setopt($ch, CURLOPT_DOH_URL, $dohUrl);
}
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,

View File

@@ -386,6 +386,63 @@ if (is_file($szFile)) {
} }
} }
// --- Stadionzeitung: Galerie-Fotos vorhanden? ---
// Die Galerie-Bilder liegen im Ausgaben-Ordner, den bin/stadionzeitung-add.php bei
// jedem Lauf leert und neu füllt. Fehlt eine Datei (Lauf ohne galerie/ im Ordner,
// Bild nicht mitcommittet), zeigt die Seite ein kaputtes Bild.
if (is_file($szFile)) {
$szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []);
foreach ($szData['issues'] ?? [] as $ausgabe) {
if (is_array($ausgabe['beitrag'] ?? null)) {
$beitrag = $ausgabe['beitrag'];
foreach (['heading', 'source', 'source_url', 'image_credit'] as $feld) {
if (trim((string) ($beitrag[$feld] ?? '')) === '') {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrag ohne {$feld}.";
}
}
$quelle = (string) ($beitrag['source_url'] ?? '');
if (!filter_var($quelle, FILTER_VALIDATE_URL) || parse_url($quelle, PHP_URL_SCHEME) !== 'https') {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrag mit ungültiger Quellen-URL.";
}
$foto = $beitrag['image'] ?? [];
if (empty($foto['base']) || empty($foto['widths']) || empty($foto['alt'])) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitragsfoto oder Alt-Text fehlt.";
}
foreach ($foto['widths'] ?? [] as $w) {
if (!is_file(PUBLIC_PATH . '/assets/' . ($foto['base'] ?? '') . "-{$w}.jpg")) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitragsfoto fehlt ({$w}px).";
}
}
if (!is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/' . $ausgabe['slug'] . '/qr-beitrag.png')) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrags-QR fehlt.";
}
}
if (is_array($ausgabe['rueckblick'] ?? null)) {
$rueckblick = $ausgabe['rueckblick'];
$foto = $rueckblick['image'] ?? [];
if (empty($foto['base']) || empty($foto['widths'])) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Foto zum Spielrückblick fehlt.";
}
foreach ($foto['widths'] ?? [] as $w) {
if (!is_file(PUBLIC_PATH . '/assets/' . ($foto['base'] ?? '') . "-{$w}.jpg")) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Rückblick-Foto fehlt ({$w}px).";
}
}
if (trim((string) ($rueckblick['credit'] ?? '')) === '') {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Bildnachweis zum Spielrückblick fehlt.";
}
}
foreach ($ausgabe['galerie']['bilder'] ?? [] as $bild) {
foreach ($bild['widths'] ?? [] as $w) {
$datei = PUBLIC_PATH . '/assets/' . ($bild['base'] ?? '') . "-{$w}.jpg";
if (!is_file($datei)) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Galerie-Foto fehlt (" . ($bild['base'] ?? '?') . "-{$w}.jpg).";
}
}
}
}
}
// --- 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

@@ -19,6 +19,9 @@ declare(strict_types=1);
* <ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz) * <ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz)
* fürs Cover — wird unverändert kopiert, nie über GD neu kodiert * fürs Cover — wird unverändert kopiert, nie über GD neu kodiert
* <ordner>/verfasser.* optional, Porträtfoto für die Vorwort-Signatur * <ordner>/verfasser.* optional, Porträtfoto für die Vorwort-Signatur
* <ordner>/galerie/* optional, Fotos vom letzten Spiel (natsort = Reihenfolge,
* das erste ist der Aufmacher) für die Bento-Galerie-
* Seite hinter dem Ergebnis-Rückblick, höchstens sieben
* *
* KEINE Anzeigen im Ordner (seit 31.07.2026): die kommen aus dem dauerhaften * KEINE Anzeigen im Ordner (seit 31.07.2026): die kommen aus dem dauerhaften
* Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive * Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive
@@ -157,6 +160,9 @@ $vorwort = $findOne('vorwort');
$rueckseite = $findOne('rueckseite'); $rueckseite = $findOne('rueckseite');
$spieler = $findOne('spieler'); $spieler = $findOne('spieler');
$verfasser = $findOne('verfasser'); $verfasser = $findOne('verfasser');
$rueckblickFoto = $findOne('rueckblick');
$beitragFoto = $findOne('beitrag');
$galerieQuellen = $findAll('galerie');
// Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften // Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften
// Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch // Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch
// vorhandener anzeigen/-Unterordner wäre also wirkungslos — dann besser sagen, // vorhandener anzeigen/-Unterordner wäre also wirkungslos — dann besser sagen,
@@ -196,12 +202,34 @@ 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;
$beitrag = $oldIssue['beitrag'] ?? null;
if (is_array($beitrag) && $beitragFoto === null) {
$fail('Redaktioneller Beitrag vorhanden, aber beitrag.* fehlt im Ablieferordner.');
}
if (is_array($beitrag)) {
$beitragLink = trim((string) ($beitrag['source_url'] ?? ''));
if (!filter_var($beitragLink, FILTER_VALIDATE_URL) || parse_url($beitragLink, PHP_URL_SCHEME) !== 'https') {
$fail('Beitrag: source_url muss eine gültige HTTPS-Adresse sein.');
}
}
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt. // Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
$oldAufruf = $oldIssue['aufruf'] ?? null; $oldAufruf = $oldIssue['aufruf'] ?? null;
// Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm, // Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm,
// Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt. // Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt.
$oldVeranstaltung = $oldIssue['veranstaltung'] ?? null; $oldVeranstaltung = $oldIssue['veranstaltung'] ?? null;
$oldCover = $oldIssue['cover'] ?? null; $oldCover = $oldIssue['cover'] ?? null;
// Anzeigename des Gegners fürs Cover und die Liste ("Wirsberg/Kupferberg" statt
// der BFV-Zeile) und die Liste der Mannschaften, die zusätzlich eine
// Gegnerseite bekommen — beides Chat-Pflege, überlebt erneute Läufe wie vorwort.
$oldOpponentShort = $oldIssue['opponent_short'] ?? null;
// Galerie-Seite: Kopf (kicker/heading/text) und Alt-Texte sind Chat-Pflege,
// die Bilder schreibt dieser Lauf aus <ordner>/galerie/ (siehe unten).
$oldGalerie = is_array($oldIssue['galerie'] ?? null) ? $oldIssue['galerie'] : null;
$rueckblick = is_array($oldIssue['rueckblick'] ?? null) ? $oldIssue['rueckblick'] : null;
if ($rueckblick !== null && $rueckblickFoto === null) {
$fail('Spielrückblick vorhanden, aber rueckblick.* fehlt im Ablieferordner.');
}
$oldGegnerTeams = is_array($oldIssue['gegner_teams'] ?? null) ? array_values($oldIssue['gegner_teams']) : null;
$veranstaltung = null; $veranstaltung = null;
if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') { if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') {
$veranstaltung = veranstaltung_find($oldVeranstaltung); $veranstaltung = veranstaltung_find($oldVeranstaltung);
@@ -312,6 +340,61 @@ $bild = static function (string $src, string $name, string $alt) use ($destDir,
]; ];
}; };
// --- Galerie (optional): Fotos vom letzten Spiel als Bento-Seite. Wie bei den
// Veranstaltungsbildern ist der Ordner immer der KOMPLETTE Bestand (der
// Ausgaben-Ordner wurde oben geleert), Alt-Texte hängen an `source`, dem
// Originaldateinamen, und folgen ihrem Bild auch bei neuer Reihenfolge. Zwei
// Breiten reichen: der Aufmacher steht mit gut zwei Dritteln der Seitenbreite. ---
$galerieMax = 7;
$galerie = $oldGalerie;
if ($galerieQuellen !== []) {
$altBySource = [];
foreach ($oldGalerie['bilder'] ?? [] as $b) {
if (is_array($b) && !empty($b['source'])) {
$altBySource[(string) $b['source']] = (string) ($b['alt'] ?? '');
}
}
$platzhalter = trim((string) ($oldGalerie['heading'] ?? '')) ?: 'Fotos vom letzten Spiel';
if (count($galerieQuellen) > $galerieMax) {
$log(sprintf('galerie/ enthält %d Fotos, das Raster fasst %d — die übrigen bleiben weg.', count($galerieQuellen), $galerieMax), 'WARN');
}
$galerieBilder = [];
$ohneAlt = [];
foreach (array_slice($galerieQuellen, 0, $galerieMax) as $n => $src) {
$source = basename($src);
$name = sprintf('galerie-%02d', $n + 1);
try {
$variants = resize_image_variants($src, "{$destDir}/{$name}", [640, 1200], 80);
} catch (RuntimeException $e) {
$fail("Galerie-Foto {$source} fehlgeschlagen: " . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
$alt = trim($altBySource[$source] ?? '');
if ($alt === '' || $alt === $platzhalter) {
$ohneAlt[] = $source;
$alt = $platzhalter;
}
$galerieBilder[] = [
'source' => $source,
'base' => "img/stadionzeitung/{$slug}/{$name}",
'widths' => $generated,
'alt' => $alt,
];
}
$galerie = ($oldGalerie ?? []) + ['kicker' => 'Rückblick', 'heading' => $platzhalter];
$galerie['bilder'] = $galerieBilder;
$log(count($galerieBilder) . ' Galerie-Fotos übernommen.');
if ($ohneAlt !== []) {
$log('Galerie: noch ohne eigenen Alt-Text (im Chat ergänzen): ' . implode(', ', $ohneAlt), 'WARN');
}
} elseif (!empty($oldGalerie['bilder'])) {
// Die Dateien sind mit dem Ausgaben-Ordner weg; ohne galerie/ im Ordner
// lieferte die Seite sonst kaputte Bilder.
$log('Die Ausgabe hatte eine Galerie, der Ordner liefert aber kein galerie/ mehr — die Galerie-Seite entfällt.', 'WARN');
$galerie['bilder'] = [];
}
/* /*
* Inhalt als Liste von BLÖCKEN, Anzeigen kommen anschließend in die Lücken * Inhalt als Liste von BLÖCKEN, Anzeigen kommen anschließend in die Lücken
* dazwischen. Ein Block ist eine Folge von Seiten, die zusammenbleiben MUSS; * dazwischen. Ein Block ist eine Folge von Seiten, die zusammenbleiben MUSS;
@@ -345,6 +428,10 @@ try {
if ($oldInterview !== null) { if ($oldInterview !== null) {
$aufschlag[] = $live('interview'); $aufschlag[] = $live('interview');
} }
if (is_array($beitrag)) {
$beitrag['image'] = $bild($beitragFoto, 'beitrag', (string) ($beitrag['image']['alt'] ?? $beitrag['heading'] ?? 'Vereinsbeitrag'));
$aufschlag[] = $live('beitrag');
}
// Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien // Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien
// Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller // Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller
// Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld). // Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld).
@@ -370,19 +457,41 @@ try {
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true]; $bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
// Redaktioneller Spielrückblick: Text und Bildnachweis sind Chat-Pflege,
// das Foto kommt aus dem Ablieferordner und bleibt in dieser Ausgabe.
if ($rueckblick !== null) {
$rueckblick['image'] = $bild($rueckblickFoto, 'rueckblick', (string) ($rueckblick['image']['alt'] ?? $rueckblick['heading'] ?? 'Spielrückblick'));
$bloecke[] = ['name' => 'rueckblick', 'seiten' => [$live('rueckblick')], 'anzeigen_danach' => true];
}
// Galerie direkt hinter dem Ergebnis-Rückblick: erst das Ergebnis, dann die
// Bilder dazu. Eigener Block, eine Anzeige dazwischen ist unbedenklich.
if (!empty($galerie['bilder'])) {
$bloecke[] = ['name' => 'galerie', 'seiten' => [$live('galerie')], 'anzeigen_danach' => true];
}
// Gegnerseite(n): normal nur die tragende Mannschaft; in der Sportfest-Ausgabe // Gegnerseite(n): normal nur die tragende Mannschaft; in der Sportfest-Ausgabe
// je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als // je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als
// EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() — // EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() —
// dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut, // dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut,
// damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen. // damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen.
$gegnerSeiten = []; $gegnerSeiten = [];
foreach (stadionzeitung_gegner_fixtures([ $gegnerFixtures = stadionzeitung_gegner_fixtures([
'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent, 'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent,
'veranstaltung' => $oldVeranstaltung, 'veranstaltung' => $oldVeranstaltung, 'gegner_teams' => $oldGegnerTeams ?? [],
]) as $gegnerTeam => $fixture) { ]);
foreach ($gegnerFixtures as $gegnerTeam => $fixture) {
$gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]); $gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]);
$log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']})."); $log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']}).");
} }
// Eine benannte Mannschaft ohne Spiel im Fenster bekommt keine Seite. Das ist
// richtig so, darf aber nicht stillschweigend passieren: ein Tippfehler im
// Team-Key sähe genauso aus wie ein spielfreies Wochenende.
foreach ($oldGegnerTeams ?? [] as $gewuenscht) {
if (!isset($gegnerFixtures[(string) $gewuenscht])) {
$log("gegner_teams: '{$gewuenscht}' hat kein Spiel im Fenster um {$date} (oder der Team-Key ist falsch) — keine Gegnerseite.", 'WARN');
}
}
if ($gegnerSeiten === []) { if ($gegnerSeiten === []) {
// Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft — // Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft —
// sie meldet dann selbst, dass keine Vergleichsdaten vorliegen. // sie meldet dann selbst, dass keine Vergleichsdaten vorliegen.
@@ -425,7 +534,7 @@ try {
if ($rueckseite !== null) { if ($rueckseite !== null) {
$bloecke[] = [ $bloecke[] = [
'name' => 'rueckseite', 'name' => 'rueckseite',
'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen {$opponent}")], 'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen " . stadionzeitung_opponent_display(['opponent' => $opponent, 'opponent_short' => $oldOpponentShort]))],
'anzeigen_danach' => false, 'anzeigen_danach' => false,
]; ];
} }
@@ -667,6 +776,12 @@ foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'his
// (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url() // (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. // wie überall, damit der gedruckte Code auf die echte Domain zeigt.
$aufrufLink = trim((string) ($oldAufruf['link'] ?? '')); $aufrufLink = trim((string) ($oldAufruf['link'] ?? ''));
$beitragLink = trim((string) ($beitrag['source_url'] ?? ''));
if ($beitragLink !== '') {
if (!generate_qr_png($beitragLink, "{$destDir}/qr-beitrag.png")) {
$fail('QR-Code für den redaktionellen Beitrag konnte nicht erzeugt werden.');
}
}
if ($aufrufLink !== '') { if ($aufrufLink !== '') {
if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) { if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) {
$log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt."); $log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt.");
@@ -734,6 +849,8 @@ if ($oldIssue !== null && ($oldIssue['status'] ?? '') === 'veroeffentlicht') {
$issues[] = [ $issues[] = [
'slug' => $slug, 'slug' => $slug,
'opponent' => $opponent, 'opponent' => $opponent,
'opponent_short' => $oldOpponentShort,
'gegner_teams' => $oldGegnerTeams,
'date' => $date, 'date' => $date,
'team_key' => $teamKey, 'team_key' => $teamKey,
'status' => 'entwurf', 'status' => 'entwurf',
@@ -742,9 +859,12 @@ $issues[] = [
'qr_image' => $qrImage, 'qr_image' => $qrImage,
'vorwort' => $oldVorwort, 'vorwort' => $oldVorwort,
'interview' => $oldInterview, 'interview' => $oldInterview,
'beitrag' => $beitrag,
'aufruf' => $oldAufruf, 'aufruf' => $oldAufruf,
'veranstaltung' => $oldVeranstaltung, 'veranstaltung' => $oldVeranstaltung,
'cover' => $oldCover, 'cover' => $oldCover,
'galerie' => $galerie,
'rueckblick' => $rueckblick,
'verfasser_photo' => $verfasserPhoto, 'verfasser_photo' => $verfasserPhoto,
'termine_snapshot' => $termineSnapshot, 'termine_snapshot' => $termineSnapshot,
'images' => $images, 'images' => $images,

View File

@@ -1,7 +1,106 @@
{ {
"fetched_at": 1789636733, "fetched_at": 1792834512,
"username": "tsv08kulmbach", "username": "tsv08kulmbach",
"posts": [ "posts": [
{
"shortcode": "De0vwrajLXG",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1792731262,
"is_video": false,
"image": "img/instagram/De0vwrajLXG.jpg",
"url": "https://www.instagram.com/p/De0vwrajLXG/"
},
{
"shortcode": "Derh4uhDLDh",
"caption": "🔴⚫️ Ergebnisse \nKann sich sehen lassen 🙌",
"alt": "🔴⚫️ Ergebnisse",
"taken_at": 1792421998,
"is_video": false,
"image": "img/instagram/Derh4uhDLDh.jpg",
"url": "https://www.instagram.com/p/Derh4uhDLDh/"
},
{
"shortcode": "DehQgDrDB0Z",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1792077339,
"is_video": false,
"image": "img/instagram/DehQgDrDB0Z.jpg",
"url": "https://www.instagram.com/p/DehQgDrDB0Z/"
},
{
"shortcode": "DeeldfjMZEm",
"caption": "Neue Trikots für unsere E-Jugend! ⚽️😍\n„Euer Ort, euer Verein, euer Trikot“ – unter diesem Motto sorgt die bundesweite TUI-Aktion auch beim TSV 08 Kulmbach für strahlende Gesichter! 👕😊\nDas ReiseCenter Schaffranek sicherte sich unter mehr als 500 Bewerbern einen von 200 Trikotsätzen. Wer sich darüber freuen durfte, entschieden anschließend die Verantwortlichen im ReiseCenter per Los: Sie befüllten die Lostrommel mit den Namen aller Vereine im Kulmbacher Stadtgebiet, die Jugendarbeit bis zur E-Jugend anbieten, und führten die Ziehung durch. Das gezogene Los: TSV 08 Kulmbach! 🍀🙌\nMelanie Schaffranek überreichte die neuen Trikots bei unserem E-Jugendfestival und konnte unsere Nachwuchskicker direkt darin in Aktion erleben. ⚽\nEin herzliches Dankeschön an TUI sowie an Melanie Schaffranek und das gesamte Team des ReiseCenter Schaffranek für diese tolle Unterstützung unserer Jugendarbeit! ❤️🖤\nUnsere E-Jugend trägt das TUI-Lächeln ab sofort auf dem Trikot – und die Freude darüber im Gesicht! 😃\n\n#tsv08kulmbach #jugendfussball #reisetui \nDanke @reisebuero_schaffranek",
"alt": "Neue Trikots für unsere E-Jugend! ⚽️😍",
"taken_at": 1791987664,
"is_video": false,
"image": "img/instagram/DeeldfjMZEm.jpg",
"url": "https://www.instagram.com/p/DeeldfjMZEm/"
},
{
"shortcode": "DePJUbkjKPc",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1791469594,
"is_video": false,
"image": "img/instagram/DePJUbkjKPc.jpg",
"url": "https://www.instagram.com/p/DePJUbkjKPc/"
},
{
"shortcode": "DeG8wTzjAVR",
"caption": "Ergebnisse vom Wochenende 🔴⚫️\n\nDerbysieg für unsere Erste 🤹🏻‍♂️ und unsere Frauen bleiben weiterhin ungeschlagen! 💪🏻",
"alt": "Ergebnisse vom Wochenende 🔴⚫️",
"taken_at": 1791194571,
"is_video": false,
"image": "img/instagram/DeG8wTzjAVR.jpg",
"url": "https://www.instagram.com/p/DeG8wTzjAVR/"
},
{
"shortcode": "Dd9tmXplrZx",
"caption": "🔴⚫️ Vorschau inkl. Stadtderby 🔥",
"alt": "🔴⚫️ Vorschau inkl. Stadtderby 🔥",
"taken_at": 1790884635,
"is_video": false,
"image": "img/instagram/Dd9tmXplrZx.jpg",
"url": "https://www.instagram.com/p/Dd9tmXplrZx/"
},
{
"shortcode": "Dd1S1UkDOfO",
"caption": "🔴⚫️ Ergebnisse",
"alt": "🔴⚫️ Ergebnisse",
"taken_at": 1790602166,
"is_video": false,
"image": "img/instagram/Dd1S1UkDOfO.jpg",
"url": "https://www.instagram.com/p/Dd1S1UkDOfO/"
},
{
"shortcode": "DdtJFZajIds",
"caption": "🔴⚫️-Vorschau",
"alt": "🔴⚫️-Vorschau",
"taken_at": 1790328620,
"is_video": false,
"image": "img/instagram/DdtJFZajIds.jpg",
"url": "https://www.instagram.com/p/DdtJFZajIds/"
},
{
"shortcode": "Ddjq90MjLvH",
"caption": "Maximale Punkteausbeute für uns am Wochenende!\n❤️🖤",
"alt": "Maximale Punkteausbeute für uns am Wochenende!",
"taken_at": 1790010839,
"is_video": false,
"image": "img/instagram/Ddjq90MjLvH.jpg",
"url": "https://www.instagram.com/p/Ddjq90MjLvH/"
},
{
"shortcode": "DdZ3vzhDAZS",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1789681996,
"is_video": false,
"image": "img/instagram/DdZ3vzhDAZS.jpg",
"url": "https://www.instagram.com/p/DdZ3vzhDAZS/"
},
{ {
"shortcode": "DdROjQqjI1_", "shortcode": "DdROjQqjI1_",
"caption": "Ergebnisse 🔴⚫️", "caption": "Ergebnisse 🔴⚫️",

File diff suppressed because it is too large Load Diff

View File

@@ -132,8 +132,9 @@
], ],
"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": "Mäuse I", "day": "Dienstag", "time": "17:30–18:30 Uhr", "trainers": ["Maxi Roßberg*", "Katrin und Marcus Braese"] }, { "label": "Mäuse I", "day": "Dienstag", "time": "17:30–18:30 Uhr", "trainers": ["Maxi Roßberg*", "Katrin und Markus Braese", "Kristina Dorsch", "Katja Knappich"] },
{ "label": "Mäuse II", "day": "Donnerstag", "time": "16:30–17:30 Uhr", "trainers": ["Franziska Trotzke"] } { "label": "Mäuse II", "day": "Donnerstag", "time": "16:30–17:30 Uhr", "trainers": ["Franziska Trotzke", "Lisa Benker"] },
{ "label": "Mäuse III", "day": "Freitag", "time": "17:00–18:00 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch*"] }
] ]
}, },
{ {
@@ -211,9 +212,9 @@
"contact_email": "tina.kornatz@gmx.de", "contact_email": "tina.kornatz@gmx.de",
"contact_phone": "0163 6032410", "contact_phone": "0163 6032410",
"footnote": "In den Schulferien findet kein Training statt.", "footnote": "In den Schulferien findet kein Training statt.",
"notice_icon": "calendar-event", "notice_tone": "stop",
"notice_title": "Schnuppern nach Absprache", "notice_title": "Hinweis zur Warteliste",
"notice": "Schnuppertraining ist bei uns jederzeit nach vorheriger Absprache möglich. Melde dich davor einfach kurz bei Tina, dann finden wir einen passenden Termin.", "notice": "Aktuell sind alle Plätze beim Trampolinturnen vergeben. Wir nehmen dich oder dein Kind aber gerne auf die Warteliste. Melde dich dafür einfach kurz bei Tina.",
"groups": [ "groups": [
{ {
"name": "Kinder und Erwachsene", "name": "Kinder und Erwachsene",

View File

@@ -7261,74 +7261,136 @@ a.news-feature__link:hover .news-feature__more {
} }
/* === stadionzeitung-ergebnisse (Live-Seite) === */ /* === stadionzeitung-ergebnisse (Live-Seite) === */
/* Ergebnis-Rückblick: zwei Zeilen pro Spiel (Heim über Gast, wie das /* Ergebnis-Rückblick als Anzeigetafel: Wappen und Vereinsname je Seite, das
„Nächste Spiele"-Band) — lange Vereinsnamen passen auf der schmalen Ergebnis groß in der Mitte (Vorbild kicker/Sofascore). Vierter Entwurf,
A5-Seite nie in eine Zeile. Eigene Mannschaft über Gewicht der Body-Schrift 19.09.2026 — die drei Vorgänger scheiterten alle an derselben Ursache:
markiert (Abel hat echte Schnitte — anders als Coolvetica erlaubt, siehe sie waren Listenzeilen, und eine Zeile stellt Heim und Gast untereinander
CLAUDE.md „nie über font-weight" bei Coolvetica). */ statt gegenüber, obwohl eine Begegnung etwas Symmetrisches ist. Mit den
Toren rechts am Blattrand lag halbe Seitenbreite Leere zwischen Name und
Ziffer, eine getönte Karte dagegen sah „altbacken" aus, und mit den Toren
links fehlte der Seite die Hierarchie.
Die Mittelachse löst zusätzlich das Namensproblem: lange Namen wie
„(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II" umbrechen in ihrer Spalte
über mehrere Zeilen, ohne dass irgendetwas verrutscht — deshalb steht der
Name UNTER dem Wappen und mittig, nicht daneben.
Flächen gibt es keine, nur Linien: Haarlinie zwischen den Begegnungen,
roter Strich unter der Mannschaftszeile, sonst weißes Blatt. */
/* Trennung der Mannschaften: Haarlinie plus reichlich Abstand auf beiden
Seiten. Die Linie liegt am Abschnitt, nicht an der letzten Begegnung darin —
sonst wanderte sie bei mehreren Spielen pro Mannschaft mitten in den Block. */
.stadionzeitung-ergebnisse__team { .stadionzeitung-ergebnisse__team {
margin-bottom: 4.2cqi; padding-bottom: 4.6cqi;
} margin-bottom: 4.6cqi;
.stadionzeitung-ergebnisse__team-name {
margin-bottom: 1.7cqi;
font-family: var(--ff-body);
font-weight: var(--fw-bold);
font-size: 2.7cqi;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-muted);
}
.stadionzeitung-ergebnisse__match {
padding-bottom: 1.7cqi;
margin-bottom: 1.7cqi;
border-bottom: 1px solid var(--clr-glass-border); border-bottom: 1px solid var(--clr-glass-border);
} }
.stadionzeitung-ergebnisse__match:last-child { .stadionzeitung-ergebnisse__team:last-of-type {
padding-bottom: 0;
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0; border-bottom: 0;
} }
.stadionzeitung-ergebnisse__meta { /* Mannschaftszeile bewusst leise: die Gliederung macht der Abstand zwischen
font-size: 2.5cqi; den Mannschaften, keine Linie (ein roter Strich darunter war gebaut und am
19.09.2026 wieder entfernt — er zog mehr Aufmerksamkeit als die Ergebnisse
darunter). */
.stadionzeitung-ergebnisse__team-name {
margin-bottom: 1.6cqi;
font-family: var(--ff-body);
font-size: 2.4cqi;
text-align: center;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint); color: var(--clr-text-faint);
} }
/* Das Zeilen-Layout gehört auf die ZEILE, nicht auf das Wappen darin (stand .stadionzeitung-ergebnisse__match {
hier mal als `.stadionzeitung-ergebnisse__row .crest` — dann wurde das Wappen
selbst zum Grid, lag als Block-Element auf eigener Zeile, und Vereinsname und
Tore rutschten als Inline-Spans darunter zusammen). `--crest-size` bleibt
trotzdem hier: Custom Properties erben, das Wappen bleibt eine dumme
Komponente und der Kontext gibt ihm seine Größe — so machen es alle anderen
Wappen-Kontexte im Projekt auch. */
.stadionzeitung-ergebnisse__row {
--crest-size: 5.8cqi;
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center; align-items: center;
gap: 1.7cqi; gap: 2.1cqi;
padding-block: 0.6cqi; padding-block: 1.4cqi;
border-bottom: 1px solid var(--clr-glass-border);
} }
.stadionzeitung-ergebnisse__match:last-child {
padding-bottom: 0;
border-bottom: 0;
}
/* Beide Seiten sind gleich gebaut und gleich breit (1fr), damit die Mittelachse
wirklich in der Mitte liegt — auch wenn eine Spielgemeinschaft drei Zeilen
Namen mitbringt und der Gegner eine. `--crest-size` setzt der Kontext, die
crest-Komponente bleibt dumm (gleiches Muster wie in allen anderen
Wappen-Kontexten). */
.stadionzeitung-ergebnisse__side {
--crest-size: 8.5cqi;
display: grid;
justify-items: center;
gap: 1.2cqi;
text-align: center;
}
/* Einzeilig, notfalls ausgepunktet: ein umbrechender Name schiebt die
Anzeigetafel auseinander und die Zeilenpaare stehen dann ungleich hoch
nebeneinander. Die volle Schreibweise steht ohnehin im Matchcenter. */
.stadionzeitung-ergebnisse__club { .stadionzeitung-ergebnisse__club {
min-width: 0; max-width: 100%;
font-size: 3.75cqi; overflow: hidden;
line-height: 1.2; font-size: 3.2cqi;
line-height: 1.15;
white-space: nowrap;
text-overflow: ellipsis;
} }
.stadionzeitung-ergebnisse__row--own .stadionzeitung-ergebnisse__club { .stadionzeitung-ergebnisse__side--own .stadionzeitung-ergebnisse__club {
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
} }
.stadionzeitung-ergebnisse__goals { .stadionzeitung-ergebnisse__score {
display: grid;
justify-items: center;
gap: 0.8cqi;
}
.stadionzeitung-ergebnisse__result {
display: flex;
align-items: baseline;
gap: 0.8cqi;
font-family: var(--ff-heading); font-family: var(--ff-heading);
font-size: 5.8cqi; font-size: 7.4cqi;
line-height: 1; line-height: 1;
} }
/* Das Ergebnis läuft durchgehend in voller Textfarbe: eine zurückgenommene
Verlierer-Ziffer war gebaut und am 19.09.2026 verworfen (Felix) — auf dem
gedruckten Blatt ist eine graue Ziffer schlechter lesbar, und wer gewonnen
hat, sagt die Zahl selbst. */
.stadionzeitung-ergebnisse__meta {
font-size: 2.5cqi;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint);
white-space: nowrap;
}
/* === stadionzeitung-gegner (Live-Seite) === */ /* === stadionzeitung-gegner (Live-Seite) === */
/* Gegnerseite als Duell-Aufmacher plus Balkenvergleich (19.09.2026). Vorher
war das eine rosa getönte Karte mit der Begegnung und darunter eine
Zahlenkolonne aus sieben Zeilen: inhaltlich richtig, gestalterisch ohne
Blickfang, und die untere Blatthälfte blieb leer.
Drei Entscheidungen tragen den Aufbau:
- Das GEGNERWAPPEN ist der Bildanker (deutlich größer als unseres). Die
Seite handelt von ihm, nicht von der Begegnung.
- Der Tabellenplatz steht oben bei den Wappen, NICHT als Balken: dort ist
klein besser, ein Balken läge genau falsch herum.
- Der Vergleich läuft als Balkenpaar aus der Mitte (Vorbild Sofascore).
Die Länge kommt aus einer KLASSE in 5-Prozent-Stufen, nie aus einem
style-Attribut: die CSP verwirft inline styles stillschweigend, der
Balken wäre in Produktion unsichtbar (siehe CLAUDE.md). */
.stadionzeitung-gegner { .stadionzeitung-gegner {
min-height: 100%; min-height: 100%;
} }
@@ -7336,90 +7398,204 @@ a.news-feature__link:hover .news-feature__more {
.stadionzeitung-gegner__duell { .stadionzeitung-gegner__duell {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center; align-items: start;
gap: 2.5cqi; gap: 2.5cqi;
margin-bottom: 5cqi; margin-top: 2.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);
} }
/* Beide Seiten sind gleich gebaut und gleich groß. Der Gegner war hier kurz
mit doppelt so großem Wappen und seinem Namen in Coolvetica gesetzt — damit
stand er zweimal laut auf derselben Seite (Überschrift und Duell) und das
Blatt wirkte unruhig (Felix, 19.09.2026). Die Überschrift nennt ihn, das
Duell zeigt nur noch, wer gegen wen und auf welchem Platz. */
.stadionzeitung-gegner__club { .stadionzeitung-gegner__club {
--crest-size: 12cqi; --crest-size: 11cqi;
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto minmax(7.4cqi, auto);
align-items: center; justify-items: center;
gap: 1.7cqi; gap: 1.2cqi;
min-width: 0; min-width: 0;
text-align: center; text-align: center;
} }
.stadionzeitung-gegner__club strong { .stadionzeitung-gegner__club-name {
font-size: 3.2cqi; font-size: 3.2cqi;
line-height: 1.15; line-height: 1.15;
} }
.stadionzeitung-gegner__club--own strong { /* Mittelspalte: „gegen" und der Anstoß stehen zusammen, damit im Duell nur
color: var(--clr-accent); noch drei Dinge auf einer Höhe liegen (wir, wann, sie). Zwei lose
} Platz-Ziffern und eine freistehende Datumszeile standen hier kurz und
machten den Kopf unruhig (Felix, 19.09.2026) — der Tabellenplatz steht
.stadionzeitung-gegner__match { jetzt im Ausschnitt unter den Balken. */
display: flex; .stadionzeitung-gegner__vs {
flex-direction: column; display: grid;
align-items: center; justify-items: center;
gap: 0.5cqi; gap: 1.2cqi;
font-size: 2.5cqi; padding-top: 3.3cqi;
color: var(--clr-text-muted); text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.stadionzeitung-gegner__match strong { .stadionzeitung-gegner__vs-wort {
font-size: 2.9cqi; font-size: 2.7cqi;
color: var(--clr-text); text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint);
}
.stadionzeitung-gegner__anstoss {
font-size: 2.7cqi;
line-height: 1.4;
color: var(--clr-text-muted);
}
/* Im Ausschnitt entfällt die Zonen-Legende: sie erklärt Auf- und Abstieg,
und das ist die Aufgabe der vollständigen Tabellenseite weiter hinten im
Heft. Hier geht es nur um die Nachbarschaft der beiden Mannschaften. */
.stadionzeitung-gegner__ausschnitt {
margin-top: 3.3cqi;
}
.stadionzeitung-gegner__ausschnitt .league-table__legend {
display: none;
}
/* Der Ausschnitt läuft eine Spur enger als die vollständige Tabellenseite:
er teilt sich das Blatt mit Duell, Aufmacher und Balken. Gemessen: mit den
Maßen der Tabellenseite stand die Seite 16px über der A5-Kante. */
.stadionzeitung-gegner__ausschnitt .league-table th,
.stadionzeitung-gegner__ausschnitt .league-table td {
padding: 0.7cqi 0.9cqi;
}
.stadionzeitung-gegner__ausschnitt .league-table tbody .crest {
--crest-size: 4cqi;
}
.stadionzeitung-gegner__ausschnitt-titel {
margin-bottom: 1.4cqi;
font-size: 2.4cqi;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint);
}
/* Zwei Blöcke (wenn die Mannschaften weit auseinanderliegen) bekommen Luft
dazwischen, sonst sähe es aus wie eine durchgehende Tabelle mit Sprung. */
.stadionzeitung-gegner__ausschnitt .table-scroll-frame + .table-scroll-frame {
margin-top: 2.1cqi;
}
/* Die Tabelle in einem Satz: derselbe Bestand, nur in Sprache. Coolvetica
trägt hier, weil die Zeile kurz ist und nie umbricht. */
.stadionzeitung-gegner__aufmacher {
margin-top: 3.3cqi;
padding-top: 2.5cqi;
border-top: 0.5cqi solid var(--clr-accent);
font-family: var(--ff-heading);
font-size: 5.8cqi;
line-height: 1;
letter-spacing: var(--ls-heading);
text-transform: uppercase;
} }
.stadionzeitung-gegner__vergleich { .stadionzeitung-gegner__vergleich {
border-block: 1px solid var(--clr-glass-border); margin-top: 2.5cqi;
} }
.stadionzeitung-gegner__vergleich-head, .stadionzeitung-gegner__vergleich-head,
.stadionzeitung-gegner__vergleich-row { .stadionzeitung-gegner__vergleich-row {
display: grid; display: grid;
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: 6cqi minmax(0, 1fr) 24cqi minmax(0, 1fr) 6cqi;
align-items: center; align-items: center;
gap: 1.7cqi;
text-align: center; text-align: center;
} }
.stadionzeitung-gegner__vergleich-head { .stadionzeitung-gegner__vergleich-head {
padding-block: 1.7cqi; padding-bottom: 1.4cqi;
font-size: 2.5cqi; margin-bottom: 1.4cqi;
font-weight: var(--fw-bold); border-bottom: 1px solid var(--clr-glass-border);
font-size: 2.4cqi;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: var(--ls-caps); letter-spacing: var(--ls-caps);
color: var(--clr-text-muted); color: var(--clr-text-faint);
}
/* Die beiden äußeren Spaltenköpfe sitzen über den Zahlen, der mittlere über
dem Label — deshalb spannt er über die drei inneren Spalten. */
.stadionzeitung-gegner__vergleich-head span:nth-child(2) {
grid-column: 2 / 5;
} }
.stadionzeitung-gegner__vergleich-row { .stadionzeitung-gegner__vergleich-row {
min-height: 6.5cqi; min-height: 5.8cqi;
border-top: 1px solid var(--clr-glass-border);
} }
.stadionzeitung-gegner__vergleich-row span { .stadionzeitung-gegner__wert {
font-family: var(--ff-heading);
font-size: 4.4cqi;
line-height: 1;
}
.stadionzeitung-gegner__label {
font-size: 2.8cqi; font-size: 2.8cqi;
color: var(--clr-text-muted); color: var(--clr-text-muted);
} }
.stadionzeitung-gegner__vergleich-row strong { /* Balkenpaar: der linke wächst aus der Mitte nach links, der rechte nach
font-family: var(--ff-heading); rechts. Die Spur bleibt sichtbar (helle Fläche), damit auch ein Wert von
font-size: 4.8cqi; null als Zeile lesbar ist. */
line-height: 1; .stadionzeitung-gegner__balken {
display: flex;
justify-content: flex-start;
block-size: 2.2cqi;
border-radius: 1.1cqi;
background: var(--clr-glass-border);
overflow: hidden;
} }
.stadionzeitung-gegner__vergleich-row strong:first-child { .stadionzeitung-gegner__balken--own {
color: var(--clr-accent); justify-content: flex-end;
} }
.stadionzeitung-gegner__balken-fuellung {
display: block;
block-size: 100%;
border-radius: 1.1cqi;
background: var(--clr-text);
}
/* Unsere Seite rot, die des Gegners schwarz: die einzige Farbe der Seite
steht damit bei uns, ohne dass Rot Fließtext tragen müsste. */
.stadionzeitung-gegner__balken--own .stadionzeitung-gegner__balken-fuellung {
background: var(--clr-accent);
}
/* 5-Prozent-Stufen, siehe Kommentar oben (kein style-Attribut). */
.stadionzeitung-gegner__balken-fuellung--s0 { inline-size: 0%; }
.stadionzeitung-gegner__balken-fuellung--s1 { inline-size: 5%; }
.stadionzeitung-gegner__balken-fuellung--s2 { inline-size: 10%; }
.stadionzeitung-gegner__balken-fuellung--s3 { inline-size: 15%; }
.stadionzeitung-gegner__balken-fuellung--s4 { inline-size: 20%; }
.stadionzeitung-gegner__balken-fuellung--s5 { inline-size: 25%; }
.stadionzeitung-gegner__balken-fuellung--s6 { inline-size: 30%; }
.stadionzeitung-gegner__balken-fuellung--s7 { inline-size: 35%; }
.stadionzeitung-gegner__balken-fuellung--s8 { inline-size: 40%; }
.stadionzeitung-gegner__balken-fuellung--s9 { inline-size: 45%; }
.stadionzeitung-gegner__balken-fuellung--s10 { inline-size: 50%; }
.stadionzeitung-gegner__balken-fuellung--s11 { inline-size: 55%; }
.stadionzeitung-gegner__balken-fuellung--s12 { inline-size: 60%; }
.stadionzeitung-gegner__balken-fuellung--s13 { inline-size: 65%; }
.stadionzeitung-gegner__balken-fuellung--s14 { inline-size: 70%; }
.stadionzeitung-gegner__balken-fuellung--s15 { inline-size: 75%; }
.stadionzeitung-gegner__balken-fuellung--s16 { inline-size: 80%; }
.stadionzeitung-gegner__balken-fuellung--s17 { inline-size: 85%; }
.stadionzeitung-gegner__balken-fuellung--s18 { inline-size: 90%; }
.stadionzeitung-gegner__balken-fuellung--s19 { inline-size: 95%; }
.stadionzeitung-gegner__balken-fuellung--s20 { inline-size: 100%; }
.stadionzeitung-gegner__empty { .stadionzeitung-gegner__empty {
padding: 4.2cqi; padding: 4.2cqi;
border: 1px solid var(--clr-glass-border); border: 1px solid var(--clr-glass-border);
@@ -8452,6 +8628,162 @@ p.stadionzeitung-turnier__label:first-of-type {
object-fit: cover; object-fit: cover;
} }
/* === stadionzeitung-beitrag (redaktioneller Lesetipp) === */
.stadionzeitung-beitrag {
display: flex;
flex-direction: column;
min-height: 100%;
}
.stadionzeitung-beitrag__figure {
margin: 0 0 3cqi;
}
.stadionzeitung-beitrag .stadionzeitung-page__heading {
font-size: 7cqi;
}
.stadionzeitung-beitrag .stadionzeitung-vorwort__text {
font-size: 3.2cqi;
line-height: 1.4;
margin-bottom: 2.4cqi;
}
.stadionzeitung-beitrag__foto {
display: block;
width: 100%;
height: 48cqi;
object-fit: contain;
}
.stadionzeitung-beitrag__source {
font-size: 2.5cqi;
line-height: 1.4;
color: var(--clr-text-muted);
margin: 0 0 4cqi;
}
.stadionzeitung-beitrag .stadionzeitung-page__cta {
margin-top: auto;
color: inherit;
text-decoration: none;
}
.stadionzeitung-beitrag .stadionzeitung-page__cta-qr {
width: 20cqi;
height: 20cqi;
}
/* === stadionzeitung-rueckblick (redaktionelle Seite mit Bildnachweis) === */
.stadionzeitung-rueckblick {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* Kopf/Trennlinie, Ergebnis und Fließtext verwenden die vorhandenen
Heft-Klassen. Hier nur die Anordnung von Bild und Ergebnis definieren. */
.stadionzeitung-rueckblick__scoreline {
display: flex;
align-items: center;
gap: 4cqi;
margin-bottom: 4cqi;
}
.stadionzeitung-rueckblick__scoreline .stadionzeitung-ergebnisse__result {
flex: 0 0 auto;
margin: 0;
}
.stadionzeitung-rueckblick__figure {
margin: 0 0 3cqi;
}
.stadionzeitung-rueckblick__foto {
display: block;
width: 100%;
height: auto;
max-height: 65cqi;
object-fit: contain;
}
.stadionzeitung-rueckblick__credit {
font-size: 2.5cqi;
line-height: 1.4;
color: var(--clr-text-muted);
margin-top: 1cqi;
}
/* === stadionzeitung-galerie (Live-Seite, optional: Fotos vom letzten Spiel) === */
/* Bento-Raster auf einem 6×4-Gitter: das erste Foto ist der Aufmacher (4×2),
die übrigen legen sich je nach Anzahl darum. Die Fläche unter dem Kopf gehört
komplett den Fotos, beschnitten wird per object-fit — wie bei den Momenten
ohne Bildunterschriften, der Alt-Text trägt die Beschreibung.
Das Raster wächst per flex in die feste A5-Höhe; min-height ist der Halt für
den gestapelten Viewer ohne JS, wo die Seite keine feste Höhe hat (dann
kollabierten die 1fr-Zeilen auf null). 95cqi passt unter jeden Kopf mit
Textzeile (gemessen gegen 141,9cqi Blatthöhe). */
.stadionzeitung-galerie {
display: flex;
flex-direction: column;
height: 100%;
}
.stadionzeitung-galerie .stadionzeitung-page__head {
margin-bottom: 2.5cqi;
}
.stadionzeitung-galerie__text {
font-size: 3.4cqi;
color: var(--clr-text-muted);
}
.stadionzeitung-galerie__bento {
flex: 1 1 auto;
min-height: 95cqi;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 1.5cqi;
}
.stadionzeitung-galerie__foto {
display: block;
width: 100%;
height: 100%;
min-height: 0;
object-fit: cover;
}
.stadionzeitung-galerie__foto:nth-child(1) { grid-area: a; }
.stadionzeitung-galerie__foto:nth-child(2) { grid-area: b; }
.stadionzeitung-galerie__foto:nth-child(3) { grid-area: c; }
.stadionzeitung-galerie__foto:nth-child(4) { grid-area: d; }
.stadionzeitung-galerie__foto:nth-child(5) { grid-area: e; }
.stadionzeitung-galerie__foto:nth-child(6) { grid-area: f; }
.stadionzeitung-galerie__foto:nth-child(7) { grid-area: g; }
.stadionzeitung-galerie__bento--n1 {
grid-template-areas: "a a a a a a" "a a a a a a" "a a a a a a" "a a a a a a";
}
.stadionzeitung-galerie__bento--n2 {
grid-template-areas: "a a a a a a" "a a a a a a" "b b b b b b" "b b b b b b";
}
.stadionzeitung-galerie__bento--n3 {
grid-template-areas: "a a a a a a" "a a a a a a" "b b b c c c" "b b b c c c";
}
.stadionzeitung-galerie__bento--n4 {
grid-template-areas: "a a a a b b" "a a a a b b" "c c c d d d" "c c c d d d";
}
.stadionzeitung-galerie__bento--n5 {
grid-template-areas: "a a a a b b" "a a a a c c" "d d d e e e" "d d d e e e";
}
.stadionzeitung-galerie__bento--n6 {
grid-template-areas: "a a a a b b" "a a a a c c" "d d e e f f" "d d e e f f";
}
.stadionzeitung-galerie__bento--n7 {
grid-template-areas: "a a a a b b" "a a a a c c" "d d e e f f" "d d e e g g";
}
/* === stadionzeitung-druck (Druckfassung, Broschüren-Montage) === */ /* === stadionzeitung-druck (Druckfassung, Broschüren-Montage) === */
/* Bildschirm-Ansicht der Route ?druck=1: jedes Blatt als A4-quer-Vorschau /* Bildschirm-Ansicht der Route ?druck=1: jedes Blatt als A4-quer-Vorschau
(297:210) mit zwei A5-Seiten, in Broschüren-Reihenfolge (die Montage (297:210) mit zwei A5-Seiten, in Broschüren-Reihenfolge (die Montage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 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: 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: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 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: 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: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 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

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