Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung

Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
This commit is contained in:
2026-07-31 14:55:27 +02:00
parent 3fb480dd78
commit a101b0c3ec
861 changed files with 75244 additions and 487 deletions

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
/**
* Bilder einer Veranstaltung einarbeiten (nur CLI).
*
* php bin/veranstaltung-bilder.php <slug> <ordner> [-v]
*
* Erwarteter Ordnerinhalt (beides optional, mindestens eines nötig):
* <ordner>/plakate/*.{jpg|jpeg|png|webp} Plakate, natsort = Reihenfolge.
* Das ERSTE ist das Hauptplakat und
* landet auf Karten und im OG-Bild.
* <ordner>/bilder/*.{jpg|…} Fotos vom Fest, natsort = Reihenfolge
*
* DER SLUG MUSS SCHON IN data/veranstaltungen.json STEHEN. Umgekehrt zur
* Stadionzeitung (dort legt das Skript die Ausgabe an und der Chat reichert sie
* an) gilt hier: der Eintrag entsteht im Chat, das Skript reichert ihn an. Genau
* daraus folgt die Garantie, dass chat-gepflegte Felder nie verloren gehen —
* das Skript ändert am Eintrag ausschließlich `plakate` und `galerie` und lässt
* alles andere unangetastet (auch `status`: nachgelieferte Fotos sind kein Grund,
* eine veröffentlichte Seite offline zu nehmen — anders als bei
* bin/stadionzeitung-add.php, wo ersetzte Seiten den Stand neu prüfbar machen).
*
* DER ORDNER IST IMMER DIE VOLLSTÄNDIGE GALERIE. Ein erneuter Lauf ersetzt sie
* komplett (alte Dateien werden vorher gelöscht). Wer nur drei Fotos nachliefert,
* verliert die anderen zwanzig — der Ordner muss also alles enthalten. Diese
* Wahl ist Absicht: nur so lassen sich Fotos auch entfernen und umsortieren.
*
* Alt-Texte schreibt Claude im Chat, nicht dieses Skript. Damit sie einen
* erneuten Lauf überleben, trägt jeder Galerie-Eintrag `source` (den
* Originaldateinamen) — daran werden bestehende Alt-Texte wiedererkannt, auch
* wenn sich die Reihenfolge durch ein neues Foto verschiebt. Neue Fotos bekommen
* den Veranstaltungstitel als Platzhalter; das Skript nennt sie am Ende
* namentlich, damit die Texte in derselben Chat-Runde entstehen.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter($argv, static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
array_shift($args); // Skriptname
if (count($args) < 2) {
fwrite(STDERR, "Nutzung: php bin/veranstaltung-bilder.php <slug> <ordner> [-v]\n");
exit(1);
}
[$slug, $sourceDir] = $args;
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('veranstaltungen', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
exit(1);
};
// --- Eingaben validieren ---
if (!is_dir($sourceDir)) {
$fail("Ordner nicht gefunden: {$sourceDir}");
}
$dataFile = DATA_PATH . '/veranstaltungen.json';
$data = is_file($dataFile)
? (json_decode((string) file_get_contents($dataFile), true) ?: ['veranstaltungen' => []])
: ['veranstaltungen' => []];
$index = null;
foreach ($data['veranstaltungen'] ?? [] as $i => $entry) {
if (($entry['slug'] ?? '') === $slug) {
$index = $i;
break;
}
}
if ($index === null) {
$known = implode(', ', array_column($data['veranstaltungen'] ?? [], 'slug'));
$fail("Unbekannter Slug '{$slug}'. Der Eintrag muss zuerst in data/veranstaltungen.json angelegt werden. Bekannt: {$known}");
}
$veranstaltung = $data['veranstaltungen'][$index];
$titel = (string) ($veranstaltung['title'] ?? $slug);
// --- Quelldateien einsammeln ---
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$findAll = static function (string $subdir) use ($sourceDir, $exts): array {
$dir = rtrim($sourceDir, '/') . "/{$subdir}";
if (!is_dir($dir)) {
return [];
}
$files = glob("{$dir}/*.{{$exts}}", GLOB_BRACE) ?: [];
// natsort, damit IMG_9 vor IMG_10 kommt (strcmp würde umgekehrt sortieren).
natsort($files);
return array_values($files);
};
$plakatSources = $findAll('plakate');
$fotoSources = $findAll('bilder');
if ($plakatSources === [] && $fotoSources === []) {
$fail("Weder plakate/* noch bilder/* in {$sourceDir} gefunden.");
}
// --- Lock gegen parallele Läufe ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/veranstaltungen.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- Bestehende Alt-Texte sichern, bevor irgendetwas ersetzt wird ---
$altBySource = [];
foreach (array_merge($veranstaltung['plakate'] ?? [], $veranstaltung['galerie'] ?? []) as $bild) {
if (!empty($bild['source']) && !empty($bild['alt'])) {
$altBySource[(string) $bild['source']] = (string) $bild['alt'];
}
}
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Dateien wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/veranstaltungen/{$slug}";
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
} else {
foreach (array_merge(
glob("{$destDir}/plakat-*.jpg") ?: [],
glob("{$destDir}/bild-*.jpg") ?: []
) as $old) {
unlink($old);
}
}
// Plakate brauchen die große Fassung: sie tragen Text, und in der Großansicht
// will man Uhrzeiten lesen können. Fotos nicht — dort ist die Großansicht nur
// „größer ansehen", und die dritte Stufe kostet bei 40 Fotos mehr Platz als sie
// bringt (gemessen: über die Hälfte des gesamten Bildvolumens).
$plakatWidths = [640, 1000, 1600];
$fotoWidths = [640, 1200];
/**
* Eine Quelldatei in die responsiven Breiten rendern und den Bild-Eintrag für
* die JSON bauen. Die `widths` im Eintrag sind die TATSÄCHLICH erzeugten —
* resize_image_variants() skaliert nie hoch, eine kleine Quelle liefert also
* weniger Varianten und img.php bleibt ehrlich.
*/
$render = static function (string $src, string $destBase, string $relBase, string $alt, int $quality, array $widths) use ($fail): array {
try {
$variants = resize_image_variants($src, $destBase, $widths, $quality);
} catch (RuntimeException $e) {
$fail('Bild nicht verarbeitbar (' . basename($src) . '): ' . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
return ['base' => $relBase, 'widths' => $generated, 'alt' => $alt];
};
$ohneAlt = [];
/**
* Eine Liste von Quelldateien zu Bild-Einträgen rendern. $prefix bestimmt den
* Dateinamen (plakat-01…, bild-01…), $quality die JPEG-Qualität: Plakate tragen
* Text, da ist Ringing um Buchstaben sichtbar — Fotos brauchen das nicht.
*/
$renderAlle = static function (array $sources, string $prefix, int $quality, array $widths, string $fallbackAlt)
use ($render, $destDir, $slug, $altBySource, &$ohneAlt): array {
$eintraege = [];
foreach ($sources as $i => $src) {
$source = basename($src);
$nummer = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT);
$alt = $altBySource[$source] ?? '';
if ($alt === '') {
$ohneAlt[] = $source;
}
$eintrag = $render(
$src,
"{$destDir}/{$prefix}-{$nummer}",
"img/veranstaltungen/{$slug}/{$prefix}-{$nummer}",
$alt !== '' ? $alt : $fallbackAlt,
$quality,
$widths
);
$eintrag['source'] = $source;
$eintraege[] = $eintrag;
}
return $eintraege;
};
$plakate = $renderAlle($plakatSources, 'plakat', 82, $plakatWidths, "Plakat zum {$titel}");
$galerie = $renderAlle($fotoSources, 'bild', 78, $fotoWidths, $titel);
// --- Nur plakate + galerie ersetzen, alles andere bleibt, wie es ist ---
$data['veranstaltungen'][$index]['plakate'] = $plakate;
$data['veranstaltungen'][$index]['galerie'] = $galerie;
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $dataFile . '.tmp';
file_put_contents($tmp, $payload);
if (!rename($tmp, $dataFile)) {
$fail('Konnte data/veranstaltungen.json nicht schreiben.');
}
$log(sprintf(
'%s: Bilder komplett ersetzt (%d Plakate, %d Fotos).',
$slug,
count($plakate),
count($galerie)
));
echo "Veranstaltung {$slug}: Bilder komplett ersetzt, jetzt "
. count($plakate) . " Plakat(e) und " . count($galerie) . " Foto(s).\n";
if ($ohneAlt !== []) {
echo count($ohneAlt) . " Bild(er) noch ohne Alt-Text: " . implode(', ', $ohneAlt) . "\n";
echo "Alt-Texte in data/veranstaltungen.json nachtragen (Wir-Form, siehe CLAUDE.md).\n";
}