diff --git a/CLAUDE.md b/CLAUDE.md index b866950..eb9e6eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,10 +54,10 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern. | Partner/Sponsoren | `data/partners.json` | — | | Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** | | Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) | -| Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort` und `interview`.** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt | -| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv` und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen | +| Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`) und `cover` (`teaser` einer Sonderausgabe).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt | +| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen | | Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) | -| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) | +| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) | | News (`/news` + optionale Detailseiten) | `data/news.json` (redaktionelle Meldungen; `artikel[]` = Fließtext-Blöcke, `status` = Entwurf/veröffentlicht) — Instagram-Posts und Ergebnisse kommen weiterhin nur aus `data/instagram.json`/`data/matchcenter.json`, werden auf der News-Seite nur gemergt | Von Claude im Chat gepflegt wie `events.json`/`teams.json` — **keine** Automatisierung, kein Bin-Skript. Instagram/Ergebnisse nie hier duplizieren. `text` bleibt der Teaser (Feed, Meta-Description, Article-Schema), der Artikel steht in `artikel[]` | | Dateiablage `/dateien` (Konfiguration) | `public/dateien/_filesconfig.php` (Rechte pro Konto: `storage/dateien/system/users/admin/config.php`) | **`storage/dateien/system/config/config.php` von Hand editieren** — die App generiert sie selbst und überschreibt sie bei jedem Update. Passwörter/Lizenz gehören in `config.php` (`dateien.*`) | | Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN | @@ -516,18 +516,23 @@ kein Download-Link — nur die Web-Ansicht. Der Ablauf pro Heimspiel ist damit: **Feste Vorlage** (seit Interview 29.07.2026, Anzeigen-Verteilung überarbeitet 31.07.2026) — der Inhalt ist eine Liste von **Blöcken**, die Anzeigen füllen die Lücken dazwischen (⌷ = Lücke): ``` -Cover → [Vorwort → Interview (nur wenn die Ausgabe eins trägt)] ⌷ Ergebnis-Rückblick ⌷ - Gegnerstatistik der tragenden Mannschaft (automatisch aus deren Tabelle) ⌷ +Cover → [Vorwort → Programm* → Interview → Aufruf (jedes nur wenn die Ausgabe es trägt)] ⌷ + [Turnierpläne*: je Turnier eine Seite, EIN Block] ⌷ Ergebnis-Rückblick ⌷ + [Gegnerstatistik: normal nur die tragende Mannschaft, in der Sportfest-Ausgabe je + Mannschaft mit Heimspiel am Fest eine — EIN Block, automatisch aus deren Tabelle] ⌷ [je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷ News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis + Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten - Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) ⌷ + Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) → + Danke-Seite (Partner-Logo-Wand + QR auf /partner-werden, ohne Lücke davor: Akquise und Dank + gehören zusammen) ⌷ Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite ``` Alle Seiten außer Rückseite und Vorwort-Fallback sind Live-Seiten. Nach dem Cover, im Aufschlag -und vor der Rückseite steht **keine** Lücke (Begründung unten). +und vor der Rückseite steht **keine** Lücke (Begründung unten). \* = nur Sonderausgabe, siehe +„Sportfest-Ausgabe" unten. **Doppelseiten-Regel fürs Falt-Heft:** aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander (gerade Seite links). Die erste Tabellenseite muss deshalb auf einer GERADEN Seitenzahl liegen, damit die Tabellen nebeneinander stehen. **Das rechnet das Skript selbst** (seit 31.07.2026): es verschiebt @@ -548,12 +553,30 @@ Lücken bekommen 7 Lücken zwei. Zwei hintereinander ist das Optimum, nicht eins Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf. Ein `images[]`-Eintrag hat `"type": "image"` (wie gehabt, `base`/`widths`/`alt`) oder `"type": "live"` (`page`: `cover` | -`vorwort` | `interview` | `aufruf` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `momente` | `impressum`, kein Bild; -`tabelle` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der Team-Key -der Ausgabe). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert +`vorwort` | `interview` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `impressum`, kein Bild; +`tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der +Team-Key der Ausgabe; `turnier` mit `turnier`: Key aus `turniere[]` der Ausgaben-Veranstaltung; +`danke` mit `anzeige`: das platzierte Anzeigenmotiv, siehe unten). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert wird jede Seite (Bild wie Live) über `app/components/stadionzeitung-seite.php`** — die eine gemeinsame Render-Stelle für Viewer und Druckfassung, nie zwei Implementierungen derselben Seite. +**Danke-Seite + platzierte Anzeigen (03.09.2026).** Der Dank an die Partner war bis dahin unten an +ein Anzeigen-Bild geklebt (ein Canva-Motiv, das Sponsorenanzeige und Dank in einer Datei hatte) und +bestand aus Wappen, Titel und einer Zeile auf leerer Fläche. Jetzt ist es eine Live-Seite +(`stadionzeitung-danke.php`): die **Logo-Wand aus `data/partners.json`** (Single Source, dieselbe +Quelle wie die Startseite), Kopf und der QR auf `/partner-werden`. Dazu ein neues, generisches Feld +im Anzeigenbestand: **`platzierung`** nimmt eine Anzeige aus dem Streu-Pool und gibt ihr einen festen +Platz auf einer Live-Seite (derzeit `danke`, dort sitzt die Emons-Anzeige über der Logo-Wand). +- **Das Motiv steht im Seiteneintrag, nicht in der Komponente**: `bin/stadionzeitung-add.php` schreibt + es als `anzeige` in den `images[]`-Eintrag. Dadurch ist es eingefroren wie jede andere Anzeige, und + eine veröffentlichte Ausgabe ändert sich nicht, wenn der Sponsor später ein neues Motiv liefert. +- **Die Seitenzahl bleibt gleich**: eine Anzeige verlässt den Pool, eine Live-Seite kommt dazu. +- Das Kombi-Motiv wurde an seiner roten Trennlinie zerschnitten, damit die Sponsorenanzeige + vollständig bleibt und der Dank nicht zweimal im Heft steht. +- **Falle in `bin/anzeige-add.php`** (dabei gefunden und behoben): das Aufräum-Muster lautete + `{$slug}-*.jpg` und traf damit jeden Slug, der den neuen als Präfix hat — `emons` löschte die + Motive von `emons-partnerdank`. Jetzt `{$slug}-[0-9]*.jpg`. + **Entwurf / Veröffentlicht (`status`-Feld):** `bin/stadionzeitung-add.php` legt jede Ausgabe als `entwurf` an — unsichtbar in Archiv und Sitemap, `noindex`, aber unter ihrer direkten URL erreichbar (zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen @@ -635,6 +658,73 @@ Fragen/Antworten, Claude trägt sie ins `interview`-Feld der Ausgabe ein (`tease Live-Seite nur ein, wenn das Feld existiert — Ausgaben ohne Interview haben weder Seite noch Teaser-Zeile. Fragen in Abel fett, Antworten in `--ff-serif` (dieselbe Begründung wie beim Vorwort). +**Sportfest-Ausgabe (03.09.2026): ein Slug-Verweis schaltet drei Seitentypen zu.** Trägt eine Ausgabe +das chat-gepflegte Feld `veranstaltung` (Slug aus `data/veranstaltungen.json`), kommen dazu: die +**Programm-Seite** (`stadionzeitung-programm.php`, aus `programm[]` der Veranstaltung), ein Block +**Turnierplan-Seiten** (`stadionzeitung-turnier.php`, je Eintrag in `turniere[]` eine Seite) und eine +**Gegnerseite je Mannschaft mit Heimspiel im Zeitraum** der Veranstaltung statt nur für die tragende. +Dazu trägt `cover.teaser` die Coverzeile „Im Heft"; das Feld überlebt erneute Skript-Läufe wie +`vorwort`. **`stadionzeitung_teaser()`** ist die eine Quelle dieser Zeile für Cover **und** +Archivkarte: `cover.teaser` vor `interview.teaser` — eine Sonderausgabe ohne Interview hat sonst keine. +**Das Cover einer Sonderausgabe zeigt das FEST, nicht die Begegnung** (Felix, 03.09.2026): Wappen +und „Heimspiel gegen …" entfallen, an ihrer Stelle steht der Veranstaltungstitel in der +Display-Schrift plus Zeitraum (Quelle: `veranstaltungen.json`, nie neu getippt). Grund: ein Fest +bringt mehrere Heimspiele mit, eines davon aufs Cover zu heben wäre schlicht falsch. Ausgelöst wird +das vom `veranstaltung`-Feld, nicht von einem eigenen Schalter. +**Statt des Spielerfotos steht das HAUPTPLAKAT der Veranstaltung rechts** (erstes Plakat aus +`veranstaltungen.json`, dieselbe Quelle wie die Detailseite, nie eine Kopie): leicht schräg gelegt +(4 Grad) und an zwei gegenüberliegenden Ecken gerundet, links oben und rechts unten, mit Schatten +statt Rahmen — wie ein aufs Cover gelegter Aushang (Felix, 03.09.2026). Es ist mit 66cqi deutlich +kleiner als das Spielerfoto (98cqi): ein Freisteller ist schmal, ein Plakat im Hochformat läge bei +derselben Höhe über der Typografie unten links. Die Textspalte wird dafür auf 48cqi verengt. +Ohne Plakat greift das Spielerfoto, ohne beides trägt der Hintergrund die Seite. `box-shadow` ist +hier unbedenklich; die Druck-Falle des Projekts betrifft `text-shadow` auf umbrechendem Text. + +**Sonst bleibt das Cover unverändert, und das ist eine Entscheidung:** eine rote Kennung +„Sportfest-Ausgabe" als Kasten im Kopf und ein kräftiger roter Doppelrahmen waren gebaut und wurden +am 03.09.2026 wieder entfernt (Felix: „sieht schrecklich aus"). Der Rahmen lief auf z2 quer über das +freigestellte Spielerfoto, und der Kasten stach aus dem ruhigen Kopf heraus. Die Ausgabe kennzeichnet +sich durch den großen Fest-Titel unten links ausreichend selbst. **Kein zweites Rot in den Kopf, und +keinen farbigen Rahmen über das Spielerfoto** — der Kicker bleibt „Saison · Datum" wie überall. +- **Die Daten liegen in `veranstaltungen.json`, nicht in der Ausgabe** — eine Quelle für Website und + Heft: dasselbe `programm[]` rendert `veranstaltung-programm.php` auf `/veranstaltungen/` und + `stadionzeitung-programm.php` auf dem A5-Blatt (zwei Darstellungen, nie zwei Datenstände). + `turniere[]` ist bisher **nur** Heft-Inhalt (Website-Darstellung offen), Feldform: + `{key, title, date, start, spieldauer, felder?, modus?, teams[], spiele[]{nr, zeit, feld?, heim, gast}}`. +- **`stadionzeitung_gegner_fixtures()`** (`helpers.php`) ist die EINZIGE Stelle, die entscheidet, + welche Mannschaft eine Gegnerseite bekommt. `bin/stadionzeitung-add.php` plant die Seiten damit, + `stadionzeitung_build_snapshot()` baut die Daten damit — sonst entstünde eine Seite ohne Daten oder + umgekehrt. `gegner` ist deshalb eine **Map je Team-Key** (wie `tabellen`) statt eines einzelnen + Objekts; Ausgaben ohne `team` am Seiteneintrag fallen auf die tragende Mannschaft zurück. Ein + eingefrorener Snapshot mit der alten Ein-Objekt-Form existiert nicht (die einzige veröffentlichte + Ausgabe ist älter als die Gegnerseite und trägt dort `null`), deshalb gibt es dafür bewusst + **keinen** Kompatibilitäts-Zweig. +- **Die Vorschau überspringt jedes Spiel mit eigener Gegnerseite**, nicht mehr nur das Heftspiel: + Zweite und Frauen stünden sonst zweimal im Heft. Sie zeigt dann die Spiele nach dem Fest. +- **Programm und Turnierpläne sind live, ohne Snapshot** (wie Kontakt/Aufruf): chat-gepflegte Daten, + die kein Sync wegräumt. Eine spätere Korrektur am Programm ändert damit auch eine archivierte + Ausgabe — bewusst in Kauf genommen, dieselbe Abwägung wie bei der Kontakt-Seite. +- **Die Turnierseite ist zum Beschreiben:** Spielplan mit LEERER Ergebnisspalte (Schreiblinien) und + leere Abschlusstabelle. Die Zeilenhöhe macht dort die **Schreiblinie**, nicht die Schriftgröße + (gemessen: eine kleinere Schrift brachte genau einen Pixel) — wer die E-Jugend-Seite mit zehn + Spielen aufs Blatt bringen muss, geht an `.stadionzeitung-turnier--dicht .__blank`. Die + Programm-Seite läuft aus demselben Grund mit straffen Abständen: zwölf Punkte plus drei Tagestitel + liefen mit den üblichen 3.3cqi 59px über die Blattkante. **Nach jeder Änderung Seitenhöhe messen.** +- **Beim Messen die scrollbaren Seiten mitnehmen.** Vorwort, Termine und Sportheim tragen + `overflow-y: auto`: dort wächst nicht die Seite, sondern das Kind wird scrollbar — eine Messung + von `scrollHeight - clientHeight` auf `.stadionzeitung-page` meldet 0, und im Druck fehlt trotzdem + das Ende des Textes. Genau so fiel ein sechsabsätziges Vorwort erst in der Druckvorschau auf + (03.09.2026, seitdem vier Absätze). Verlässlich ist: Unterkante der Signatur gegen die Blattkante + prüfen, im Viewer **und** in `?druck=1`. +- **Seitenzahl aufs Vielfache von 4 bringen, ohne Inhalt zu erfinden:** eine zeitgebundene + Eigenanzeige lässt sich per `bis` in `data/anzeigen.json` für diese Ausgabe abschalten (der Filter + vergleicht mit dem Ausgabedatum). So kam die Sportfest-Ausgabe von 49 auf 48 Seiten: das + Programmplakat als Anzeige wurde abgelöst, weil die gesetzte Programmseite es ersetzt. +- **Ablauf ist zweistufig** (wie beim Vorwort): erst `bin/stadionzeitung-add.php` laufen lassen, dann + `veranstaltung`/`cover` in die Ausgabe eintragen, dann **erneut** laufen lassen. Vorher existiert + die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder + Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler. + **Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite — `app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein** Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das diff --git a/app/components/stadionzeitung-cards.php b/app/components/stadionzeitung-cards.php index 4f616fa..215fa17 100644 --- a/app/components/stadionzeitung-cards.php +++ b/app/components/stadionzeitung-cards.php @@ -36,11 +36,12 @@ $issues = $issues ?? []; · Seiten

- +

diff --git a/app/components/stadionzeitung-cover.php b/app/components/stadionzeitung-cover.php index e750af6..ffd2c14 100644 --- a/app/components/stadionzeitung-cover.php +++ b/app/components/stadionzeitung-cover.php @@ -69,18 +69,64 @@ if ($match !== null) { // Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()). $linkText = club_domain(); -$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : '')); +// Aufmacher unten links: normal die Begegnung (Wappen + „Heimspiel gegen …"). +// Zeigt die Ausgabe auf eine Veranstaltung (Sportfest-Ausgabe), tritt an deren +// Stelle das FEST als Titelgeschichte — denn ein Fest bringt mehrere Heimspiele +// mit (Erste, Zweite, Frauen), und ein einzelnes davon aufs Cover zu heben wäre +// falsch (Felix, 03.09.2026). Titel und Zeitraum kommen aus +// data/veranstaltungen.json, nie neu getippt. +$feature = null; +$featureV = veranstaltung_find((string) ($issue['veranstaltung'] ?? '')); +if ($featureV !== null) { + $feature = [ + 'title' => trim((string) ($featureV['title'] ?? '')), + 'range' => german_date_range((string) ($featureV['date_start'] ?? ''), $featureV['date_end'] ?? null), + ]; +} + +// Bildmotiv rechts: normal das freigestellte Spielerfoto. Eine Sonderausgabe mit +// Veranstaltung zeigt stattdessen deren HAUPTPLAKAT (erstes Plakat, dieselbe +// Quelle wie /veranstaltungen/, nie eine Kopie) — leicht schräg gelegt mit +// zwei abgerundeten Ecken, wie ein aufs Cover gelegter Aushang (Felix, +// 03.09.2026). Ohne Plakat bleibt es beim Spielerfoto, ohne beides trägt der +// Hintergrund die Seite allein. +$poster = null; +if ($featureV !== null) { + $ersteseite = $featureV['plakate'][0] ?? null; + if (is_array($ersteseite) && !empty($ersteseite['base'])) { + $poster = $ersteseite; + } +} + +// Kicker: Saison · Datum, auf jedem Cover gleich. Eine Sonderausgabe bekommt +// bewusst KEINE zusätzliche Kennung im Kopf: ein roter Kasten „Sportfest-Ausgabe" +// war gebaut und am 03.09.2026 wieder entfernt (Felix) — er stach aus dem +// ruhigen Kopf heraus, und die Ausgabe kennzeichnet sich unten links ohnehin +// selbst durch den großen Fest-Titel. +$kicker = implode(' · ', array_filter([ + $season, + $date !== '' ? german_date($date) : '', +], static fn (string $s): bool => $s !== '')); // Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) — -// erscheint automatisch, sobald die Ausgabe ein Interview trägt -// (interview-Feld, chat-gepflegt wie das Vorwort). -$teaser = trim((string) ($issue['interview']['teaser'] ?? '')); +// aus stadionzeitung_teaser(): `cover.teaser` einer Sonderausgabe, sonst die +// Coverzeile des Interviews. Dieselbe Funktion speist die Archivkarte. +$teaser = stadionzeitung_teaser($issue); ?> -
+
$heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?> - + + + ['base' => $poster['base'], 'widths' => $poster['widths'] ?? [], 'alt' => ''], + 'class' => 'stadionzeitung-cover__poster', + 'sizes' => '(max-width: 600px) 55vw, 260px', + ]); ?> + Rund ums Heimspiel im Katzbachtal

+ + +
+

+ +

+ +
+
@@ -124,6 +181,7 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));

+

Im Heft diff --git a/app/components/stadionzeitung-danke.php b/app/components/stadionzeitung-danke.php new file mode 100644 index 0000000..741591c --- /dev/null +++ b/app/components/stadionzeitung-danke.php @@ -0,0 +1,65 @@ + +

+ + $anzeige, 'sizes' => '420px', 'class' => 'stadionzeitung-danke__anzeige']); ?> + +
+

Unsere Partner

+ +

Danke für die Unterstützung

+
+ +
    + + + + +
+ +
+
+

Mitmachen

+

Werden Sie unser Partner

+ +
+ +
+ + Scan mich +
+ +
+
diff --git a/app/components/stadionzeitung-gegner.php b/app/components/stadionzeitung-gegner.php index 9a206d3..a211bb5 100644 --- a/app/components/stadionzeitung-gegner.php +++ b/app/components/stadionzeitung-gegner.php @@ -7,9 +7,14 @@ declare(strict_types=1); * aufgebauten Stadionzeitungs-Snapshot; die Komponente rendert nur. Dadurch * sind Entwurf und veröffentlichte Druckausgabe konsistent und archivfest. * - * Props: $info stadionzeitung_live_data($issue)['gegner'] + * Props: $info stadionzeitung_live_data($issue)['gegner'][team] — ein Eintrag der + * Map je Team-Key (team_name/opponent/league/season/fixture/own/ + * opponent_stats). Normal nur die tragende Mannschaft; in der + * Sportfest-Ausgabe eine Seite je Heimspiel am Fest, deshalb + * nennt der Kicker die Mannschaft. */ $info = is_array($info ?? null) ? $info : []; +$teamName = trim((string) ($info['team_name'] ?? '')); $fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : []; $own = is_array($info['own'] ?? null) ? $info['own'] : null; $opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null; @@ -39,7 +44,7 @@ $format = static function (?array $row, string $key, bool $signed = false): stri ?>
-

Unser Gegner

+

@@ -70,9 +75,12 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
- TSV 08 + + Wir Vergleich - ASV + Gegner
diff --git a/app/components/stadionzeitung-programm.php b/app/components/stadionzeitung-programm.php new file mode 100644 index 0000000..95367be --- /dev/null +++ b/app/components/stadionzeitung-programm.php @@ -0,0 +1,71 @@ + +
+
+

+

Programm

+ +

+ +
+ + + +
+ +

+ +
    + + +
  • + + + + + + + + + + + +
  • + +
+
+ +
diff --git a/app/components/stadionzeitung-seite.php b/app/components/stadionzeitung-seite.php index 6638955..e3fa035 100644 --- a/app/components/stadionzeitung-seite.php +++ b/app/components/stadionzeitung-seite.php @@ -9,9 +9,12 @@ declare(strict_types=1); * anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props: * $entry Eintrag aus issue.images[]: * type 'image' (base/widths/alt), - * type 'live' (page: cover|vorwort|interview|aufruf|tabelle|termine| - * news|ergebnisse|gegner|vorschau|kontakt; tabelle optional - * mit team: Team-Key aus matchcenter.json), + * type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier| + * tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt| + * mitglied|historie|sportheim|partner|danke|momente|impressum; + * tabelle und gegner optional mit team: Team-Key aus + * matchcenter.json; turnier mit turnier: Key aus + * veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung), * type 'leer' (weiße Füllseite, nur Druckfassung — hält die * Seitenzahl auf einem Vielfachen von 4) * $issue Eintrag aus data/stadionzeitung.json @@ -56,7 +59,19 @@ $type = $entry['type'] ?? 'image'; $data['ergebnisse'] ?? []]); ?> - $data['gegner'] ?? null]); ?> + $data['gegner'][$gegnerTeam] ?? null]); +?> + + + $issue]); ?> + + + $issue, 'turnier' => (string) ($entry['turnier'] ?? '')]); ?> $data['vorschau'] ?? []]); ?> @@ -76,6 +91,11 @@ $type = $entry['type'] ?? 'image'; + + + $entry['anzeige'] ?? null]); ?> + $data['momente'] ?? []]); ?> diff --git a/app/components/stadionzeitung-turnier.php b/app/components/stadionzeitung-turnier.php new file mode 100644 index 0000000..91e44bc --- /dev/null +++ b/app/components/stadionzeitung-turnier.php @@ -0,0 +1,158 @@ + $clubName !== '' + && str_starts_with(mb_strtolower(trim($team)), mb_strtolower($clubName)); + +$date = (string) ($t['date'] ?? ''); +$weekday = $date !== '' ? german_weekday($date) : ''; +$kicker = trim(implode(' · ', array_filter([ + (string) ($v['title'] ?? ''), + trim(($weekday !== '' ? $weekday . ', ' : '') . ($date !== '' ? german_date($date) : '')), +]))); + +$teams = array_values(array_filter( + array_map(static fn ($x): string => trim((string) $x), $t['teams'] ?? []), + static fn (string $x): bool => $x !== '' +)); +$spiele = array_values(array_filter($t['spiele'] ?? [], 'is_array')); +$felder = (int) ($t['felder'] ?? 0); +$hatFeld = $felder > 1; + +$meta = []; +if (!empty($t['start'])) { + $meta[] = 'Beginn ' . (string) $t['start'] . ' Uhr'; +} +if (!empty($t['spieldauer'])) { + $meta[] = 'Spieldauer ' . (int) $t['spieldauer'] . ' Min.'; +} +if ($hatFeld) { + $meta[] = $felder . ' Felder'; +} +if (!empty($t['modus'])) { + $meta[] = (string) $t['modus']; +} +$sub = trim(implode(' · ', $meta)); +$title = trim((string) ($t['title'] ?? $turnierKey)); +// Ab neun Spielen (E-Jugend: zehn) rücken die Zeilen enger, sonst läuft der +// Spielplan mit der Abschlusstabelle über die Blattkante. +$dicht = count($spiele) > 8; +?> +
+
+ +

+ +

+ +

+ +
+ + +

Teilnehmer

+

+ $team): ?> + 0 ? ' · ' : '' ?> + +

+ + + + + + + + + + + + + + + + + + $spiel): ?> + + + + + + + + + + + + +
Spielplan
Nr.ZeitFeldBegegnungErgebnis
gegen :
+ + + + + + + + + + + + + + + + + + + + + + + + +
Platzierung zum Eintragen
Pl.TeilnehmerTTDPkt
.
+ +
diff --git a/app/components/stadionzeitung-viewer.php b/app/components/stadionzeitung-viewer.php index 3f92d7e..df4a7b1 100644 --- a/app/components/stadionzeitung-viewer.php +++ b/app/components/stadionzeitung-viewer.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props: * $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status, - * images[]{type: 'image'{base,widths,alt} | 'live'{page[,team]}}) + * images[]{type: 'image'{base,widths,alt} | 'live'{page[,team][,turnier]}}) * * Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) — * voll lesbar, sequentiell auch per Screenreader. Mit JS diff --git a/app/helpers.php b/app/helpers.php index 169ed88..d404e07 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -568,6 +568,27 @@ function veranstaltungen_load(): array return $items; } +/** + * Eine Veranstaltung per Slug, UNGEFILTERT (auch Entwürfe) — für die + * Detailseite (app/pages/veranstaltung.php) und die Stadionzeitungs-Live-Seiten + * der Sportfest-Ausgabe (Programm, Turnierpläne, Gegnerseiten je Heimspiel). + * Ungefiltert aus demselben Grund wie die Detailseite: ein Entwurf muss sich + * gegenlesen lassen, und eine Entwurfs-Ausgabe der Stadionzeitung darf auf + * einen Entwurfs-Termin zeigen. null, wenn es den Slug nicht gibt. + */ +function veranstaltung_find(string $slug): ?array +{ + if ($slug === '') { + return null; + } + foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) { + if (($entry['slug'] ?? '') === $slug) { + return $entry; + } + } + return null; +} + /** * Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der * Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am @@ -955,6 +976,86 @@ function stadionzeitung_is_published(array $issue): bool return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf'; } +/** + * Coverzeile „Im Heft" einer Ausgabe — die EINE Quelle für das Cover + * (stadionzeitung-cover.php) und die Archivkarte (stadionzeitung-cards.php), + * damit beide nie Verschiedenes sagen. Eine Sonderausgabe (Sportfest) setzt + * `cover.teaser` chat-gepflegt; sonst trägt die Coverzeile des Interviews + * (`interview.teaser`). Leer, wenn es weder das eine noch das andere gibt. + */ +function stadionzeitung_teaser(array $issue): string +{ + $teaser = trim((string) ($issue['cover']['teaser'] ?? '')); + if ($teaser !== '') { + return $teaser; + } + return trim((string) ($issue['interview']['teaser'] ?? '')); +} + +/** + * Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE + * Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND + * stadionzeitung_build_snapshot() (Daten), damit nie eine Seite ohne Daten + * oder Daten ohne Seite entstehen. + * + * Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`). + * Zusätzlich, wenn die Ausgabe eine `veranstaltung` (Slug) mit Zeitraum trägt + * (Sportfest-Ausgabe): jede weitere Mannschaft aus matchcenter.json mit einem + * HEIMspiel in diesem Zeitraum — drei Heimspiele am Fest, drei Gegnerseiten. + * Der Gegner ist dann der andere Verein der Begegnung. Reihenfolge wie in + * matchcenter.json (= config 'matchcenter.teams'). + * + * Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']] + */ +function stadionzeitung_gegner_fixtures(array $issue): array +{ + $date = (string) ($issue['date'] ?? ''); + $issueTeam = (string) ($issue['team_key'] ?? ''); + $issueOpponent = trim((string) ($issue['opponent'] ?? '')); + + $von = ''; + $bis = ''; + $veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? '')); + if ($veranstaltung !== null) { + $von = (string) ($veranstaltung['date_start'] ?? ''); + $bis = (string) ($veranstaltung['date_end'] ?? $von); + } + + $fixtures = []; + foreach (json_load('matchcenter')['teams'] ?? [] as $team) { + $key = (string) ($team['key'] ?? ''); + if ($key === '') { + continue; + } + if ($key === $issueTeam) { + if ($issueOpponent !== '') { + $fixtures[$key] = ['opponent' => $issueOpponent, 'date' => $date]; + } + continue; + } + if ($von === '') { + continue; + } + foreach ($team['next_matches'] ?? [] as $match) { + if (empty($match['is_home'])) { + continue; + } + $matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10); + if ($matchDate < $von || $matchDate > $bis) { + continue; + } + $opponent = trim((string) ($match['away'] ?? '')); + if ($opponent === '' || $opponent === 'SPIELFREI') { + continue; + } + $fixtures[$key] = ['opponent' => $opponent, 'date' => $matchDate]; + break; + } + } + + return $fixtures; +} + /** * Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) — für die * Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert @@ -988,12 +1089,15 @@ function stadionzeitung_build_snapshot(array $issue): array { $date = (string) ($issue['date'] ?? ''); $issueTeam = (string) ($issue['team_key'] ?? ''); - $issueOpponent = trim((string) ($issue['opponent'] ?? '')); $tabellen = []; $ergebnisse = []; $vorschau = []; - $gegner = null; + // Gegnerseiten als Map je Team-Key (wie `tabellen`): welche Mannschaften + // eine bekommen, entscheidet stadionzeitung_gegner_fixtures() — dieselbe + // Funktion, mit der bin/stadionzeitung-add.php die Seiten einplant. + $gegnerFixtures = stadionzeitung_gegner_fixtures($issue); + $gegner = []; foreach (json_load('matchcenter')['teams'] ?? [] as $team) { $key = (string) ($team['key'] ?? ''); if ($key === '') { @@ -1006,11 +1110,14 @@ function stadionzeitung_build_snapshot(array $issue): array 'table' => $team['table'] ?? [], ]; - // Gegnerseite der tragenden Mannschaft: ausschließlich aus demselben - // Matchcenter-Datensatz wie die Tabelle ableiten. So gibt es keinen - // zweiten BFV-Abruf und der Stand wird beim Veröffentlichen zusammen - // mit den übrigen Live-Daten eingefroren. - if ($key === $issueTeam && $issueOpponent !== '') { + // Gegnerseite(n): ausschließlich aus demselben Matchcenter-Datensatz + // wie die Tabelle ableiten. So gibt es keinen zweiten BFV-Abruf und der + // Stand wird beim Veröffentlichen zusammen mit den übrigen Live-Daten + // eingefroren. Normal nur die tragende Mannschaft; in der Sportfest- + // Ausgabe jede Mannschaft mit Heimspiel am Fest (s. gegner_fixtures). + if (isset($gegnerFixtures[$key])) { + $gegnerName = $gegnerFixtures[$key]['opponent']; + $gegnerDate = $gegnerFixtures[$key]['date']; $table = is_array($team['table'] ?? null) ? $team['table'] : []; $ownRow = null; $opponentRow = null; @@ -1018,7 +1125,7 @@ function stadionzeitung_build_snapshot(array $issue): array if (!empty($row['is_own_club'])) { $ownRow = $row; } - if (strcasecmp(trim((string) ($row['club'] ?? '')), $issueOpponent) === 0) { + if (strcasecmp(trim((string) ($row['club'] ?? '')), $gegnerName) === 0) { $opponentRow = $row; } } @@ -1027,15 +1134,15 @@ function stadionzeitung_build_snapshot(array $issue): array foreach ($team['next_matches'] ?? [] as $match) { $matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10); $clubs = [(string) ($match['home'] ?? ''), (string) ($match['away'] ?? '')]; - if ($matchDate === $date && in_array($issueOpponent, $clubs, true)) { + if ($matchDate === $gegnerDate && in_array($gegnerName, $clubs, true)) { $fixture = $match; break; } } - $gegner = [ + $gegner[$key] = [ 'team_name' => (string) ($team['name'] ?? ''), - 'opponent' => $issueOpponent, + 'opponent' => $gegnerName, 'league' => (string) ($team['league'] ?? ''), 'season' => (string) ($team['season'] ?? ''), 'fixture' => $fixture, @@ -1064,7 +1171,11 @@ function stadionzeitung_build_snapshot(array $issue): array // Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der // Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es - // schon); ein Spiel einer ANDEREN Mannschaft am selben Tag bleibt drin. + // schon), und ebenso jedes Spiel, das eine eigene Gegnerseite hat + // (Sportfest-Ausgabe: Zweite und Frauen stünden sonst doppelt im Heft). + // Ein Spiel einer ANDEREN Mannschaft am selben Tag ohne Gegnerseite + // bleibt drin. + $gegnerDate = $gegnerFixtures[$key]['date'] ?? null; foreach ($team['next_matches'] ?? [] as $match) { // Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen // "SPIELFREI" — kein echtes Spiel, im Heft überspringen. @@ -1078,6 +1189,9 @@ function stadionzeitung_build_snapshot(array $issue): array if ($matchDate === $date && $key === $issueTeam) { continue; } + if ($gegnerDate !== null && $matchDate === $gegnerDate) { + continue; + } $vorschau[] = [ 'team_key' => $key, 'team_name' => (string) ($team['name'] ?? ''), diff --git a/app/pages/veranstaltung.php b/app/pages/veranstaltung.php index 94e44e8..69d6bf7 100644 --- a/app/pages/veranstaltung.php +++ b/app/pages/veranstaltung.php @@ -9,9 +9,9 @@ declare(strict_types=1); * data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie` * schreibt bin/veranstaltung-bilder.php. * - * Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()): - * ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er - * gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er + * Geladen wird hier bewusst UNGEFILTERT (veranstaltung_find(), nicht + * veranstaltungen_load()): ein Entwurf muss unter seiner direkten URL lesbar + * bleiben, damit er gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er * trägt noindex. * * Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe): @@ -23,13 +23,7 @@ declare(strict_types=1); $urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); $veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/')); -$v = null; -foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) { - if (($entry['slug'] ?? '') === $veranstaltungSlug) { - $v = $entry; - break; - } -} +$v = veranstaltung_find($veranstaltungSlug); if ($v === null) { // http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie diff --git a/bin/anzeige-add.php b/bin/anzeige-add.php index 28a15a4..c8cf7ae 100644 --- a/bin/anzeige-add.php +++ b/bin/anzeige-add.php @@ -123,7 +123,11 @@ foreach ($sources as $src) { // Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv // Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild // nicht mehr passen. - foreach (glob("{$destDir}/{$slug}-*.jpg") ?: [] as $alt) { + // Muster bewusst auf die BREITEN-Endung eingeschränkt (`-.jpg`): mit + // `{$slug}-*.jpg` löscht ein Slug die Motive jedes Slugs, der ihn als Präfix + // hat — `emons` räumte so die Dateien von `emons-partnerdank` mit weg + // (03.09.2026 beobachtet, dort zufällig gewollt, generell Datenverlust). + foreach (glob("{$destDir}/{$slug}-[0-9]*.jpg") ?: [] as $alt) { unlink($alt); } diff --git a/bin/preflight.php b/bin/preflight.php index 48c2435..b12761e 100644 --- a/bin/preflight.php +++ b/bin/preflight.php @@ -326,6 +326,41 @@ if (is_file($szFile)) { } } +// --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) --- +// Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die +// Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug +// oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil +// die Komponenten bewusst still zurückkehren. Deshalb hier laut. +if (is_file($szFile)) { + $szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []); + $vBySlug = array_column($veranstaltungRows, null, 'slug'); + foreach ($szData['issues'] ?? [] as $ausgabe) { + $vSlug = (string) ($ausgabe['veranstaltung'] ?? ''); + if ($vSlug === '') { + continue; + } + if (!isset($vBySlug[$vSlug])) { + $errors[] = "Stadionzeitung '{$ausgabe['slug']}': veranstaltung '{$vSlug}' fehlt in data/veranstaltungen.json."; + continue; + } + $turniere = $vBySlug[$vSlug]['turniere'] ?? []; + foreach ($turniere as $i => $turnier) { + foreach (['key', 'title', 'date', 'teams', 'spiele'] as $feld) { + if (empty($turnier[$feld])) { + $warnings[] = "data/veranstaltungen.json, '{$vSlug}' → turniere[{$i}]: Feld '{$feld}' fehlt oder ist leer."; + } + } + } + $keys = array_column($turniere, 'key'); + foreach ($ausgabe['images'] ?? [] as $seite) { + if (($seite['type'] ?? '') === 'live' && ($seite['page'] ?? '') === 'turnier' + && !in_array((string) ($seite['turnier'] ?? ''), $keys, true)) { + $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Turnierseite '" . ($seite['turnier'] ?? '') . "' hat keinen Eintrag in turniere[] von '{$vSlug}'."; + } + } + } +} + // --- Inline-Styles (die CSP verwirft sie stillschweigend) --- // Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der // Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf diff --git a/bin/stadionzeitung-add.php b/bin/stadionzeitung-add.php index f966d30..c6a24f3 100644 --- a/bin/stadionzeitung-add.php +++ b/bin/stadionzeitung-add.php @@ -58,6 +58,16 @@ declare(strict_types=1); * Interview → Aufruf. Für die Aufruf-Seite erzeugt das Skript zusätzlich den * QR (qr-aufruf.png im Ausgaben-Ordner) aus `aufruf.link`. * + * Sportfest-Ausgabe (03.09.2026): trägt die Ausgabe ein `veranstaltung`-Feld + * (Slug aus data/veranstaltungen.json, chat-gepflegt), kommen dazu: die + * Programm-Seite im Aufschlag (aus programm[] der Veranstaltung, dieselbe Liste + * wie die Website), ein Block Turnierplan-Seiten (je Eintrag in turniere[] eine + * Seite) direkt danach, und eine Gegnerseite je Mannschaft mit HEIMspiel im + * Zeitraum der Veranstaltung statt nur für die tragende Mannschaft — welche das + * sind, sagt stadionzeitung_gegner_fixtures() (helpers.php), dieselbe Funktion, + * aus der der Snapshot die Daten baut. `cover` ({edition, teaser}) kennzeichnet + * das Cover als Sonderausgabe. Beide Felder überleben erneute Läufe wie `vorwort`. + * * Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte * Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen. */ @@ -188,6 +198,19 @@ $oldVorwort = $oldIssue['vorwort'] ?? null; $oldInterview = $oldIssue['interview'] ?? null; // Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt. $oldAufruf = $oldIssue['aufruf'] ?? null; +// Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm, +// Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt. +$oldVeranstaltung = $oldIssue['veranstaltung'] ?? null; +$oldCover = $oldIssue['cover'] ?? null; +$veranstaltung = null; +if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') { + $veranstaltung = veranstaltung_find($oldVeranstaltung); + if ($veranstaltung === null) { + $log("veranstaltung '{$oldVeranstaltung}' steht nicht in data/veranstaltungen.json — Programm-, Turnier- und Zusatz-Gegnerseiten entfallen.", 'WARN'); + } else { + $log("Sportfest-Ausgabe: Veranstaltung '{$oldVeranstaltung}' (" . count($veranstaltung['programm'] ?? []) . ' Programmtage, ' . count($veranstaltung['turniere'] ?? []) . ' Turniere).'); + } +} // --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen --- $destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}"; @@ -215,6 +238,28 @@ $widths = [480, 960, 1440]; // erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und // im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die // Bilder liegen schon. --- +// Anzeigen mit `platzierung` haben einen FESTEN Platz auf einer Live-Seite +// (derzeit `danke`: die Emons-Anzeige über der Partner-Logo-Wand) und laufen +// deshalb nicht im Streu-Pool mit — sonst stünden sie zweimal im Heft. +$platzierteAnzeigen = []; +foreach (json_load('anzeigen')['anzeigen'] ?? [] as $a) { + $platz = trim((string) ($a['platzierung'] ?? '')); + if ($platz === '' || ($a['aktiv'] ?? true) === false || empty($a['base'])) { + continue; + } + if (!empty($a['bis']) && (string) $a['bis'] < $date) { + continue; + } + if (isset($platzierteAnzeigen[$platz])) { + continue; + } + $platzierteAnzeigen[$platz] = [ + 'base' => (string) $a['base'], + 'widths' => $a['widths'] ?? [], + 'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . trim((string) ($a['sponsor'] ?? '')))), + ]; +} + $anzeigenSeiten = array_values(array_map( static function (array $a): array { $sponsor = trim((string) ($a['sponsor'] ?? '')); @@ -237,6 +282,7 @@ $anzeigenSeiten = array_values(array_map( // nächsten Handgriff im Heft zu bleiben. static fn (array $a): bool => ($a['aktiv'] ?? true) && !empty($a['base']) + && empty($a['platzierung']) && (empty($a['bis']) || (string) $a['bis'] >= $date) ) )); @@ -245,12 +291,8 @@ $anzeigenSeiten = array_values(array_map( // Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE // Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich // bei jeder Umverteilung. --- -$live = static function (string $page, ?string $team = null): array { - $entry = ['type' => 'live', 'page' => $page]; - if ($team !== null) { - $entry['team'] = $team; - } - return $entry; +$live = static function (string $page, array $extra = []): array { + return ['type' => 'live', 'page' => $page] + $extra; }; $bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array { @@ -293,6 +335,11 @@ try { } elseif ($vorwort !== null) { $aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung'); } + // Programm-Seite der Sportfest-Ausgabe direkt hinter dem Vorwort: aufgeschlagen + // liegen Vorwort (links) und Programm (rechts) als Doppelseite 2/3 nebeneinander. + if ($veranstaltung !== null && !empty($veranstaltung['programm'])) { + $aufschlag[] = $live('programm'); + } // Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes // Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu. if ($oldInterview !== null) { @@ -308,8 +355,40 @@ try { $bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true]; } + // Turnierplan-Seiten der Sportfest-Ausgabe: je Turnier eine Seite, EIN Block + // (zusammenhängender Sportfest-Teil, keine Anzeige zwischen zwei Turnieren), + // gleich nach dem Aufschlag und vor dem Fußball-Teil. + $turniere = []; + foreach ($veranstaltung['turniere'] ?? [] as $turnier) { + if (is_array($turnier) && !empty($turnier['key'])) { + $turniere[] = $live('turnier', ['turnier' => (string) $turnier['key']]); + } + } + if ($turniere !== []) { + $bloecke[] = ['name' => 'turniere', 'seiten' => $turniere, 'anzeigen_danach' => true]; + } + $bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true]; - $bloecke[] = ['name' => 'gegner', 'seiten' => [$live('gegner')], 'anzeigen_danach' => true]; + + // Gegnerseite(n): normal nur die tragende Mannschaft; in der Sportfest-Ausgabe + // je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als + // EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() — + // dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut, + // damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen. + $gegnerSeiten = []; + foreach (stadionzeitung_gegner_fixtures([ + 'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent, + 'veranstaltung' => $oldVeranstaltung, + ]) as $gegnerTeam => $fixture) { + $gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]); + $log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']})."); + } + if ($gegnerSeiten === []) { + // Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft — + // sie meldet dann selbst, dass keine Vergleichsdaten vorliegen. + $gegnerSeiten[] = $live('gegner', ['team' => $teamKey]); + } + $bloecke[] = ['name' => 'gegner', 'seiten' => $gegnerSeiten, 'anzeigen_danach' => true]; // Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json // (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die @@ -317,15 +396,28 @@ try { $tabellen = []; foreach ($mc['teams'] ?? [] as $team) { if (!empty($team['key'])) { - $tabellen[] = $live('tabelle', (string) $team['key']); + $tabellen[] = $live('tabelle', ['team' => (string) $team['key']]); } } if ($tabellen !== []) { $bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true]; } - foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) { - $bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true]; + foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'danke', 'momente'] as $seite) { + // Die Danke-Seite trägt ihr Anzeigenmotiv im Seiteneintrag (siehe oben), + // direkt hinter der Partner-werden-Seite: aufgeschlagen liegen damit + // Akquise und Dank als Partner-Doppelseite nebeneinander. + $extra = ($seite === 'danke' && isset($platzierteAnzeigen['danke'])) + ? ['anzeige' => $platzierteAnzeigen['danke']] + : []; + // Hinter der Partner-werden-Seite bleibt die Lücke leer, damit die + // Danke-Seite direkt folgt: Akquise und Dank gehören zusammen und + // liegen aufgeschlagen im günstigen Fall als Doppelseite nebeneinander. + $bloecke[] = [ + 'name' => $seite, + 'seiten' => [$live($seite, $extra)], + 'anzeigen_danach' => $seite !== 'partner', + ]; } $bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false]; @@ -651,6 +743,8 @@ $issues[] = [ 'vorwort' => $oldVorwort, 'interview' => $oldInterview, 'aufruf' => $oldAufruf, + 'veranstaltung' => $oldVeranstaltung, + 'cover' => $oldCover, 'verfasser_photo' => $verfasserPhoto, 'termine_snapshot' => $termineSnapshot, 'images' => $images, diff --git a/data/anzeigen.json b/data/anzeigen.json index bc3f70e..ed585b5 100644 --- a/data/anzeigen.json +++ b/data/anzeigen.json @@ -239,7 +239,7 @@ "slug": "sportfest-2026-programm", "sponsor": "Programm Sportfest 2026", "aktiv": true, - "bis": "2026-09-06", + "bis": "2026-09-03", "alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade", "base": "img/anzeigen/sportfest-2026-programm", "widths": [ @@ -248,18 +248,6 @@ 1440 ] }, - { - "slug": "emons-partnerdank", - "sponsor": "Emons Spedition und Dank an unsere Partner", - "aktiv": true, - "alt": "Anzeige: Emons Spedition, darunter unser Dank an alle Partner", - "base": "img/anzeigen/emons-partnerdank", - "widths": [ - 480, - 960, - 1440 - ] - }, { "slug": "gagel-pickelmann", "sponsor": "Gagel Bäder und Mineralölhandel Pickelmann", @@ -294,6 +282,19 @@ 960, 1440 ] + }, + { + "slug": "emons", + "sponsor": "Emons Spedition", + "aktiv": true, + "base": "img/anzeigen/emons", + "widths": [ + 480, + 960, + 1141 + ], + "alt": "Anzeige: Emons Spedition Himmelkron, Ihr Partner für nationale und internationale Transporte", + "platzierung": "danke" } ] } diff --git a/data/stadionzeitung.json b/data/stadionzeitung.json index 7690dee..4144eb9 100644 --- a/data/stadionzeitung.json +++ b/data/stadionzeitung.json @@ -1,5 +1,847 @@ { "issues": [ + { + "slug": "2026-09-04-ats-wartenfels", + "opponent": "ATS Wartenfels", + "date": "2026-09-04", + "team_key": "erste-mannschaft", + "status": "entwurf", + "pages": 48, + "player_image": { + "base": "img/stadionzeitung/2026-09-04-ats-wartenfels/spieler", + "widths": [ + 320, + 640, + 1000 + ], + "ext": "png", + "alt": "" + }, + "qr_image": "img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png", + "vorwort": { + "heading": "Drei Tage Fußball im Katzbachtal", + "paragraphs": [ + "Liebe Fußballfreunde,", + "herzlich willkommen zu unserem Sportfest. Ein besonderer Gruß gilt dem Schiedsrichtergespann und unseren Gästen vom ATS Wartenfels.", + "Bis Sonntag ist bei uns durchgehend Betrieb: am Samstag unsere Zweite, am Sonntag unsere Frauen, dazwischen die Turniere unserer Jugend. Danke an alle, die dieses Wochenende möglich machen.", + "Der 7:3-Heimsieg gegen Marktschorgast gibt uns Rückenwind. Leidenschaft, Wille, Heimsieg. Los geht's!" + ], + "closing": "Rot-schwarze Grüße", + "signee_name": "Benedikt Höfner", + "signee_role": "Sportlicher Leiter" + }, + "interview": null, + "aufruf": null, + "veranstaltung": "sportfest-2026", + "cover": { + "teaser": "Das komplette Programm zum Sportfest und die Spielpläne aller vier Jugendturniere." + }, + "verfasser_photo": { + "base": "img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser", + "widths": [ + 120, + 222 + ], + "alt": "" + }, + "termine_snapshot": [ + { + "date": "2026-09-04", + "date_end": "2026-09-06", + "title": "Sportfest 2026", + "department": "verein", + "type": "event", + "meta": "16:30 Uhr · Hans-Rausch-Sportanlage", + "href": "/veranstaltungen/sportfest-2026", + "time": "16:30", + "location": "Hans-Rausch-Sportanlage", + "raw": { + "slug": "sportfest-2026", + "title": "Sportfest 2026", + "date_start": "2026-09-04", + "date_end": "2026-09-06", + "time": "16:30", + "location": "Hans-Rausch-Sportanlage", + "department": "verein", + "status": "veroeffentlicht", + "text": "Drei Tage Fußball, Essen, Trinken, Kaffee und Kuchen: bei uns ist für groß und klein alles dabei, für die Kinder steht eine Hüpfburg.", + "ankuendigung": null, + "rueckblick": null, + "karte": "club", + "anfahrt": [], + "programm": [ + { + "tag": "2026-09-04", + "punkte": [ + { + "zeit": "16:30", + "titel": "Spiel unserer E2-Jugend" + }, + { + "zeit": "18:00", + "titel": "Unsere erste Mannschaft gegen ATS Wartenfels", + "text": "Kreisklasse" + } + ] + }, + { + "tag": "2026-09-05", + "punkte": [ + { + "zeit": "09:00", + "titel": "Turnier der G-Jugend" + }, + { + "zeit": "12:00", + "titel": "Turnier der F1-Jugend" + }, + { + "zeit": "12:00", + "titel": "Turnier der F2-Jugend" + }, + { + "zeit": "14:30", + "titel": "Kinderolympiade", + "text": "bis 16:30 Uhr in der Turnhalle Maximum Gym" + }, + { + "zeit": "15:00", + "titel": "Turnier der D1-Jugend" + }, + { + "zeit": "18:00", + "titel": "Unsere zweite Mannschaft gegen SV Weidenberg III/SSV Warmensteinach II", + "text": "Reserve" + } + ] + }, + { + "tag": "2026-09-06", + "punkte": [ + { + "zeit": "11:00", + "titel": "Unsere Frauenmannschaft gegen 1. FC 1928 Schwarzenbach/Saale", + "text": "Kreisliga Ost" + }, + { + "zeit": "13:00", + "titel": "Turnier der E1-Jugend" + }, + { + "zeit": "15:00", + "titel": "Spiel unserer D2-Jugend" + }, + { + "zeit": "16:30", + "titel": "Spiel unserer C/B-Jugend" + } + ] + } + ], + "turniere": [ + { + "key": "f1", + "title": "F1-Jugend", + "date": "2026-09-05", + "start": "12:00", + "spieldauer": 10, + "modus": "Jeder gegen jeden mit Hin und Rückspiel", + "teams": [ + "VfR Katschenreuth 1", + "SV Motschenbach 1", + "TSV Neudrossenfeld 1" + ], + "spiele": [ + { + "nr": 1, + "zeit": "12:00", + "heim": "SV Motschenbach 1", + "gast": "TSV Neudrossenfeld 1" + }, + { + "nr": 2, + "zeit": "12:15", + "heim": "VfR Katschenreuth 1", + "gast": "SV Motschenbach 1" + }, + { + "nr": 3, + "zeit": "12:30", + "heim": "TSV Neudrossenfeld 1", + "gast": "VfR Katschenreuth 1" + }, + { + "nr": 4, + "zeit": "12:45", + "heim": "TSV Neudrossenfeld 1", + "gast": "SV Motschenbach 1" + }, + { + "nr": 5, + "zeit": "13:00", + "heim": "SV Motschenbach 1", + "gast": "VfR Katschenreuth 1" + }, + { + "nr": 6, + "zeit": "13:15", + "heim": "VfR Katschenreuth 1", + "gast": "TSV Neudrossenfeld 1" + } + ] + }, + { + "key": "f2", + "title": "F2-Jugend", + "date": "2026-09-05", + "start": "12:00", + "spieldauer": 15, + "modus": "Jeder gegen jeden", + "teams": [ + "TSV 08 Kulmbach", + "SG Neuenmarkt", + "VfR Katschenreuth 2", + "SV Motschenbach 2" + ], + "spiele": [ + { + "nr": 1, + "zeit": "12:00", + "heim": "SG Neuenmarkt", + "gast": "SV Motschenbach 2" + }, + { + "nr": 2, + "zeit": "12:16", + "heim": "TSV 08 Kulmbach", + "gast": "VfR Katschenreuth 2" + }, + { + "nr": 3, + "zeit": "12:32", + "heim": "SG Neuenmarkt", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 4, + "zeit": "12:48", + "heim": "VfR Katschenreuth 2", + "gast": "SV Motschenbach 2" + }, + { + "nr": 5, + "zeit": "13:04", + "heim": "SV Motschenbach 2", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 6, + "zeit": "13:20", + "heim": "VfR Katschenreuth 2", + "gast": "SG Neuenmarkt" + } + ] + }, + { + "key": "d1", + "title": "D1-Jugend", + "date": "2026-09-05", + "start": "15:00", + "spieldauer": 35, + "modus": "Jeder gegen jeden", + "teams": [ + "TSV 08 Kulmbach", + "JFG Maintal/Friesenbachtal", + "VfB Kulmbach" + ], + "spiele": [ + { + "nr": 1, + "zeit": "15:00", + "heim": "TSV 08 Kulmbach", + "gast": "JFG Maintal/Friesenbachtal" + }, + { + "nr": 2, + "zeit": "15:45", + "heim": "JFG Maintal/Friesenbachtal", + "gast": "VfB Kulmbach" + }, + { + "nr": 3, + "zeit": "16:30", + "heim": "VfB Kulmbach", + "gast": "TSV 08 Kulmbach" + } + ] + }, + { + "key": "e1", + "title": "E1-Jugend", + "date": "2026-09-06", + "start": "13:00", + "spieldauer": 12, + "felder": 2, + "modus": "Jeder gegen jeden", + "teams": [ + "TSV 08 Kulmbach", + "SV Motschenbach 2", + "VfB Kulmbach 2", + "VfR Katschenreuth 2", + "SG Wirsberg" + ], + "spiele": [ + { + "nr": 1, + "zeit": "13:00", + "feld": 1, + "heim": "TSV 08 Kulmbach", + "gast": "SG Wirsberg" + }, + { + "nr": 2, + "zeit": "13:00", + "feld": 2, + "heim": "SV Motschenbach 2", + "gast": "VfB Kulmbach 2" + }, + { + "nr": 3, + "zeit": "13:16", + "feld": 1, + "heim": "VfR Katschenreuth 2", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 4, + "zeit": "13:16", + "feld": 2, + "heim": "VfB Kulmbach 2", + "gast": "SG Wirsberg" + }, + { + "nr": 5, + "zeit": "13:32", + "feld": 1, + "heim": "VfR Katschenreuth 2", + "gast": "SV Motschenbach 2" + }, + { + "nr": 6, + "zeit": "13:32", + "feld": 2, + "heim": "VfB Kulmbach 2", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 7, + "zeit": "13:48", + "feld": 1, + "heim": "SG Wirsberg", + "gast": "VfR Katschenreuth 2" + }, + { + "nr": 8, + "zeit": "13:48", + "feld": 2, + "heim": "TSV 08 Kulmbach", + "gast": "SV Motschenbach 2" + }, + { + "nr": 9, + "zeit": "14:04", + "feld": 1, + "heim": "VfR Katschenreuth 2", + "gast": "VfB Kulmbach 2" + }, + { + "nr": 10, + "zeit": "14:04", + "feld": 2, + "heim": "SV Motschenbach 2", + "gast": "SG Wirsberg" + } + ] + } + ], + "plakate": [ + { + "base": "img/veranstaltungen/sportfest-2026/plakat-01", + "widths": [ + 640, + 1000, + 1587 + ], + "alt": "Plakat zum Sportfest 2026 mit Luftbild unserer Sportanlage und den Terminen vom 4. bis 6. September", + "source": "1-ankuendigung.png" + }, + { + "base": "img/veranstaltungen/sportfest-2026/plakat-02", + "widths": [ + 640, + 1000, + 1587 + ], + "alt": "Programmplakat zum Sportfest 2026 mit allen Spielen, Turnieren und der Kinderolympiade am Samstag", + "source": "2-programm.png" + } + ], + "galerie": [] + } + }, + { + "date": "2026-09-05", + "date_end": null, + "title": "(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II – (SG 2) SV Weidenberg III/SSV Warmensteinach II (flex)", + "department": "fussball", + "type": "spiel", + "meta": "Heimspiel · Reserve BK 5 BT-KU", + "href": "/matchcenter", + "kickoff": "2026-09-05T18:00", + "team_name": "2. Mannschaft", + "league": "Reserve BK 5 BT-KU", + "raw": { + "kickoff": "2026-09-05T18:00", + "date_display": "Sa, 05.09.", + "time_display": "18:00 Uhr", + "competition": "Liga", + "home": "(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II", + "away": "(SG 2) SV Weidenberg III/SSV Warmensteinach II (flex)", + "home_crest": "img/crests/00ES8GNK4O000002VV0AG08LVUPGND5I.png", + "away_crest": "img/crests/00ES8GNK4O000013VV0AG08LVUPGND5I.png", + "is_home": true, + "team_key": "zweite-mannschaft", + "team_name": "2. Mannschaft" + } + }, + { + "date": "2026-09-06", + "date_end": null, + "title": "TSV 08 Kulmbach – 1. FC 1928 Schwarzenbach/Saale", + "department": "fussball", + "type": "spiel", + "meta": "Heimspiel · Frauen KL Ost", + "href": "/matchcenter", + "kickoff": "2026-09-06T11:00", + "team_name": "Frauenmannschaft", + "league": "Frauen KL Ost", + "raw": { + "kickoff": "2026-09-06T11:00", + "date_display": "So, 06.09.", + "time_display": "11:00 Uhr", + "competition": "Liga", + "home": "TSV 08 Kulmbach", + "away": "1. FC 1928 Schwarzenbach/Saale", + "home_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png", + "away_crest": "img/crests/00ES8GNK9O00000QVV0AG08LVUPGND5I.png", + "is_home": true, + "team_key": "damen", + "team_name": "Frauenmannschaft" + } + }, + { + "date": "2026-09-13", + "date_end": null, + "title": "TSV 08 Kulmbach – TSV Engelmannsreuth", + "department": "fussball", + "type": "spiel", + "meta": "Heimspiel · Frauen KL Ost", + "href": "/matchcenter", + "kickoff": "2026-09-13T10:30", + "team_name": "Frauenmannschaft", + "league": "Frauen KL Ost", + "raw": { + "kickoff": "2026-09-13T10:30", + "date_display": "So, 13.09.", + "time_display": "10:30 Uhr", + "competition": "Liga", + "home": "TSV 08 Kulmbach", + "away": "TSV Engelmannsreuth", + "home_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png", + "away_crest": "img/crests/00ES8GNK3G000009VV0AG08LVUPGND5I.png", + "is_home": true, + "team_key": "damen", + "team_name": "Frauenmannschaft" + } + }, + { + "date": "2026-09-13", + "date_end": null, + "title": "SV Lindenhardt II flex – (SG1) TSV Melkendorf III/ TSV 08 Kulmbach II", + "department": "fussball", + "type": "spiel", + "meta": "Auswärtsspiel · Reserve BK 5 BT-KU", + "href": "/matchcenter", + "kickoff": "2026-09-13T13:00", + "team_name": "2. Mannschaft", + "league": "Reserve BK 5 BT-KU", + "raw": { + "kickoff": "2026-09-13T13:00", + "date_display": "So, 13.09.", + "time_display": "13:00 Uhr", + "competition": "Liga", + "home": "SV Lindenhardt II flex", + "away": "(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II", + "home_crest": "img/crests/00ES8GNK3G00001AVV0AG08LVUPGND5I.png", + "away_crest": "img/crests/00ES8GNK4O000002VV0AG08LVUPGND5I.png", + "is_home": false, + "team_key": "zweite-mannschaft", + "team_name": "2. Mannschaft" + } + } + ], + "images": [ + { + "type": "live", + "page": "cover" + }, + { + "type": "live", + "page": "vorwort" + }, + { + "type": "live", + "page": "programm" + }, + { + "type": "image", + "base": "img/anzeigen/eldorado", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: eldorado Radsporttechnik Kulmbach" + }, + { + "type": "live", + "page": "turnier", + "turnier": "f1" + }, + { + "type": "live", + "page": "turnier", + "turnier": "f2" + }, + { + "type": "live", + "page": "turnier", + "turnier": "d1" + }, + { + "type": "live", + "page": "turnier", + "turnier": "e1" + }, + { + "type": "image", + "base": "img/anzeigen/egu", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Erfrischungsgetränke-Union Kulmbacher Gruppe GmbH" + }, + { + "type": "image", + "base": "img/anzeigen/hofmann-graef", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Buchhandlung Hofmann, Kulmbach, und Automobilwerkstatt Gräf, Thurnau" + }, + { + "type": "live", + "page": "ergebnisse" + }, + { + "type": "image", + "base": "img/anzeigen/muench-richterstegner", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Planungsbüro Münch und Richter & Stegner Steuerungstechnik" + }, + { + "type": "live", + "page": "gegner", + "team": "erste-mannschaft" + }, + { + "type": "live", + "page": "gegner", + "team": "zweite-mannschaft" + }, + { + "type": "live", + "page": "gegner", + "team": "damen" + }, + { + "type": "image", + "base": "img/anzeigen/malteser-herrmann", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Malteser-Hilfsdienst und Schlosserei Herrmann, Kulmbach" + }, + { + "type": "image", + "base": "img/anzeigen/apotheken-wenkel", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Apotheke im Fritz, Stadtpark Apotheke und Schlüsseldienst Wenkel, Kulmbach" + }, + { + "type": "live", + "page": "tabelle", + "team": "erste-mannschaft" + }, + { + "type": "live", + "page": "tabelle", + "team": "zweite-mannschaft" + }, + { + "type": "live", + "page": "tabelle", + "team": "damen" + }, + { + "type": "image", + "base": "img/anzeigen/karl-geyer", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Geyer Farbe + Raum, Kulmbach, Starthilfe Boden" + }, + { + "type": "live", + "page": "vorschau" + }, + { + "type": "image", + "base": "img/anzeigen/gsb", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: GSB Security und GSB Gebäude Service, Bindlach" + }, + { + "type": "image", + "base": "img/anzeigen/physiofit-wohlfart", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Physio Fit Neudrossenfeld und Textilpflege Wohlfart, Kulmbach" + }, + { + "type": "live", + "page": "termine" + }, + { + "type": "image", + "base": "img/anzeigen/sport2000-pizza", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Sport 2000 Downstairs und Kulmbacher Pizza-Service" + }, + { + "type": "image", + "base": "img/anzeigen/schulz-csu", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Schreinerei Schulz, Mainleus, und CSU Stadtverband Kulmbach" + }, + { + "type": "live", + "page": "news" + }, + { + "type": "image", + "base": "img/anzeigen/mattis-ka2", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Karl Mattis Flaschnerei und KA2 Fitness, Kulmbach" + }, + { + "type": "live", + "page": "kontakt" + }, + { + "type": "image", + "base": "img/anzeigen/motor-nuetzel", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Motor-Nützel Vertriebs-GmbH, Bayreuth" + }, + { + "type": "image", + "base": "img/anzeigen/vereinskarte-edeka", + "widths": [ + 480, + 960, + 1410 + ], + "alt": "Eigenanzeige: So funktioniert die Vereinskarte beim E center Seidl Kulmbach" + }, + { + "type": "live", + "page": "mitglied" + }, + { + "type": "image", + "base": "img/anzeigen/trapper-verkehrsakademie", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Trapper GmbH Entsorgungsfachbetrieb und Verkehrsakademie, Kulmbach" + }, + { + "type": "live", + "page": "historie" + }, + { + "type": "image", + "base": "img/anzeigen/ohnemueller-dippold", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Gaststätte und Metzgerei Ohne-Müller, Kulmbach-Weiher, und Bäckerei Dippold, Melkendorf" + }, + { + "type": "image", + "base": "img/anzeigen/hanft", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Hanft Bürotechnik GmbH, Kulmbach" + }, + { + "type": "live", + "page": "sportheim" + }, + { + "type": "image", + "base": "img/anzeigen/elma-kleinheinz", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: elma GmbH Bayreuth und Metzgerei Kleinheinz, Kulmbach" + }, + { + "type": "live", + "page": "partner" + }, + { + "type": "live", + "page": "danke", + "anzeige": { + "base": "img/anzeigen/emons", + "widths": [ + 480, + 960, + 1141 + ], + "alt": "Anzeige: Emons Spedition Himmelkron, Ihr Partner für nationale und internationale Transporte" + } + }, + { + "type": "image", + "base": "img/anzeigen/stadionzeitung-digital", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Eigenanzeige: Unsere Stadionzeitung gibt es jetzt auch online, mit QR-Code zum Scannen" + }, + { + "type": "image", + "base": "img/anzeigen/gagel-pickelmann", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Gagel Bäder und Mineralölhandel Pickelmann" + }, + { + "type": "live", + "page": "momente" + }, + { + "type": "image", + "base": "img/anzeigen/schramm", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Anzeige: Landgasthof und Metzgerei Schramm, Forstlahm" + }, + { + "type": "image", + "base": "img/anzeigen/tagesskifahrt-2027", + "widths": [ + 480, + 960, + 1440 + ], + "alt": "Eigenanzeige: Unsere Tagesskifahrt ins Hochzillertal am 20. Februar 2027" + }, + { + "type": "live", + "page": "impressum" + }, + { + "type": "image", + "base": "img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite", + "widths": [ + 480, + 960, + 1410 + ], + "alt": "Rückseite der Stadionzeitung gegen ATS Wartenfels" + } + ] + }, { "slug": "2026-08-29-asv-marktschorgast", "opponent": "ASV Marktschorgast", diff --git a/data/veranstaltungen.json b/data/veranstaltungen.json index 3bb7797..0b9202e 100644 --- a/data/veranstaltungen.json +++ b/data/veranstaltungen.json @@ -45,6 +45,301 @@ "rueckblick": null, "karte": "club", "anfahrt": [], + "programm": [ + { + "tag": "2026-09-04", + "punkte": [ + { + "zeit": "16:30", + "titel": "Spiel unserer E2-Jugend" + }, + { + "zeit": "18:00", + "titel": "Unsere erste Mannschaft gegen ATS Wartenfels", + "text": "Kreisklasse" + } + ] + }, + { + "tag": "2026-09-05", + "punkte": [ + { + "zeit": "09:00", + "titel": "Turnier der G-Jugend" + }, + { + "zeit": "12:00", + "titel": "Turnier der F1-Jugend" + }, + { + "zeit": "12:00", + "titel": "Turnier der F2-Jugend" + }, + { + "zeit": "14:30", + "titel": "Kinderolympiade", + "text": "bis 16:30 Uhr in der Turnhalle Maximum Gym" + }, + { + "zeit": "15:00", + "titel": "Turnier der D1-Jugend" + }, + { + "zeit": "18:00", + "titel": "Unsere zweite Mannschaft gegen SV Weidenberg III/SSV Warmensteinach II", + "text": "Reserve" + } + ] + }, + { + "tag": "2026-09-06", + "punkte": [ + { + "zeit": "11:00", + "titel": "Unsere Frauenmannschaft gegen 1. FC 1928 Schwarzenbach/Saale", + "text": "Kreisliga Ost" + }, + { + "zeit": "13:00", + "titel": "Turnier der E1-Jugend" + }, + { + "zeit": "15:00", + "titel": "Spiel unserer D2-Jugend" + }, + { + "zeit": "16:30", + "titel": "Spiel unserer C/B-Jugend" + } + ] + } + ], + "turniere": [ + { + "key": "f1", + "title": "F1-Jugend", + "date": "2026-09-05", + "start": "12:00", + "spieldauer": 10, + "modus": "Jeder gegen jeden mit Hin und Rückspiel", + "teams": [ + "VfR Katschenreuth 1", + "SV Motschenbach 1", + "TSV Neudrossenfeld 1" + ], + "spiele": [ + { + "nr": 1, + "zeit": "12:00", + "heim": "SV Motschenbach 1", + "gast": "TSV Neudrossenfeld 1" + }, + { + "nr": 2, + "zeit": "12:15", + "heim": "VfR Katschenreuth 1", + "gast": "SV Motschenbach 1" + }, + { + "nr": 3, + "zeit": "12:30", + "heim": "TSV Neudrossenfeld 1", + "gast": "VfR Katschenreuth 1" + }, + { + "nr": 4, + "zeit": "12:45", + "heim": "TSV Neudrossenfeld 1", + "gast": "SV Motschenbach 1" + }, + { + "nr": 5, + "zeit": "13:00", + "heim": "SV Motschenbach 1", + "gast": "VfR Katschenreuth 1" + }, + { + "nr": 6, + "zeit": "13:15", + "heim": "VfR Katschenreuth 1", + "gast": "TSV Neudrossenfeld 1" + } + ] + }, + { + "key": "f2", + "title": "F2-Jugend", + "date": "2026-09-05", + "start": "12:00", + "spieldauer": 15, + "modus": "Jeder gegen jeden", + "teams": [ + "TSV 08 Kulmbach", + "SG Neuenmarkt", + "VfR Katschenreuth 2", + "SV Motschenbach 2" + ], + "spiele": [ + { + "nr": 1, + "zeit": "12:00", + "heim": "SG Neuenmarkt", + "gast": "SV Motschenbach 2" + }, + { + "nr": 2, + "zeit": "12:16", + "heim": "TSV 08 Kulmbach", + "gast": "VfR Katschenreuth 2" + }, + { + "nr": 3, + "zeit": "12:32", + "heim": "SG Neuenmarkt", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 4, + "zeit": "12:48", + "heim": "VfR Katschenreuth 2", + "gast": "SV Motschenbach 2" + }, + { + "nr": 5, + "zeit": "13:04", + "heim": "SV Motschenbach 2", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 6, + "zeit": "13:20", + "heim": "VfR Katschenreuth 2", + "gast": "SG Neuenmarkt" + } + ] + }, + { + "key": "d1", + "title": "D1-Jugend", + "date": "2026-09-05", + "start": "15:00", + "spieldauer": 35, + "modus": "Jeder gegen jeden", + "teams": [ + "TSV 08 Kulmbach", + "JFG Maintal/Friesenbachtal", + "VfB Kulmbach" + ], + "spiele": [ + { + "nr": 1, + "zeit": "15:00", + "heim": "TSV 08 Kulmbach", + "gast": "JFG Maintal/Friesenbachtal" + }, + { + "nr": 2, + "zeit": "15:45", + "heim": "JFG Maintal/Friesenbachtal", + "gast": "VfB Kulmbach" + }, + { + "nr": 3, + "zeit": "16:30", + "heim": "VfB Kulmbach", + "gast": "TSV 08 Kulmbach" + } + ] + }, + { + "key": "e1", + "title": "E1-Jugend", + "date": "2026-09-06", + "start": "13:00", + "spieldauer": 12, + "felder": 2, + "modus": "Jeder gegen jeden", + "teams": [ + "TSV 08 Kulmbach", + "SV Motschenbach 2", + "VfB Kulmbach 2", + "VfR Katschenreuth 2", + "SG Wirsberg" + ], + "spiele": [ + { + "nr": 1, + "zeit": "13:00", + "feld": 1, + "heim": "TSV 08 Kulmbach", + "gast": "SG Wirsberg" + }, + { + "nr": 2, + "zeit": "13:00", + "feld": 2, + "heim": "SV Motschenbach 2", + "gast": "VfB Kulmbach 2" + }, + { + "nr": 3, + "zeit": "13:16", + "feld": 1, + "heim": "VfR Katschenreuth 2", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 4, + "zeit": "13:16", + "feld": 2, + "heim": "VfB Kulmbach 2", + "gast": "SG Wirsberg" + }, + { + "nr": 5, + "zeit": "13:32", + "feld": 1, + "heim": "VfR Katschenreuth 2", + "gast": "SV Motschenbach 2" + }, + { + "nr": 6, + "zeit": "13:32", + "feld": 2, + "heim": "VfB Kulmbach 2", + "gast": "TSV 08 Kulmbach" + }, + { + "nr": 7, + "zeit": "13:48", + "feld": 1, + "heim": "SG Wirsberg", + "gast": "VfR Katschenreuth 2" + }, + { + "nr": 8, + "zeit": "13:48", + "feld": 2, + "heim": "TSV 08 Kulmbach", + "gast": "SV Motschenbach 2" + }, + { + "nr": 9, + "zeit": "14:04", + "feld": 1, + "heim": "VfR Katschenreuth 2", + "gast": "VfB Kulmbach 2" + }, + { + "nr": 10, + "zeit": "14:04", + "feld": 2, + "heim": "SV Motschenbach 2", + "gast": "SG Wirsberg" + } + ] + } + ], "plakate": [ { "base": "img/veranstaltungen/sportfest-2026/plakat-01", @@ -370,4 +665,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/public/assets/css/components.css b/public/assets/css/components.css index 5457dbc..b204b90 100644 --- a/public/assets/css/components.css +++ b/public/assets/css/components.css @@ -6097,6 +6097,25 @@ body.is-sz-full .back-to-top { } +/* Titelgeschichte einer Sonderausgabe: das Fest statt der Begegnung. Coolvetica + in Versalien wie der Masthead, aber kleiner — es soll die zweite Stimme auf + dem Cover sein, nicht mit der Wortmarke konkurrieren. */ +.stadionzeitung-cover__feature { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1.7cqi; + text-align: left; +} + +.stadionzeitung-cover__feature-title { + font-family: var(--ff-heading); + font-size: 13cqi; + line-height: 0.88; + text-transform: uppercase; + text-shadow: 0 2px 10px rgb(0 0 0 / 0.5); +} + /* Vereins-Hero-Bild als Hintergrund (dieselbe Quelle wie die Startseite). */ .stadionzeitung-cover__bg { position: absolute; @@ -6135,6 +6154,63 @@ body.is-sz-full .back-to-top { width: auto; } +/* Hauptplakat einer Sonderausgabe an der Stelle des Spielerfotos: rechts + randabfallend, leicht schräg gelegt und an zwei gegenüberliegenden Ecken + gerundet (links oben, rechts unten) — wie ein aufs Cover gelegter Aushang, + nicht wie ein eingesetztes Bild (Felix, 03.09.2026). + + Warum kleiner als das Spielerfoto (98cqi hoch): ein Freisteller ist schmal, + ein Plakat im Hochformat ist bei gleicher Höhe knapp 60cqi breit und läge damit + über der Typografie unten links. Bei 66cqi Höhe belegt es die rechte Bildhälfte + und lässt Masthead und Fest-Titel frei — die Textspalte wird dafür schmaler + (siehe --poster .stadionzeitung-cover__corner). + + Schatten statt Rahmen, damit das Plakat auf dem Foto-Hintergrund abhebt: + box-shadow ist unbedenklich, die Druck-Falle im Projekt betrifft text-shadow + auf umbrechendem Text (siehe .stadionzeitung-cover__corner). */ +.stadionzeitung-cover__poster { + position: absolute; + z-index: 1; + /* VOLLSTÄNDIG im Bild, nicht randabfallend wie das Spielerfoto: ein Plakat + trägt Text (Datum, Ort), und angeschnitten fehlte rechts genau der. Die + Rotation lässt die Ecken trotzdem über die Passepartout-Linie laufen, das + Plakat liegt also sichtbar „auf" der Seite. */ + /* Unten rechts randabfallend („angeschnitten", Felix 03.09.2026): das Plakat + läuft über zwei Kanten hinaus und wirkt dadurch aufgelegt statt eingesetzt. + Angeschnitten wird nur die untere rechte Ecke, nicht die Kopfzeile mit den + Terminen — die bleibt lesbar. */ + right: -1.5cqi; + /* bottom: 0 statt negativ — die Box endet an der Blattkante, angeschnitten + wird nur noch die gedrehte Ecke (4 Grad auf 53cqi Breite = knapp 4cqi + Überhang). Das hält den Anschnitt-Look und rückt das Plakat gleichzeitig + aus dem unteren Bereich nach oben (Felix, 03.09.2026). */ + bottom: 0; + /* Höhe ist NICHT frei wählbar: das Plakat darf die Textspalte links nicht + berühren. Rechnung bei 4 Grad Drehung — belegte Breite = Höhe / 1,414 + (Hochformat) + sin(4°) × Höhe (Überhang der gedrehten Ecken) ≈ 0,78 × Höhe. + Bei 72cqi sind das 56,2cqi; mit 1,5cqi Überhang rechts liegt die linke Kante + bei 45,3% und die Textspalte endet bei 44%. Der Überhang ist bewusst winzig: + vom Plakat bleiben so 97% sichtbar und seine Schlagzeile „Sportfest" steht + ganz im Bild (Felix, 03.09.2026) — bei 9cqi Überhang fehlte rechts ein + Siebtel und das Wort lief aus dem Bild. Angeschnitten wirkt es trotzdem, + das macht die gedrehte Ecke unten. Höhe und Überhang hängen also + zusammen: weiter nach links (kleinerer Überhang) heißt kleiner, größer + heißt weiter nach rechts — oder die Textspalte muss schmaler + (--poster .stadionzeitung-cover__corner). Nicht nur eine Zahl drehen. */ + height: 72cqi; + width: auto; + border-radius: 5cqi 0 5cqi 0; + transform: rotate(4deg); + box-shadow: 0 3cqi 6cqi rgb(0 0 0 / 0.45); +} + +/* Schmaler als das Standard-Cover (56cqi): das Plakat belegt die rechte Hälfte. + „Sportfest 2026" bricht dadurch zweizeilig — auf einem Cover trägt das eher, + als es schadet. */ +.stadionzeitung-cover--poster .stadionzeitung-cover__corner { + max-width: 38cqi; +} + .stadionzeitung-cover__inner { position: relative; z-index: 2; @@ -7452,6 +7528,225 @@ a.news-feature__link:hover .news-feature__more { color: var(--clr-accent); } +/* === stadionzeitung-programm (Live-Seite, Sportfest-Ausgabe) === */ +/* Tagesprogramm aus data/veranstaltungen.json → programm[] (dieselbe Liste wie + die Website-Detailseite, hier fürs A5-Blatt gesetzt): je Tag ein Abel-Caps- + Tagestitel, darunter Zeit | Titel. Die Zeit trägt links den roten Strich wie + .veranstaltung-programm__zeit auf der Website — gleiche Sprache, andere + Skala. Maße in cqi (Selbst-Containment, siehe .stadionzeitung-page). */ +/* Abstände bewusst straff: ein dreitägiges Fest bringt gut zwölf Punkte plus + drei Tagestitel auf EIN A5-Blatt (gemessen: mit den üblichen 3.3cqi lief die + Seite 59px über die Blattkante). Wer hier lockerer setzt, misst die + Seitenhöhe nach. */ +.stadionzeitung-programm__tag { + margin-bottom: 2.2cqi; +} + +.stadionzeitung-programm__tag:last-child { + margin-bottom: 0; +} + +.stadionzeitung-programm__tag-titel { + margin-bottom: 0.9cqi; + padding-bottom: 0.6cqi; + border-bottom: 1px solid var(--clr-glass-border); + font-family: var(--ff-body); + font-weight: var(--fw-bold); + font-size: 2.9cqi; + text-transform: uppercase; + letter-spacing: var(--ls-caps); + color: var(--clr-text-muted); +} + +.stadionzeitung-programm__liste { + margin: 0; + padding: 0; + list-style: none; +} + +.stadionzeitung-programm__punkt { + display: grid; + grid-template-columns: 11cqi minmax(0, 1fr); + gap: 2.2cqi; + align-items: baseline; + padding-block: 0.5cqi; +} + +.stadionzeitung-programm__zeit { + padding-left: 1.4cqi; + border-left: 2px solid var(--clr-accent); + font-family: var(--ff-body); + font-weight: var(--fw-bold); + font-size: 3.3cqi; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.stadionzeitung-programm__titel { + display: block; + font-size: 3.3cqi; + line-height: 1.3; +} + +.stadionzeitung-programm__text { + display: block; + margin-top: 0.3cqi; + font-size: 2.7cqi; + line-height: 1.3; + color: var(--clr-text-muted); +} + +/* === stadionzeitung-turnier (Live-Seite, Sportfest-Ausgabe) === */ +/* Ein Jugendturnier pro Blatt: Spielplan mit LEERER Ergebnisspalte + (Schreiblinien) und leere Abschlusstabelle — das Blatt ist zum Eintragen am + Platz gedacht, deshalb echte Schreibhöhe in den leeren Zellen. Eigenes Team + wie league-table tr.is-own (rote Tönung + Abel-Fett). Tabellenmaße an + .stadionzeitung-page .league-table angelehnt (2.7/2.3cqi). Der Modifier + --dicht (ab neun Spielen, E-Jugend) rückt die Zeilen enger, sonst läuft die + Abschlusstabelle über die Blattkante. */ +.stadionzeitung-turnier__label { + caption-side: top; + text-align: left; + margin: 3.3cqi 0 1.2cqi; + font-family: var(--ff-body); + font-weight: var(--fw-bold); + font-size: 2.9cqi; + text-transform: uppercase; + letter-spacing: var(--ls-caps); + color: var(--clr-text-muted); +} + +p.stadionzeitung-turnier__label:first-of-type { + margin-top: 0; +} + +.stadionzeitung-turnier__teams { + font-size: 3.1cqi; + line-height: 1.5; +} + +.stadionzeitung-turnier__spiele, +.stadionzeitung-turnier__tabelle { + width: 100%; + border-collapse: collapse; + font-size: 2.9cqi; + font-variant-numeric: tabular-nums; +} + +.stadionzeitung-turnier__spiele thead th, +.stadionzeitung-turnier__tabelle thead th { + padding: 0.8cqi 0.9cqi; + border-bottom: 1px solid var(--clr-glass-border-strong); + font-family: var(--ff-body); + font-weight: var(--fw-bold); + font-size: 2.3cqi; + text-transform: uppercase; + letter-spacing: var(--ls-caps); + color: var(--clr-text-muted); + text-align: left; +} + +.stadionzeitung-turnier__spiele td, +.stadionzeitung-turnier__tabelle td { + padding: 1cqi 0.9cqi; + border-bottom: 1px solid var(--clr-glass-border); + vertical-align: middle; + line-height: 1.25; +} + +/* Dichte Variante ab neun Spielen: die E-Jugend spielt zehn Partien auf zwei + Feldern und braucht darunter noch eine Tabelle mit fünf Zeilen. Mit den + normalen Abständen lief das Blatt 69px über. Alle vier Stellen unten gehören + zusammen — eine allein reicht nicht. */ +.stadionzeitung-turnier--dicht .stadionzeitung-turnier__spiele td { + padding-block: 0.3cqi; +} + +/* Die Zeilenhöhe des Spielplans macht NICHT die Schriftgröße, sondern die + Schreiblinie: sie ist das höchste Element in der Zeile. Bei zehn Spielen + zählt deshalb nur diese Stelle (eine kleinere Schrift brachte gemessen + einen einzigen Pixel). 2.8cqi sind auf A5 gut 4mm über der Linie, genug + für zwei Ziffern mit dem Stift. */ +.stadionzeitung-turnier--dicht .stadionzeitung-turnier__blank { + height: 2.8cqi; +} + +.stadionzeitung-turnier--dicht .stadionzeitung-turnier__tabelle td { + padding-block: 0.6cqi; +} + +/* 4cqi Zellhöhe sind auf A5 knapp 6mm plus Innenabstand — genug, um Tore und + Punkte mit dem Stift einzutragen, und es passt neben zehn Spielzeilen. */ +.stadionzeitung-turnier--dicht .stadionzeitung-turnier__tabelle td:empty { + height: 4cqi; +} + +.stadionzeitung-turnier--dicht .stadionzeitung-turnier__label { + margin: 1.8cqi 0 0.7cqi; +} + +.stadionzeitung-turnier--dicht .stadionzeitung-turnier__teams { + font-size: 2.9cqi; + line-height: 1.35; +} + +.stadionzeitung-turnier__nr { + width: 6cqi; + color: var(--clr-text-muted); +} + +.stadionzeitung-turnier__zeit { + width: 9cqi; + white-space: nowrap; +} + +.stadionzeitung-turnier__paarung { + width: 100%; +} + +.stadionzeitung-turnier__vs { + color: var(--clr-text-muted); +} + +.stadionzeitung-turnier__team.is-own { + padding-inline: 0.6cqi; + border-radius: 0.6cqi; + background: rgb(var(--clr-accent-rgb) / 0.16); + font-weight: var(--fw-bold); +} + +/* Leere Ergebnisspalte: zwei Schreiblinien um den Doppelpunkt, unten + ausgerichtet wie eine Zeile zum Ausfüllen. */ +.stadionzeitung-turnier__ergebnis { + white-space: nowrap; + text-align: center; +} + +.stadionzeitung-turnier__blank { + display: inline-block; + width: 6cqi; + height: 3.4cqi; + border-bottom: 1px solid var(--clr-text-faint); + vertical-align: baseline; +} + +.stadionzeitung-turnier__colon { + display: inline-block; + padding-inline: 0.8cqi; + font-weight: var(--fw-bold); +} + +/* Schreibhöhe der leeren Tabellenzellen (Platzierung wird von Hand eingetragen). */ +.stadionzeitung-turnier__tabelle td:empty { + height: 5.6cqi; +} + +.stadionzeitung-turnier__tabelle th:nth-child(n + 3), +.stadionzeitung-turnier__tabelle td:nth-child(n + 3) { + width: 9cqi; + text-align: center; +} + /* === stadionzeitung-news (Live-Seite) === */ /* Eigene Liste statt des geteilten news-feed von /news: feste Medienspalte (Wappen/Foto/Icon-Badge in EINER Größe), damit alle Titel bündig stehen — @@ -7864,6 +8159,57 @@ a.news-feature__link:hover .news-feature__more { color: var(--clr-text); } +/* === stadionzeitung-danke (Live-Seite) === */ +/* Dank an die Partner: oben die platzierte Anzeige (Motiv aus dem Seiteneintrag, + siehe stadionzeitung-danke.php), darunter Kopf, Logo-Wand und der QR auf + /partner-werden. Vier Logos pro Reihe in EINER festen Zellhöhe, damit + unterschiedlich hohe Logos auf einer Linie sitzen — dieselbe Idee wie die + feste Medienspalte auf der News-Seite. Maße in cqi (Selbst-Containment). */ +.stadionzeitung-danke { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.stadionzeitung-danke__anzeige { + width: 100%; + height: auto; + margin-bottom: 5cqi; + border: 1px solid var(--clr-glass-border); +} + +.stadionzeitung-danke__head { + margin-bottom: 3.3cqi; +} + +.stadionzeitung-danke__logos { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3.3cqi 2.5cqi; + margin: 0 0 auto; + padding: 0; + list-style: none; +} + +.stadionzeitung-danke__logo { + display: flex; + align-items: center; + justify-content: center; + /* Feste Zellhöhe: Logos sind teils quer, teils hoch — ohne sie tanzt die Reihe. */ + height: 11cqi; +} + +/* Logos liegen hier auf Weiß statt auf dem dunklen Website-Grund: contain statt + cover, damit nichts beschnitten wird, und keine Tönung — ein Sponsorenlogo + wird nicht umgefärbt. */ +.stadionzeitung-danke__logo img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; +} + /* === stadionzeitung-momente (Live-Seite, Heft-Abschluss) === */ /* 2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-Ticket — quadratische Kacheln wie im Instagram-Raster, bewusst ohne Bildunterschriften: die diff --git a/public/assets/img/anzeigen/emons-1141.jpg b/public/assets/img/anzeigen/emons-1141.jpg new file mode 100644 index 0000000..7db2ecd Binary files /dev/null and b/public/assets/img/anzeigen/emons-1141.jpg differ diff --git a/public/assets/img/anzeigen/emons-480.jpg b/public/assets/img/anzeigen/emons-480.jpg new file mode 100644 index 0000000..27a13d6 Binary files /dev/null and b/public/assets/img/anzeigen/emons-480.jpg differ diff --git a/public/assets/img/anzeigen/emons-960.jpg b/public/assets/img/anzeigen/emons-960.jpg new file mode 100644 index 0000000..b5b7240 Binary files /dev/null and b/public/assets/img/anzeigen/emons-960.jpg differ diff --git a/public/assets/img/anzeigen/emons-partnerdank-1440.jpg b/public/assets/img/anzeigen/emons-partnerdank-1440.jpg deleted file mode 100644 index e03c069..0000000 Binary files a/public/assets/img/anzeigen/emons-partnerdank-1440.jpg and /dev/null differ diff --git a/public/assets/img/anzeigen/emons-partnerdank-480.jpg b/public/assets/img/anzeigen/emons-partnerdank-480.jpg deleted file mode 100644 index 770c24e..0000000 Binary files a/public/assets/img/anzeigen/emons-partnerdank-480.jpg and /dev/null differ diff --git a/public/assets/img/anzeigen/emons-partnerdank-960.jpg b/public/assets/img/anzeigen/emons-partnerdank-960.jpg deleted file mode 100644 index 7d0a418..0000000 Binary files a/public/assets/img/anzeigen/emons-partnerdank-960.jpg and /dev/null differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png new file mode 100644 index 0000000..92e5efd Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/qr.png differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-1410.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-1410.jpg new file mode 100644 index 0000000..4db91d9 Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-1410.jpg differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-480.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-480.jpg new file mode 100644 index 0000000..3288000 Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-480.jpg differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-960.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-960.jpg new file mode 100644 index 0000000..ccf82c9 Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/rueckseite-960.jpg differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-1000.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-1000.png new file mode 100644 index 0000000..c64c1a0 Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-1000.png differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-320.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-320.png new file mode 100644 index 0000000..6d807e2 Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-320.png differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-640.png b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-640.png new file mode 100644 index 0000000..cfcd852 Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/spieler-640.png differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-120.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-120.jpg new file mode 100644 index 0000000..0dd08af Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-120.jpg differ diff --git a/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-222.jpg b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-222.jpg new file mode 100644 index 0000000..aafa29d Binary files /dev/null and b/public/assets/img/stadionzeitung/2026-09-04-ats-wartenfels/verfasser-222.jpg differ diff --git a/public/assets/img/stadionzeitung/qr-historie.png b/public/assets/img/stadionzeitung/qr-historie.png index cb107e9..f32b602 100644 Binary files a/public/assets/img/stadionzeitung/qr-historie.png and b/public/assets/img/stadionzeitung/qr-historie.png differ diff --git a/public/assets/img/stadionzeitung/qr-instagram.png b/public/assets/img/stadionzeitung/qr-instagram.png index 28388ab..cbb26be 100644 Binary files a/public/assets/img/stadionzeitung/qr-instagram.png and b/public/assets/img/stadionzeitung/qr-instagram.png differ diff --git a/public/assets/img/stadionzeitung/qr-mitmachen.png b/public/assets/img/stadionzeitung/qr-mitmachen.png index adaf047..9f991db 100644 Binary files a/public/assets/img/stadionzeitung/qr-mitmachen.png and b/public/assets/img/stadionzeitung/qr-mitmachen.png differ diff --git a/public/assets/img/stadionzeitung/qr-news.png b/public/assets/img/stadionzeitung/qr-news.png index db6f20b..f17e231 100644 Binary files a/public/assets/img/stadionzeitung/qr-news.png and b/public/assets/img/stadionzeitung/qr-news.png differ diff --git a/public/assets/img/stadionzeitung/qr-partner-werden.png b/public/assets/img/stadionzeitung/qr-partner-werden.png index 941103b..f3528cb 100644 Binary files a/public/assets/img/stadionzeitung/qr-partner-werden.png and b/public/assets/img/stadionzeitung/qr-partner-werden.png differ diff --git a/public/assets/img/stadionzeitung/qr-sportheimbuchung.png b/public/assets/img/stadionzeitung/qr-sportheimbuchung.png index f44d290..f1d77e6 100644 Binary files a/public/assets/img/stadionzeitung/qr-sportheimbuchung.png and b/public/assets/img/stadionzeitung/qr-sportheimbuchung.png differ diff --git a/public/assets/img/stadionzeitung/qr-termine.png b/public/assets/img/stadionzeitung/qr-termine.png index 2d90275..cef1dce 100644 Binary files a/public/assets/img/stadionzeitung/qr-termine.png and b/public/assets/img/stadionzeitung/qr-termine.png differ