Files
tsv08kulmbach-website/bin/stadionzeitung-add.php

668 lines
29 KiB
PHP
Raw Normal View History

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
2026-07-31 14:55:27 +02:00
<?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
*
* KEINE Anzeigen im Ordner (seit 31.07.2026): die kommen aus dem dauerhaften
* Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive
* ändern sich selten, das Heft erscheint oft vorher lag jede Anzeige als Kopie
* unter jeder Ausgabe und ihr Alt-Text musste jedes Mal neu geschrieben werden.
*
* Feste Vorlage (Interview-Beschluss 29.07.2026, Anzeigen 31.07.2026 überarbeitet):
* Der Inhalt ist eine Liste von BLÖCKEN, die Anzeigen werden gleichmäßig in die
* Lücken dazwischen verteilt nie mehr als zwei hintereinander (vorher vier
* Blöcke à fünf, das las sich wie „Heft zu Ende"). Anzeigenfrei bleiben: nach dem
* Cover, im Aufschlag Vorwort+Interview, zwischen den Tabellenseiten und zwischen
* Impressum und Rückseite. Die Doppelseiten-Regel (erste Tabelle auf gerader
* Seite) rechnet das Skript selbst und verschiebt dafür eine Anzeige.
* Das Cover wird automatisch generiert (app/components/stadionzeitung-cover.php).
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
2026-07-31 14:55:27 +02:00
*
* 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.*`.
*
* Interview (`interview`-Feld) und die generische Aufruf-Seite (`aufruf`-Feld,
* z. B. Sportfest-Helferaufruf) sind wie das Vorwort chat-gepflegt: das Skript
* trägt sie über erneute Läufe weiter und plant die Live-Seite nur ein, wenn
* das Feld existiert. Reihenfolge im anzeigenfreien Aufschlag: Vorwort
* Interview Aufruf. Für die Aufruf-Seite erzeugt das Skript zusätzlich den
* QR (qr-aufruf.png im Ausgaben-Ordner) aus `aufruf.link`.
*
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
2026-07-31 14:55:27 +02:00
* 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;
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
$oldAufruf = $oldIssue['aufruf'] ?? null;
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
2026-07-31 14:55:27 +02:00
// --- 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');
}
// Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien
// Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller
// Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld).
if ($oldAufruf !== null) {
$aufschlag[] = $live('aufruf');
}
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
2026-07-31 14:55:27 +02:00
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');
}
Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari) Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
// --- Spielerfoto fürs Cover: zuschneiden UND verkleinern. ---
// Hier stand früher „unverändert kopieren, Felix liefert bereits ein web-taugliches
// Foto" — die Annahme stimmte nicht. Das Hutschdorf-Foto hatte 2584x5093 Pixel und
// 12 MB; iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt
// (hier gut 53 MB), und auf einer Ausgabe liegen rund 86 Bilder. Ergebnis: das Cover
// blieb am iPhone leer, während Chrome am Desktop es zeigte (31.07.2026).
// Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa
// 238x470 CSS-Pixel — die 1000er Variante deckt Druck und dreifache Pixeldichte ab.
// resize_image_variants() erhält bei $alpha = true die Freistellung (PNG statt JPG).
// Optional, kein Fehlschlag bei Fehlen.
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
2026-07-31 14:55:27 +02:00
$playerImage = null;
if ($spieler !== null) {
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari) Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
$istPng = $ext === 'png';
// PNG: erst auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
// leerem Rand aus dem Export-Tool), dann verkleinern. Der Zuschnitt geht in eine
// Zwischendatei, damit das riesige Original nicht im Ausgaben-Ordner landet.
$quelle = $spieler;
$tmpCrop = "{$destDir}/spieler-zuschnitt.png";
if ($istPng && crop_transparent_png_to_content($spieler, $tmpCrop)) {
$quelle = $tmpCrop;
}
try {
$varianten = resize_image_variants($quelle, "{$destDir}/spieler", [320, 640, 1000], 82, $istPng);
$playerImage = [
'base' => "img/stadionzeitung/{$slug}/spieler",
'widths' => array_map(static fn (array $v): int => $v['width'], $varianten),
'ext' => $istPng ? 'png' : 'jpg',
'alt' => '',
];
$groesse = array_sum(array_map(static fn (array $v): int => $v['bytes'], $varianten));
$log(sprintf(
'Spielerfoto übernommen: %d Varianten%s, zusammen %.1f MB (Original %.1f MB).',
count($varianten),
$istPng ? ' mit Transparenz, auf Inhalt zugeschnitten' : '',
$groesse / 1048576,
filesize($spieler) / 1048576
));
} catch (Throwable $e) {
$log("Spielerfoto konnte nicht verarbeitet werden: {$spieler}", 'WARN');
}
if (is_file($tmpCrop)) {
unlink($tmpCrop);
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
2026-07-31 14:55:27 +02:00
}
}
// --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie.
// club_url() statt abs_url(): der Code landet auf Papier und muss auf die echte
// Domain zeigen, auch wenn er hier lokal erzeugt wird (vorher stand
// http://localhost:8000 darin).
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
2026-07-31 14:55:27 +02:00
// 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(club_url('stadionzeitung/' . $slug), $qrDest)) {
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
2026-07-31 14:55:27 +02:00
$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) — ebenfalls über
// club_url(), damit sie unabhängig von der Umgebung stimmen und nach dem
// Produktivgang nicht neu erzeugt werden müssen.
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
2026-07-31 14:55:27 +02:00
foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) {
if (generate_qr_png(club_url($ctaTarget), PUBLIC_PATH . "/assets/img/stadionzeitung/qr-{$ctaTarget}.png")) {
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
2026-07-31 14:55:27 +02:00
$log("QR-Code für /{$ctaTarget} erzeugt.");
} else {
$log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN');
}
}
// Aufruf-Seite: QR auf das interne Ziel (z. B. /veranstaltungen/sportfest-2026)
// aus dem chat-gepflegten `aufruf.link`-Feld. Anders als die geteilten CTA-QRs
// ist das Ziel ausgabenspezifisch, deshalb liegt der Code im Ausgaben-Ordner
// (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url()
// wie überall, damit der gedruckte Code auf die echte Domain zeigt.
$aufrufLink = trim((string) ($oldAufruf['link'] ?? ''));
if ($aufrufLink !== '') {
if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) {
$log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt.");
} else {
$log('QR-Code für die Aufruf-Seite konnte nicht erzeugt werden — die Seite rendert ohne QR.', 'WARN');
}
}
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
2026-07-31 14:55:27 +02:00
// 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,
'aufruf' => $oldAufruf,
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
2026-07-31 14:55:27 +02:00
'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";