diff --git a/CLAUDE.md b/CLAUDE.md index 8b563ff..215b814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -548,6 +548,8 @@ Inhalt: Rand: Freistellungs-Exporte lassen oft viel leeren, transparenten Rand stehen, sonst wirkt der Spieler auf dem Cover kleiner als nötig) /verfasser.* optional, Porträtfoto für die Signatur im automatischen Vorwort +/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 `data/anzeigen.json`, gepflegt mit `php bin/anzeige-add.php ` (Dateiname = Slug, @@ -571,6 +573,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)] ⌷ [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 Mannschaft mit Heimspiel am Fest eine — EIN Block, automatisch aus deren Tabelle] ⌷ [je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine @@ -606,7 +609,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. Ein `images[]`-Eintrag hat `"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 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 @@ -819,6 +822,18 @@ chat-gepflegt und von `bin/stadionzeitung-add.php` über erneute Läufe durchget 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 `/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. + **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 diff --git a/app/components/stadionzeitung-galerie.php b/app/components/stadionzeitung-galerie.php new file mode 100644 index 0000000..2b97837 --- /dev/null +++ b/app/components/stadionzeitung-galerie.php @@ -0,0 +1,50 @@ +/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'] ?? '')); +?> +
+
+ +

+ +

+ +

+ +
+ +
+ $bild): ?> + $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', + ]); ?> + +
+ +
diff --git a/app/components/stadionzeitung-seite.php b/app/components/stadionzeitung-seite.php index e3fa035..176cfdb 100644 --- a/app/components/stadionzeitung-seite.php +++ b/app/components/stadionzeitung-seite.php @@ -11,7 +11,8 @@ declare(strict_types=1); * type 'image' (base/widths/alt), * type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier| * 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 * matchcenter.json; turnier mit turnier: Key aus * veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung), @@ -96,6 +97,9 @@ $type = $entry['type'] ?? 'image'; wie jede andere Anzeige in images[] eingefroren ist. */ ?> $entry['anzeige'] ?? null]); ?> + + $issue]); ?> + $data['momente'] ?? []]); ?> diff --git a/bin/preflight.php b/bin/preflight.php index 67c7ec1..6bb5ac3 100644 --- a/bin/preflight.php +++ b/bin/preflight.php @@ -386,6 +386,24 @@ 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) { + 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) --- // 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 9dab6ee..b6835ab 100644 --- a/bin/stadionzeitung-add.php +++ b/bin/stadionzeitung-add.php @@ -19,6 +19,9 @@ declare(strict_types=1); * /spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz) * fürs Cover — wird unverändert kopiert, nie über GD neu kodiert * /verfasser.* optional, Porträtfoto für die Vorwort-Signatur + * /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 * Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive @@ -157,6 +160,7 @@ $vorwort = $findOne('vorwort'); $rueckseite = $findOne('rueckseite'); $spieler = $findOne('spieler'); $verfasser = $findOne('verfasser'); +$galerieQuellen = $findAll('galerie'); // Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften // Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch // vorhandener anzeigen/-Unterordner wäre also wirkungslos — dann besser sagen, @@ -206,6 +210,9 @@ $oldCover = $oldIssue['cover'] ?? null; // 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 /galerie/ (siehe unten). +$oldGalerie = is_array($oldIssue['galerie'] ?? null) ? $oldIssue['galerie'] : null; $oldGegnerTeams = is_array($oldIssue['gegner_teams'] ?? null) ? array_values($oldIssue['gegner_teams']) : null; $veranstaltung = null; if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') { @@ -317,6 +324,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 * dazwischen. Ein Block ist eine Folge von Seiten, die zusammenbleiben MUSS; @@ -375,6 +437,12 @@ try { $bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], '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 // je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als // EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() — @@ -761,6 +829,7 @@ $issues[] = [ 'aufruf' => $oldAufruf, 'veranstaltung' => $oldVeranstaltung, 'cover' => $oldCover, + 'galerie' => $galerie, 'verfasser_photo' => $verfasserPhoto, 'termine_snapshot' => $termineSnapshot, 'images' => $images, diff --git a/public/assets/css/components.css b/public/assets/css/components.css index 5defef8..1020b00 100644 --- a/public/assets/css/components.css +++ b/public/assets/css/components.css @@ -8628,6 +8628,83 @@ p.stadionzeitung-turnier__label:first-of-type { object-fit: cover; } +/* === 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) === */ /* Bildschirm-Ansicht der Route ?druck=1: jedes Blatt als A4-quer-Vorschau (297:210) mit zwei A5-Seiten, in Broschüren-Reihenfolge (die Montage