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

606
bin/stadionzeitung-add.php Normal file
View File

@@ -0,0 +1,606 @@
<?php
declare(strict_types=1);
/**
* Stadionzeitung-Ausgabe hinzufügen/aktualisieren: baut eine Ausgabe nach der
* festen Vorlage (siehe CLAUDE.md → Stadionzeitung) aus Canva-Bildern +
* drei automatischen Live-Seiten (Tabelle/Termine/News). Manuell im Chat
* ausgelöst, kein Cron.
*
* Aufruf: php bin/stadionzeitung-add.php <ordner> "<Gegner>" <YYYY-MM-DD> <team-key> [slug]
* <team-key> Team-Key aus data/matchcenter.json (z. B. erste-mannschaft, damen) —
* bestimmt, wessen Tabelle auf der Live-Seite „Tabelle" erscheint.
*
* Erwarteter Ordnerinhalt:
* <ordner>/vorwort.* optional, Fallback-Bild — nur genutzt, solange für diese
* Ausgabe noch kein Vorwort-Text hinterlegt ist (siehe unten)
* <ordner>/rueckseite.* optional
* <ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz)
* fürs Cover — wird unverändert kopiert, nie über GD neu kodiert
* <ordner>/verfasser.* optional, Porträtfoto für die Vorwort-Signatur
* <ordner>/anzeigen/* Sponsoren-Seiten (fertige Vollseiten — ob im Sponsoring
* halbe oder ganze Seite verkauft wurde, ist hier egal,
* geliefert wird immer eine fertige Seite), natürlich sortiert
*
* Feste Vorlage (Interview-Beschluss 29.07.2026): Cover (live) → Vorwort →
* Ergebnis-Rückblick (live) → Anzeigen A → je Mannschaft eine Tabelle (live,
* Reihenfolge = data/matchcenter.json) → Vorschau (live) → Anzeigen B →
* Termine (live) → Anzeigen C → News (live) → Ansprechpartner (live) →
* Anzeigen D → Rückseite. Das Cover wird automatisch generiert (siehe
* app/components/stadionzeitung-cover.php) — kein Canva-Bild mehr nötig. Der
* gesamte Anzeigen-Pool (in gelieferter Reihenfolge) wird zu möglichst
* gleichen Teilen auf die vier Gruppen verteilt.
*
* Neue Ausgaben entstehen als ENTWURF (status: entwurf): unsichtbar in
* Archiv/Sitemap, Live-Seiten rechnen bei jedem Aufruf frisch. Erst
* bin/stadionzeitung-publish.php friert Tabellen/News/Ergebnisse/Vorschau als
* snapshot ein und schaltet die Ausgabe sichtbar. Fürs gefaltete A5-Heft
* (Druckfassung ?druck=1, Broschüren-Reihenfolge) sollte die Seitenzahl ein
* Vielfaches von 4 sein — das Skript meldet die Lücke, aufgefüllt wird beim
* Erzeugen gemeinsam (weiße Füllseiten in der Druckfassung sind der Notnagel).
*
* Vorwort-Text ist KEIN Skript-Input: er wird (wie data/events.json/news.json) im
* Chat gepflegt — Felix schickt den Text, Claude trägt ihn manuell ins `vorwort`-Feld
* der Ausgabe in data/stadionzeitung.json ein (heading/paragraphs/closing/signee_name/
* signee_role). Damit ein erneuter Skript-Lauf (z. B. um eine Anzeige zu korrigieren)
* dieses Feld nicht wegwirft, liest das Skript den bisherigen Wert vor dem Ersetzen
* aus und schreibt ihn unverändert zurück. Ist ein `vorwort`-Feld vorhanden, rendert
* die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
* Fallback-Bilds `vorwort.*`.
*
* Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
*/
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) < 4) {
fwrite(STDERR, "Nutzung: php bin/stadionzeitung-add.php <ordner> \"<Gegner>\" <YYYY-MM-DD> <team-key> [slug]\n");
exit(1);
}
[$sourceDir, $opponent, $date, $teamKey] = $args;
$slugArg = $args[4] ?? '';
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('stadionzeitung', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
// --- Eingaben validieren ---
if (!is_dir($sourceDir)) {
$fail("Ordner nicht gefunden: {$sourceDir}");
}
$parsedDate = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($parsedDate === false || $parsedDate->format('Y-m-d') !== $date) {
$fail("Ungültiges Datum (erwartet YYYY-MM-DD): {$date}");
}
$opponent = trim($opponent);
if ($opponent === '') {
$fail('Gegner darf nicht leer sein.');
}
$mc = json_load('matchcenter');
$knownTeamKeys = array_column($mc['teams'] ?? [], 'key');
if (!in_array($teamKey, $knownTeamKeys, true)) {
$fail("Unbekannter team-key '{$teamKey}'. Bekannt: " . implode(', ', $knownTeamKeys));
}
/**
* Kleine, lokale Slugify-Funktion (nur dieser Aufrufer) — Umlaute
* transliterieren, Kleinschreibung, alles außer a-z0-9 zu Bindestrichen.
*/
$slugify = static function (string $text): string {
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
return trim($text, '-');
};
$slug = $slugArg !== '' ? $slugify($slugArg) : $date . '-' . $slugify($opponent);
if ($slug === '') {
$fail('Konnte keinen gültigen Slug bilden.');
}
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$findOne = static function (string $baseName) use ($sourceDir, $exts): ?string {
$matches = glob(rtrim($sourceDir, '/') . "/{$baseName}.{{$exts}}", GLOB_BRACE) ?: [];
return $matches[0] ?? null;
};
$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($files);
return array_values($files);
};
$vorwort = $findOne('vorwort');
$rueckseite = $findOne('rueckseite');
$spieler = $findOne('spieler');
$verfasser = $findOne('verfasser');
// 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,
// wo sie jetzt hingehören, als ihn stillschweigend zu ignorieren.
if ($findAll('anzeigen') !== []) {
$log('Der Ordner enthält noch anzeigen/ — die werden NICHT mehr von hier gelesen. '
. 'Anzeigen liegen im Bestand: php bin/anzeige-add.php <ordner>', 'WARN');
}
if ($rueckseite === null) {
$log('Keine rueckseite.* im Ordner — das Heft endet mit dem Impressum.', 'WARN');
}
// --- Lock gegen parallele Läufe (billige Absicherung, kein echtes Cron-Risiko hier) ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/stadionzeitung.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- Bisherigen Eintrag lesen (falls vorhanden) — trägt u. a. das chat-gepflegte
// vorwort-Feld über einen erneuten Lauf hinweg weiter, siehe Kommentar oben. ---
$cacheFile = DATA_PATH . '/stadionzeitung.json';
$existing = is_file($cacheFile)
? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
: ['issues' => []];
$oldIssue = null;
foreach ($existing['issues'] ?? [] as $entry) {
if (($entry['slug'] ?? '') === $slug) {
$oldIssue = $entry;
break;
}
}
$oldVorwort = $oldIssue['vorwort'] ?? null;
// Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso.
$oldInterview = $oldIssue['interview'] ?? null;
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
} else {
// Der Ordner wird KOMPLETT geleert, nicht nach Mustern aufgeräumt: alles
// darin entsteht in diesem Lauf neu (Vorwort- und Rückseiten-Bild, Spieler,
// Verfasser, QR). Ein Muster-Aufräumen hinterlässt sonst Waisen, sobald sich
// Dateinamen ändern — genau das ist passiert: neben den `page-NN`-Resten des
// alten Positions-Schemas lagen auch die `anzeige-*`-Kopien, die jetzt im
// Bestand (public/assets/img/anzeigen/) leben. Die geteilten CTA-QR-Codes
// liegen eine Ebene höher und sind davon nicht betroffen.
foreach (glob("{$destDir}/*") ?: [] as $old) {
if (is_file($old)) {
unlink($old);
}
}
}
$widths = [480, 960, 1440];
// --- Anzeigen kommen aus dem DAUERHAFTEN Bestand (data/anzeigen.json), nicht
// mehr aus dem Ablieferordner: Sponsorenmotive ändern sich selten, das Heft
// 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. ---
$anzeigenSeiten = array_values(array_map(
static function (array $a): array {
$sponsor = trim((string) ($a['sponsor'] ?? ''));
return [
'type' => 'image',
'base' => (string) $a['base'],
'widths' => $a['widths'] ?? [],
// Alt-Text aus dem Sponsorennamen abgeleitet, damit er nur an EINER
// Stelle gepflegt wird. `alt` im Bestand überschreibt ihn (z. B.
// „Eigenanzeige: …").
'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . $sponsor)),
];
},
array_filter(
json_load('anzeigen')['anzeigen'] ?? [],
// `bis` gegen das DATUM DER AUSGABE prüfen, nicht gegen heute: ein Heft
// zum Spiel am 1. August darf für ein Fest am 6. September werben, ein
// später angelegtes Heft nicht mehr. Damit retirieren zeitgebundene
// Eigenanzeigen (Sportfest-Plakat, Programm) von selbst, statt bis zum
// nächsten Handgriff im Heft zu bleiben.
static fn (array $a): bool => ($a['aktiv'] ?? true)
&& !empty($a['base'])
&& (empty($a['bis']) || (string) $a['bis'] >= $date)
)
));
// --- Seiten-Bausteine: Live-Seiten brauchen kein Bild, die zwei verbliebenen
// 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;
};
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
try {
$variants = resize_image_variants($src, "{$destDir}/{$name}", $widths, 82);
} catch (RuntimeException $e) {
throw new RuntimeException("Seite '{$name}' ({$src}) fehlgeschlagen: " . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
return [
'type' => 'image',
'base' => "img/stadionzeitung/{$slug}/{$name}",
'widths' => $generated,
'alt' => $alt,
];
};
/*
* 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;
* `anzeigen_danach` sagt, ob nach dem Block eine Lücke für Anzeigen entsteht.
*
* Drei Stellen sind bewusst anzeigenfrei:
* - nach dem Cover: der Leser schlägt auf Inhalt auf, nicht auf Werbung,
* - innerhalb des Aufschlags Vorwort+Interview (deshalb EIN Block),
* - innerhalb der Tabellen (deshalb EIN Block): die Tabellenseiten müssen im
* gefalteten Heft nebeneinander liegen, eine Anzeige dazwischen zerstört
* genau die Doppelseite, die die Paritätsrechnung unten herstellt,
* - zwischen Impressum und Rückseite: die Rückseite ist die Heft-Außenseite.
*/
$bloecke = [];
try {
$bloecke[] = ['name' => 'cover', 'seiten' => [$live('cover')], 'anzeigen_danach' => false];
$aufschlag = [];
if ($oldVorwort !== null) {
$aufschlag[] = $live('vorwort');
} elseif ($vorwort !== null) {
$aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung');
}
// 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) {
$aufschlag[] = $live('interview');
}
if ($aufschlag !== []) {
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
}
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
// Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
// SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
$tabellen = [];
foreach ($mc['teams'] ?? [] as $team) {
if (!empty($team['key'])) {
$tabellen[] = $live('tabelle', (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];
}
$bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];
if ($rueckseite !== null) {
$bloecke[] = [
'name' => 'rueckseite',
'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen {$opponent}")],
'anzeigen_danach' => false,
];
}
} catch (RuntimeException $e) {
$fail($e->getMessage());
}
// --- Anzeigen gleichmäßig auf die Lücken verteilen ---
$luecken = [];
foreach ($bloecke as $i => $b) {
if ($b['anzeigen_danach']) {
$luecken[] = $i;
}
}
$luckCount = count($luecken);
$adCount = count($anzeigenSeiten);
if ($luckCount === 0) {
$fail('Keine Lücke für Anzeigen — die Vorlage hat keine Inhaltsblöcke.');
}
// Grundmenge plus Restverteilung. Der Rest wird GESTREUT (nicht vorne gebündelt),
// sonst stünden alle Doppel-Lücken am Heftanfang.
$basis = intdiv($adCount, $luckCount);
$rest = $adCount % $luckCount;
$verteilung = [];
for ($g = 0; $g < $luckCount; $g++) {
$verteilung[$g] = $basis + (intdiv(($g + 1) * $rest, $luckCount) - intdiv($g * $rest, $luckCount));
}
if ($adCount > 2 * $luckCount) {
$log(sprintf(
'%d Anzeigen auf %d Lücken — mehr als zwei hintereinander ist damit unvermeidbar (höchstens %d). Weniger Anzeigen oder eine Inhaltsseite mehr.',
$adCount,
$luckCount,
max($verteilung)
), 'WARN');
}
/** 1-basierte Seitenzahl, auf der ein Block beginnt (null, wenn er fehlt). */
$blockSeite = static function (string $name) use ($bloecke, $verteilung): ?int {
$seite = 0;
$g = 0;
foreach ($bloecke as $b) {
if ($b['name'] === $name) {
return $seite + 1;
}
$seite += count($b['seiten']);
if ($b['anzeigen_danach']) {
$seite += $verteilung[$g];
$g++;
}
}
return null;
};
/*
* Doppelseiten-Regel: aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander,
* die GERADE Seite links. Die erste Tabellenseite muss deshalb auf einer geraden
* Seitenzahl liegen, damit die Tabellen im gefalteten Heft eine Doppelseite bilden.
* Nur Anzeigen in Lücken VOR dem Tabellenblock verschieben dessen Parität — eine
* Anzeige zwischen einer solchen Lücke und einer dahinter zu tauschen kippt sie
* also, ohne die Gesamtseitenzahl zu ändern. Früher war das Handarbeit
* („notfalls eine Anzeige verschieben"), jetzt rechnet es das Skript.
*/
$tabellenIndex = null;
foreach ($bloecke as $i => $b) {
if ($b['name'] === 'tabellen') {
$tabellenIndex = $i;
break;
}
}
if ($tabellenIndex !== null && ($seite = $blockSeite('tabellen')) !== null && $seite % 2 === 1) {
$vor = [];
$nach = [];
foreach ($luecken as $g => $blockIdx) {
if ($blockIdx < $tabellenIndex) {
$vor[] = $g;
} else {
$nach[] = $g;
}
}
$getauscht = false;
// Erst versuchen: eine Anzeige nach vorne holen (Lücke vorne darf auf 2).
foreach ($vor as $g) {
if ($verteilung[$g] >= 2) {
continue;
}
foreach (array_reverse($nach) as $h) {
if ($verteilung[$h] > 0) {
$verteilung[$g]++;
$verteilung[$h]--;
$getauscht = true;
$log("Doppelseiten-Regel: eine Anzeige aus Lücke {$h} nach Lücke {$g} verschoben, damit die Tabellen nebeneinander liegen.");
break 2;
}
}
}
// Sonst umgekehrt: eine Anzeige nach hinten abgeben.
if (!$getauscht) {
foreach ($vor as $g) {
if ($verteilung[$g] < 1) {
continue;
}
foreach ($nach as $h) {
if ($verteilung[$h] < 2) {
$verteilung[$g]--;
$verteilung[$h]++;
$getauscht = true;
$log("Doppelseiten-Regel: eine Anzeige aus Lücke {$g} nach Lücke {$h} verschoben, damit die Tabellen nebeneinander liegen.");
break 2;
}
}
}
}
if (!$getauscht) {
$log('Doppelseiten-Regel NICHT herstellbar (keine Anzeige verschiebbar) — die Tabellen liegen im Heft nicht nebeneinander.', 'WARN');
}
}
// --- Zusammensetzen: Block, dann die Anzeigen seiner Lücke ---
$images = [];
$g = 0;
$zeiger = 0;
foreach ($bloecke as $b) {
foreach ($b['seiten'] as $seiteEintrag) {
$images[] = $seiteEintrag;
}
if ($b['anzeigen_danach']) {
for ($k = 0; $k < $verteilung[$g]; $k++) {
$images[] = $anzeigenSeiten[$zeiger++];
}
$g++;
}
}
$total = count($images);
// Layout einmal kompakt protokollieren (statt einer Zeile pro Seite) — daran
// sieht man auf einen Blick, ob die Verteilung stimmt.
$layout = implode(' ', array_map(
static fn (array $e): string => ($e['type'] === 'live' ? strtoupper(substr($e['page'], 0, 3)) : '·'),
$images
));
$log("Layout ({$total} Seiten, · = Anzeige): {$layout}");
$log(sprintf(
'%d Anzeigen auf %d Lücken verteilt (%s), längster Block: %d.',
$adCount,
$luckCount,
implode('/', $verteilung),
max($verteilung ?: [0])
));
// Fürs gefaltete Heft (Sattelheftung) muss die Seitenzahl durch 4 teilbar
// sein — die Druckfassung füllt fehlende Seiten weiß auf, aber sauberer ist
// eine gemeinsame Lösung beim Erzeugen (weitere Anzeige, Füllseite mit
// Inhalt). Deshalb hier nur eine deutliche Meldung, kein Abbruch.
if ($total % 4 !== 0) {
$log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
}
// --- Spielerfoto fürs Cover: unverändert kopieren (kein resize_image_variants(),
// das würde über imagejpeg() die Transparenz zerstören — Felix liefert bereits
// ein web-taugliches Foto). Optional, kein Fehlschlag bei Fehlen. ---
$playerImage = null;
if ($spieler !== null) {
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
$playerDest = "{$destDir}/spieler.{$ext}";
// PNG: auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
// leerem Rand aus dem Export-Tool). Andere Formate (kein Alpha) unverändert.
$copied = $ext === 'png'
? crop_transparent_png_to_content($spieler, $playerDest)
: copy($spieler, $playerDest);
if ($copied) {
$playerImage = "img/stadionzeitung/{$slug}/spieler.{$ext}";
$log('Spielerfoto übernommen' . ($ext === 'png' ? ' (auf Inhalt zugeschnitten).' : ' (unverändert kopiert).'));
} else {
$log("Spielerfoto konnte nicht kopiert werden: {$spieler}", 'WARN');
}
}
// --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie.
// QR ist "nice to have" — schlägt die Erzeugung fehl, läuft die Ausgabe ohne weiter. ---
$qrImage = null;
$qrDest = "{$destDir}/qr.png";
if (generate_qr_png(abs_url('stadionzeitung/' . $slug), $qrDest)) {
$qrImage = "img/stadionzeitung/{$slug}/qr.png";
$log('QR-Code erzeugt.');
} else {
$log('QR-Code konnte nicht erzeugt werden — Cover rendert ohne QR.', 'WARN');
}
// Geteilte QRs der CTA-Füße (Kontakt → /mitmachen, News → /news, Termine →
// /termine, Vorschau → /matchcenter, für alle Ausgaben gleich) — werden bei
// jedem Lauf mit der aktuellen base_url neu geschrieben, damit sie nach dem
// Produktivgang automatisch auf die echte Domain zeigen.
foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) {
if (generate_qr_png(abs_url($ctaTarget), PUBLIC_PATH . "/assets/img/stadionzeitung/qr-{$ctaTarget}.png")) {
$log("QR-Code für /{$ctaTarget} erzeugt.");
} else {
$log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN');
}
}
// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
// keine Einbindung, Regel 1 bleibt unberührt).
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
if ($instagramUrl !== '' && generate_qr_png($instagramUrl, PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')) {
$log('QR-Code fürs Instagram-Profil erzeugt.');
} else {
$log('QR-Code fürs Instagram-Profil konnte nicht erzeugt werden — Momente-Seite rendert ohne QR.', 'WARN');
}
// --- Termine-Snapshot für die Live-Seite "termine": die 5 zum Spieltag
// nächsten Termine, EINMALIG eingefroren (termine_build_lists($date)) —
// nicht bei jedem Seitenaufruf neu berechnet. Eine Ausgabe ist eine gedruckte
// Publikation: wer sie Monate später liest, soll weiter sehen, was am
// Spieltag anstand, nicht was von heute aus gesehen ansteht. Das heutige
// Spiel selbst (das Cover zeigt es schon) fliegt raus — sonst listet
// "Kommende Termine" die eigene Ausgabe. ---
$termineUpcoming = array_filter(
termine_build_lists($date)['upcoming'],
static fn (array $item): bool => ($item['date'] ?? '') !== $date || ($item['type'] ?? '') !== 'spiel'
);
$termineSnapshot = array_slice(array_values($termineUpcoming), 0, 5);
$log(count($termineSnapshot) . ' Termine für die Live-Seite eingefroren (Stand ' . $date . ').');
// --- Porträtfoto für die Vorwort-Signatur: normales Foto (kein Alpha), daher
// über resize_image_variants() wie eine Bild-Seite — kleine Breiten reichen,
// es wird nur als kleiner Kreis neben der Signatur gezeigt. Optional. ---
$verfasserPhoto = null;
if ($verfasser !== null) {
$verfasserDest = "{$destDir}/verfasser";
try {
$variants = resize_image_variants($verfasser, $verfasserDest, [120, 240], 82);
$generatedWidths = array_values(array_unique(array_column($variants, 'width')));
sort($generatedWidths);
$verfasserPhoto = [
'base' => "img/stadionzeitung/{$slug}/verfasser",
'widths' => $generatedWidths,
'alt' => '',
];
$log('Verfasserfoto übernommen.');
} catch (RuntimeException $e) {
$log("Verfasserfoto konnte nicht verarbeitet werden: {$e->getMessage()}", 'WARN');
}
}
// --- data/stadionzeitung.json atomar schreiben (bestehenden Slug ersetzen) ---
$issues = array_values(array_filter(
$existing['issues'] ?? [],
static fn (array $issue): bool => ($issue['slug'] ?? '') !== $slug
));
// Immer als Entwurf — auch beim erneuten Lauf über eine schon veröffentlichte
// Ausgabe: der Lauf ersetzt die Seiten, also muss der Stand neu geprüft und
// neu veröffentlicht werden (der alte snapshot passt nicht mehr und fliegt mit).
if ($oldIssue !== null && ($oldIssue['status'] ?? '') === 'veroeffentlicht') {
$log("Ausgabe '{$slug}' war veröffentlicht — steht jetzt wieder auf Entwurf, nach Prüfung neu veröffentlichen (bin/stadionzeitung-publish.php).", 'WARN');
}
$issues[] = [
'slug' => $slug,
'opponent' => $opponent,
'date' => $date,
'team_key' => $teamKey,
'status' => 'entwurf',
'pages' => $total,
'player_image' => $playerImage,
'qr_image' => $qrImage,
'vorwort' => $oldVorwort,
'interview' => $oldInterview,
'verfasser_photo' => $verfasserPhoto,
'termine_snapshot' => $termineSnapshot,
'images' => $images,
];
usort($issues, static fn (array $a, array $b): int => strcmp($b['date'], $a['date']));
$payload = json_encode(['issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $payload);
rename($tmp, $cacheFile);
$log("OK: Ausgabe '{$slug}' ({$opponent}, {$date}, Team {$teamKey}) mit {$total} Seiten als Entwurf gespeichert.");
echo "Fertig (ENTWURF): /stadionzeitung/{$slug} ({$total} Seiten, davon {$adCount} Anzeigen aus dem Bestand)\n";
echo 'Anzeigen verteilt: ' . implode('/', $verteilung) . ' je Lücke, längster Block ' . max($verteilung ?: [0]) . ".\n";
echo "Veröffentlichen nach Prüfung: php bin/stadionzeitung-publish.php {$slug}\n";