Gedruckte Adressen aus club.website statt base_url

QR-Codes und Adresszeilen trugen `http://localhost:8000` ins Druckmaterial, weil
sie über abs_url() liefen. Nach dem Produktivgang hätten alle zehn Codes neu
erzeugt werden müssen — und ein gedruckter Code mit localhost darin ist Altpapier.

Die öffentliche Domain ist Vereinsidentität, keine Umgebungseinstellung: neues
Feld `club.website` in data/club.json plus club_url()/club_domain(). base_url
bleibt umgebungsabhängig, weil Canonical, OG, Sitemap und JSON-LD zur
ausliefernden Adresse passen müssen.

Umgestellt: Cover-QR und die acht geteilten CTA-QRs, die Internet-Zeile im
Heft-Impressum, die Cover-Zeile neben dem QR, die acht Linkzeilen unter den
CTA-QRs der Live-Seiten (die hatte ich beim ersten Durchgang übersehen), das
Web-Feld im Website-Impressum und bin/qr.php.

Nebenbei: club_domain() ohne Pfad hängte einen Wurzel-Slash an
(„tsv08kulmbach.de/"), und der Kopf-Kommentar von stadionzeitung-add.php
beschrieb noch die alten vier Anzeigen-Gruppen.

Nicht verifizierbar und deshalb dokumentiert: die vendorte QR-Bibliothek
arbeitet nicht reproduzierbar (gleiche URL, andere Pixel — anderes Maskenmuster,
beides gültig). Ein PNG-Vergleich beweist nichts, nachweisbar ist nur der
Code-Pfad. Jeden Druck-QR einmal scannen.

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 15:40:59 +02:00
parent 93b67bc9d3
commit 2f8619e78d
27 changed files with 173 additions and 115 deletions

View File

@@ -43,7 +43,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Farben, Fonts, Spacing, Radii, Schatten | `public/assets/css/tokens.css` | Hex-Werte/Magic Numbers in anderen CSS-Dateien | | Farben, Fonts, Spacing, Radii, Schatten | `public/assets/css/tokens.css` | Hex-Werte/Magic Numbers in anderen CSS-Dateien |
| Seiten-Slugs & Routing | `app/routes.php` | URLs woanders hart verdrahten | | Seiten-Slugs & Routing | `app/routes.php` | URLs woanders hart verdrahten |
| Navigation (Labels/Reihenfolge) | `data/navigation.json` | Menüpunkte in Templates | | Navigation (Labels/Reihenfolge) | `data/navigation.json` | Menüpunkte in Templates |
| Vereinsdaten (Name, Adresse, Kontakt, Social, Geo, Slogan) | `data/club.json` | Adresse/E-Mail irgendwo als Text duplizieren | | Vereinsdaten (Name, Adresse, Kontakt, Social, Geo, Slogan, **Web-Adresse**) | `data/club.json` | Adresse/E-Mail irgendwo als Text duplizieren. `club.website` ist die öffentliche Domain für alles Gedruckte (`club_url()`/`club_domain()`) — **nicht** `config('base_url')`, die ist umgebungsabhängig |
| Startseiten-Inhalte | `data/home.json` | Texte in `pages/home.php` | | Startseiten-Inhalte | `data/home.json` | Texte in `pages/home.php` |
| Mannschaften (Kader, Trainer, Hero, FAQ) | `data/teams.json` | Spieler-/Trainernamen in Team-Templates | | Mannschaften (Kader, Trainer, Hero, FAQ) | `data/teams.json` | Spieler-/Trainernamen in Team-Templates |
| Seiteninhalte (Texte, Hero, FAQ) | `data/<slug>.json` (z. B. `fussball`, `jugend`, `turnen`, `verein`, `mitmachen`, `historie`, `partner-werden`, `sportheimbuchung`) | Texte/Hero/FAQ hart in `pages/*.php` | | Seiteninhalte (Texte, Hero, FAQ) | `data/<slug>.json` (z. B. `fussball`, `jugend`, `turnen`, `verein`, `mitmachen`, `historie`, `partner-werden`, `sportheimbuchung`) | Texte/Hero/FAQ hart in `pages/*.php` |
@@ -100,7 +100,10 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen
Seiten → als Komponente extrahieren. Seiten → als Komponente extrahieren.
- **Helpers** (`app/helpers.php`): `e()` (Escaping — IMMER für dynamische Ausgaben), `url()` (interne - **Helpers** (`app/helpers.php`): `e()` (Escaping — IMMER für dynamische Ausgaben), `url()` (interne
Pfade), `abs_url()` (absolute URLs für Canonical/OG), `asset()` (Versionierung via filemtime; Pfade), `abs_url()` (absolute URLs für Canonical/OG), **`club_url()`/`club_domain()`** (absolute
URL bzw. Domain ohne Schema auf der ÖFFENTLICHEN Vereinsadresse `club.website` — für alles, was
gedruckt wird oder als Adresse dasteht; **nie `abs_url()` dafür**, siehe „Gedruckte Adressen" unten),
`asset()` (Versionierung via filemtime;
zweiter Parameter `false` lässt `?v=` weg — nur für Dateien, die auch statisches CSS anfordert, zweiter Parameter `false` lässt `?v=` weg — nur für Dateien, die auch statisches CSS anfordert,
also die Font-Preloads in `meta.php`, deren URL exakt der `@font-face`-URL aus `base.css` also die Font-Preloads in `meta.php`, deren URL exakt der `@font-face`-URL aus `base.css`
entsprechen muss; geänderte Fonts bekommen einen neuen Dateinamen statt einer Version), entsprechen muss; geänderte Fonts bekommen einen neuen Dateinamen statt einer Version),
@@ -611,14 +614,23 @@ unreparierbar auf jedem Gerät, das ihn einmal gesehen hat) · `Cache-Control: n
`exit` statt `return` (sonst hängt `layout.php` einen HTML-Body an die Weiterleitung). `exit` statt `return` (sonst hängt `layout.php` einen HTML-Body an die Weiterleitung).
Nicht in der Sitemap: es ist eine Weiterleitung, keine Seite. Nicht in der Sitemap: es ist eine Weiterleitung, keine Seite.
**Gedruckte Adressen: `club.website` statt `base_url`** (seit 31.07.2026). `base_url` ist bewusst
umgebungsabhängig, weil Canonical, OG, Sitemap und JSON-LD zur ausliefernden Adresse passen müssen.
**Gedrucktes kennt kein „lokal":** QR-Codes, die Internet-Zeile im Heft-Impressum, die Cover-Zeile
neben dem QR und die Linkzeilen unter den CTA-QRs der Live-Seiten laufen deshalb über
`club_url()`/`club_domain()` (Quelle: `club.website` in `data/club.json`). Vorher trugen sie
`localhost:8000` ins Druckmaterial und hätten nach dem Produktivgang alle neu erzeugt werden müssen.
Die Domain ist Vereinsidentität, keine Umgebungseinstellung — deshalb `club.json` und nicht `config`.
Fehlt das Feld, fällt `club_url()` auf `abs_url()` zurück (nie ein kaputter Link).
**QR-Codes für Drucksachen: `php bin/qr.php <pfad|url> [ziel.png] [--scale=24]`** (Default nach **QR-Codes für Drucksachen: `php bin/qr.php <pfad|url> [ziel.png] [--scale=24]`** (Default nach
`storage/qr/`, gitignored). Getrennt von den Stadionzeitungs-Codes, die `storage/qr/`, gitignored). Getrennt von den Stadionzeitungs-Codes, die
`bin/stadionzeitung-add.php` selbst klein für die A5-Seiten erzeugt. **Die Warnung ist der Zweck des `bin/stadionzeitung-add.php` selbst klein für die A5-Seiten erzeugt; interne Pfade laufen über
Skripts:** ein QR-Code hat die URL fest eingebacken, und gegen die Dev-Config erzeugt steht `club_url()`, sind also unabhängig von der Umgebung schon richtig.
`http://localhost:8000/…` darin — das gedruckte Plakat ist dann Altpapier. Interne Pfade laufen **Jeden Druck-QR vor der Freigabe einmal mit dem Handy scannen.** Das ist nicht Vorsicht, sondern die
über `abs_url()` (Single Source `base_url`), und ist die nicht https, sagt das Skript es laut. Solange einzige Prüfmöglichkeit: die vendorte Bibliothek arbeitet **nicht reproduzierbar** (dieselbe URL
die Seite nicht live ist, die vollständige Produktions-URL direkt übergeben. **Jeden Druck-QR vor der ergibt unterschiedliche Pixel — anderes Maskenmuster, beides gültig), ein Vergleich zweier PNGs
Freigabe einmal mit dem Handy scannen** — im Repo gibt es keinen Decoder, der das prüfen könnte. beweist deshalb nichts. Nachweisbar ist nur der Code-Pfad, nicht der Bildinhalt.
**Live-Seiten wiederverwendbar:** `termine_build_lists()` und `news_build_feed()` (`app/helpers.php`) **Live-Seiten wiederverwendbar:** `termine_build_lists()` und `news_build_feed()` (`app/helpers.php`)
sind die einzige Merge-Logik für Termine bzw. News — `/termine`, `/news` UND die sind die einzige Merge-Logik für Termine bzw. News — `/termine`, `/news` UND die

View File

@@ -67,7 +67,8 @@ if ($match !== null) {
$opponentCrest = $match['is_home'] ? $match['away_crest'] : $match['home_crest']; $opponentCrest = $match['is_home'] ? $match['away_crest'] : $match['home_crest'];
} }
$linkText = preg_replace('~^https?://(www\.)?~', '', rtrim((string) config('base_url'), '/')); // Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
$linkText = club_domain();
$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : '')); $kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : ''));
// Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) — // Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —

View File

@@ -25,7 +25,7 @@ $groups = $groups ?? [];
$matchcenterQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-matchcenter.png') $matchcenterQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-matchcenter.png')
? 'img/stadionzeitung/qr-matchcenter.png' ? 'img/stadionzeitung/qr-matchcenter.png'
: null; : null;
$matchcenterLink = preg_replace('~^https?://(www\.)?~', '', abs_url('matchcenter')); $matchcenterLink = club_domain('matchcenter');
?> ?>
<div class="stadionzeitung-ergebnisse"> <div class="stadionzeitung-ergebnisse">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">

View File

@@ -17,7 +17,7 @@ $emailHistorie = (string) (json_load('club')['email_historie'] ?? '');
$historieQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-historie.png') $historieQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-historie.png')
? 'img/stadionzeitung/qr-historie.png' ? 'img/stadionzeitung/qr-historie.png'
: null; : null;
$historieLink = preg_replace('~^https?://(www\.)?~', '', abs_url('historie')); $historieLink = club_domain('historie');
?> ?>
<div class="stadionzeitung-historie"> <div class="stadionzeitung-historie">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">

View File

@@ -15,7 +15,9 @@ $club = json_load('club');
$legalName = (string) ($club['legal_name'] ?? $club['name'] ?? ''); $legalName = (string) ($club['legal_name'] ?? $club['name'] ?? '');
$address = $club['address'] ?? []; $address = $club['address'] ?? [];
$email = (string) ($club['email'] ?? ''); $email = (string) ($club['email'] ?? '');
$domain = preg_replace('~^https?://(www\.)?~', '', rtrim((string) config('base_url'), '/')); // club_domain() statt base_url: das Heft wird gedruckt, da darf nie
// „localhost:8000" stehen.
$domain = club_domain();
$board = $club['board']['members'] ?? []; $board = $club['board']['members'] ?? [];
$boardNote = (string) ($club['board']['note'] ?? ''); $boardNote = (string) ($club['board']['note'] ?? '');
?> ?>

View File

@@ -23,7 +23,7 @@ $persons = array_values(array_filter(
$mitmachenQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitmachen.png') $mitmachenQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitmachen.png')
? 'img/stadionzeitung/qr-mitmachen.png' ? 'img/stadionzeitung/qr-mitmachen.png'
: null; : null;
$mitmachenLink = preg_replace('~^https?://(www\.)?~', '', abs_url('mitmachen')); $mitmachenLink = club_domain('mitmachen');
?> ?>
<div class="stadionzeitung-kontakt"> <div class="stadionzeitung-kontakt">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">

View File

@@ -18,7 +18,7 @@ declare(strict_types=1);
$mitgliedQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitglied-werden.png') $mitgliedQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitglied-werden.png')
? 'img/stadionzeitung/qr-mitglied-werden.png' ? 'img/stadionzeitung/qr-mitglied-werden.png'
: null; : null;
$mitgliedLink = preg_replace('~^https?://(www\.)?~', '', abs_url('mitglied-werden')); $mitgliedLink = club_domain('mitglied-werden');
?> ?>
<div class="stadionzeitung-mitglied"> <div class="stadionzeitung-mitglied">
<div class="stadionzeitung-mitglied__poster"> <div class="stadionzeitung-mitglied__poster">

View File

@@ -27,7 +27,7 @@ $sourceLabel = static fn (string $source): string => match ($source) {
$newsQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-news.png') $newsQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-news.png')
? 'img/stadionzeitung/qr-news.png' ? 'img/stadionzeitung/qr-news.png'
: null; : null;
$newsLink = preg_replace('~^https?://(www\.)?~', '', abs_url('news')); $newsLink = club_domain('news');
?> ?>
<div class="stadionzeitung-news"> <div class="stadionzeitung-news">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">

View File

@@ -24,7 +24,7 @@ foreach (json_load('club')['board']['members'] ?? [] as $member) {
$partnerQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png') $partnerQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
? 'img/stadionzeitung/qr-partner-werden.png' ? 'img/stadionzeitung/qr-partner-werden.png'
: null; : null;
$partnerLink = preg_replace('~^https?://(www\.)?~', '', abs_url('partner-werden')); $partnerLink = club_domain('partner-werden');
?> ?>
<div class="stadionzeitung-partner"> <div class="stadionzeitung-partner">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">

View File

@@ -18,7 +18,7 @@ $heroText = (string) (json_load('sportheimbuchung')['hero']['text'] ?? '');
$sportheimQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-sportheimbuchung.png') $sportheimQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-sportheimbuchung.png')
? 'img/stadionzeitung/qr-sportheimbuchung.png' ? 'img/stadionzeitung/qr-sportheimbuchung.png'
: null; : null;
$sportheimLink = preg_replace('~^https?://(www\.)?~', '', abs_url('sportheimbuchung')); $sportheimLink = club_domain('sportheimbuchung');
?> ?>
<?php /* Pinselstrich-Ecken bewusst entfernt (Felix, 29.07.2026) — kommen <?php /* Pinselstrich-Ecken bewusst entfernt (Felix, 29.07.2026) — kommen
ggf. später als saubere Bild-Assets zurück. */ ?> ggf. später als saubere Bild-Assets zurück. */ ?>

View File

@@ -27,7 +27,7 @@ $heroImage = json_load('home')['hero']['image'] ?? null;
$termineQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-termine.png') $termineQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-termine.png')
? 'img/stadionzeitung/qr-termine.png' ? 'img/stadionzeitung/qr-termine.png'
: null; : null;
$termineLink = preg_replace('~^https?://(www\.)?~', '', abs_url('termine')); $termineLink = club_domain('termine');
?> ?>
<div class="stadionzeitung-termine"> <div class="stadionzeitung-termine">
<?php if ($heroImage): ?> <?php if ($heroImage): ?>

View File

@@ -77,6 +77,40 @@ function abs_url(string $slug = ''): string
return rtrim((string) config('base_url'), '/') . url($slug); return rtrim((string) config('base_url'), '/') . url($slug);
} }
/**
* Absolute URL auf der ÖFFENTLICHEN Vereinsadresse (`club.website`), unabhängig
* davon, wo dieser Code gerade läuft.
*
* Nicht dasselbe wie abs_url(): `base_url` ist bewusst umgebungsabhängig, weil
* Canonical, OG und Sitemap zur ausliefernden Adresse passen müssen. Gedruckte
* Dinge kennen dagegen kein „lokal" — ein QR-Code auf Papier und die
* Internet-Zeile im Heft-Impressum zeigen immer auf die echte Domain. Vorher
* liefen sie über base_url und trugen deshalb `http://localhost:8000` ins
* Druckmaterial.
*
* Fällt `club.website` weg, greift abs_url() — ein fehlendes Feld darf keinen
* kaputten Link erzeugen.
*/
function club_url(string $slug = ''): string
{
$website = (string) (json_load('club')['website'] ?? '');
return $website !== '' ? rtrim($website, '/') . url($slug) : abs_url($slug);
}
/**
* Die öffentliche Adresse ohne Schema und ohne „www", zum Hinschreiben
* (Cover, Impressum, Plakate): `tsv08kulmbach.de/stadionzeitung`.
*/
function club_domain(string $slug = ''): string
{
$ohneSchema = (string) preg_replace('~^https?://(www\.)?~', '', club_url($slug));
// Ohne Pfad soll „tsv08kulmbach.de" stehen, nicht „tsv08kulmbach.de/" —
// url('') liefert den Wurzel-Slash, der hier nur Lärm wäre.
return $slug === '' ? rtrim($ohneSchema, '/') : $ohneSchema;
}
/** /**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css'). * Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
* *

View File

@@ -38,7 +38,10 @@ $meta = [
<?= e($addr['zip']) ?> <?= e($addr['city']) ?><br> <?= e($addr['zip']) ?> <?= e($addr['city']) ?><br>
<br> <br>
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a><br> E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a><br>
Web: <a href="<?= e(config('base_url')) ?>"><?= e(preg_replace('#^https?://#', '', (string) config('base_url'))) ?></a> <?php /* club_url() statt base_url: das Impressum nennt die Adresse des VEREINS,
nicht die des Servers, auf dem der Code gerade läuft. Lokal stand hier
sonst „localhost:8000" als Vereins-Webadresse. */ ?>
Web: <a href="<?= e(club_url()) ?>"><?= e(club_domain()) ?></a>
</address> </address>
</div> </div>
</section> </section>

View File

@@ -18,11 +18,11 @@ declare(strict_types=1);
* Plakate und Aushänge, die in Canva zusammengebaut werden — dort braucht es * Plakate und Aushänge, die in Canva zusammengebaut werden — dort braucht es
* ein Vielfaches an Pixeln, sonst franst der Code im Druck aus. * ein Vielfaches an Pixeln, sonst franst der Code im Druck aus.
* *
* DIE EIGENTLICHE AUFGABE DIESES SKRIPTS IST DIE WARNUNG: ein QR-Code enthält * Ein QR-Code hat die URL fest eingebacken — gegen die Entwicklungs-Config erzeugt
* die URL fest eingebacken. Wird er gegen die Entwicklungs-Config erzeugt, steht * stünde `http://localhost:8000/…` darin und das gedruckte Plakat wäre Altpapier.
* `http://localhost:8000/…` darin und das gedruckte Plakat ist Altpapier. Ein * Deshalb läuft ein interner Pfad durch club_url(): die öffentliche Vereinsadresse
* interner Pfad läuft deshalb durch abs_url() (Single Source: config base_url), * aus data/club.json, unabhängig von der Umgebung. Der Code stimmt damit schon vor
* und wenn die nicht auf https zeigt, sagt das Skript es laut. * dem Produktivgang und muss danach nicht neu erzeugt werden.
*/ */
if (PHP_SAPI !== 'cli') { if (PHP_SAPI !== 'cli') {
exit(1); exit(1);
@@ -45,7 +45,7 @@ if ($args === []) {
$target = $args[0]; $target = $args[0];
$isAbsolute = str_starts_with($target, 'http://') || str_starts_with($target, 'https://'); $isAbsolute = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
$url = $isAbsolute ? $target : abs_url(trim($target, '/')); $url = $isAbsolute ? $target : club_url(trim($target, '/'));
// Zielpfad: ohne Angabe neben das Skript-Verzeichnis in storage/qr/ ablegen — // Zielpfad: ohne Angabe neben das Skript-Verzeichnis in storage/qr/ ablegen —
// Druckmaterial ist kein Website-Asset und hat unter public/ nichts zu suchen. // Druckmaterial ist kein Website-Asset und hat unter public/ nichts zu suchen.
@@ -59,11 +59,11 @@ if ($dest === null) {
$dest = $dir . '/qr-' . trim($name, '-') . '.png'; $dest = $dir . '/qr-' . trim($name, '-') . '.png';
} }
if (!$isAbsolute && !str_starts_with((string) config('base_url'), 'https://')) { // Der Riegel bleibt, nur zeigt er jetzt auf das richtige Feld: ohne saubere
fwrite(STDERR, "WARNUNG base_url ist '" . (string) config('base_url') . "' — dieser QR-Code führt NICHT auf die echte Seite\n"); // club.website wäre der gedruckte Code wieder unbrauchbar.
fwrite(STDERR, " und ist als Druckvorlage unbrauchbar. Entweder die Produktions-Config verwenden\n"); if (!$isAbsolute && !str_starts_with($url, 'https://')) {
fwrite(STDERR, " oder die vollständige URL direkt übergeben:\n"); fwrite(STDERR, "WARNUNG club.website in data/club.json ist nicht https ('{$url}') — dieser QR-Code\n");
fwrite(STDERR, " php bin/qr.php https://tsv08kulmbach.de/{$target}\n\n"); fwrite(STDERR, " führt nicht auf die echte Seite und ist als Druckvorlage unbrauchbar.\n\n");
} }
if (!generate_qr_png($url, $dest, $scale)) { if (!generate_qr_png($url, $dest, $scale)) {

View File

@@ -19,18 +19,20 @@ declare(strict_types=1);
* <ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz) * <ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz)
* fürs Cover — wird unverändert kopiert, nie über GD neu kodiert * fürs Cover — wird unverändert kopiert, nie über GD neu kodiert
* <ordner>/verfasser.* optional, Porträtfoto für die Vorwort-Signatur * <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 → * KEINE Anzeigen im Ordner (seit 31.07.2026): die kommen aus dem dauerhaften
* Ergebnis-Rückblick (live) → Anzeigen A → je Mannschaft eine Tabelle (live, * Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive
* Reihenfolge = data/matchcenter.json) → Vorschau (live) → Anzeigen B → * ändern sich selten, das Heft erscheint oft — vorher lag jede Anzeige als Kopie
* Termine (live) → Anzeigen C → News (live) → Ansprechpartner (live) → * unter jeder Ausgabe und ihr Alt-Text musste jedes Mal neu geschrieben werden.
* Anzeigen D → Rückseite. Das Cover wird automatisch generiert (siehe *
* app/components/stadionzeitung-cover.php) — kein Canva-Bild mehr nötig. Der * Feste Vorlage (Interview-Beschluss 29.07.2026, Anzeigen 31.07.2026 überarbeitet):
* gesamte Anzeigen-Pool (in gelieferter Reihenfolge) wird zu möglichst * Der Inhalt ist eine Liste von BLÖCKEN, die Anzeigen werden gleichmäßig in die
* gleichen Teilen auf die vier Gruppen verteilt. * 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).
* *
* Neue Ausgaben entstehen als ENTWURF (status: entwurf): unsichtbar in * Neue Ausgaben entstehen als ENTWURF (status: entwurf): unsichtbar in
* Archiv/Sitemap, Live-Seiten rechnen bei jedem Aufruf frisch. Erst * Archiv/Sitemap, Live-Seiten rechnen bei jedem Aufruf frisch. Erst
@@ -500,10 +502,13 @@ if ($spieler !== null) {
} }
// --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie. // --- 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).
// QR ist "nice to have" — schlägt die Erzeugung fehl, läuft die Ausgabe ohne weiter. --- // QR ist "nice to have" — schlägt die Erzeugung fehl, läuft die Ausgabe ohne weiter. ---
$qrImage = null; $qrImage = null;
$qrDest = "{$destDir}/qr.png"; $qrDest = "{$destDir}/qr.png";
if (generate_qr_png(abs_url('stadionzeitung/' . $slug), $qrDest)) { if (generate_qr_png(club_url('stadionzeitung/' . $slug), $qrDest)) {
$qrImage = "img/stadionzeitung/{$slug}/qr.png"; $qrImage = "img/stadionzeitung/{$slug}/qr.png";
$log('QR-Code erzeugt.'); $log('QR-Code erzeugt.');
} else { } else {
@@ -511,11 +516,11 @@ if (generate_qr_png(abs_url('stadionzeitung/' . $slug), $qrDest)) {
} }
// Geteilte QRs der CTA-Füße (Kontakt → /mitmachen, News → /news, Termine → // Geteilte QRs der CTA-Füße (Kontakt → /mitmachen, News → /news, Termine →
// /termine, Vorschau → /matchcenter, für alle Ausgaben gleich) — werden bei // /termine, Vorschau → /matchcenter, für alle Ausgaben gleich) — ebenfalls über
// jedem Lauf mit der aktuellen base_url neu geschrieben, damit sie nach dem // club_url(), damit sie unabhängig von der Umgebung stimmen und nach dem
// Produktivgang automatisch auf die echte Domain zeigen. // Produktivgang nicht neu erzeugt werden müssen.
foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) { 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")) { if (generate_qr_png(club_url($ctaTarget), PUBLIC_PATH . "/assets/img/stadionzeitung/qr-{$ctaTarget}.png")) {
$log("QR-Code für /{$ctaTarget} erzeugt."); $log("QR-Code für /{$ctaTarget} erzeugt.");
} else { } else {
$log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN'); $log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN');

View File

@@ -1,71 +1,72 @@
{ {
"name": "TSV 08 Kulmbach", "name": "TSV 08 Kulmbach",
"legal_name": "TSV 1908 Kulmbach e. V.", "legal_name": "TSV 1908 Kulmbach e. V.",
"founded": "1908", "founded": "1908",
"description": "Wir sind dein Sportverein für Fußball und Turnen in Kulmbach: gegründet 1908, heute zuhause im Katzbachtal, mit Jugend-, Herren- und Frauenfußball, Turnen und Trampolin.", "description": "Wir sind dein Sportverein für Fußball und Turnen in Kulmbach: gegründet 1908, heute zuhause im Katzbachtal, mit Jugend-, Herren- und Frauenfußball, Turnen und Trampolin.",
"slogan": "Gemeinsam. Immer weiter.", "slogan": "Gemeinsam. Immer weiter.",
"departments": [ "website": "https://www.tsv08kulmbach.de",
"Fußball", "departments": [
"Turnen", "Fußball",
"Trampolin" "Turnen",
], "Trampolin"
"address": { ],
"venue": "Hans-Rausch-Sportanlage", "address": {
"street": "Thurnauer Str. 51", "venue": "Hans-Rausch-Sportanlage",
"zip": "95326", "street": "Thurnauer Str. 51",
"city": "Kulmbach", "zip": "95326",
"country": "DE" "city": "Kulmbach",
}, "country": "DE"
"geo": {
"lat": 50.095,
"lng": 11.4372
},
"email": "info@tsv08kulmbach.de",
"email_turnen": "turnen@tsv08kulmbach.de",
"email_historie": "historie@tsv08kulmbach.de",
"board": {
"note": "Vertretungsberechtigter Vorstand gemäß § 26 BGB",
"members": [
{
"name": "Kai van Avondt",
"role": "1. Vorsitzender"
},
{
"name": "Christian Ohnemüller",
"role": "2. Vorsitzender"
},
{
"name": "Matthias Korzonek",
"role": ""
},
{
"name": "Michael Senar",
"role": ""
}
]
},
"banking": [
{
"bank": "Sparkasse Kulmbach-Kronach",
"iban": "DE11 7715 0000 0000 1542 52",
"bic": "BYLADEM1KUB"
}, },
{ "geo": {
"bank": "VR Bank Oberfranken Mitte", "lat": 50.095,
"iban": "DE90 7719 0000 0000 0362 18", "lng": 11.4372
"bic": "GENODEF1KU1" },
"email": "info@tsv08kulmbach.de",
"email_turnen": "turnen@tsv08kulmbach.de",
"email_historie": "historie@tsv08kulmbach.de",
"board": {
"note": "Vertretungsberechtigter Vorstand gemäß § 26 BGB",
"members": [
{
"name": "Kai van Avondt",
"role": "1. Vorsitzender"
},
{
"name": "Christian Ohnemüller",
"role": "2. Vorsitzender"
},
{
"name": "Matthias Korzonek",
"role": ""
},
{
"name": "Michael Senar",
"role": ""
}
]
},
"banking": [
{
"bank": "Sparkasse Kulmbach-Kronach",
"iban": "DE11 7715 0000 0000 1542 52",
"bic": "BYLADEM1KUB"
},
{
"bank": "VR Bank Oberfranken Mitte",
"iban": "DE90 7719 0000 0000 0362 18",
"bic": "GENODEF1KU1"
}
],
"data_protection_contact": "Fabian Früh",
"webdesign": "Felix Schneider",
"memberships": [
"Bayerischer Landes-Sportverband",
"Bayerischer Fußball-Verband",
"Bayerischer Turnverband"
],
"social": {
"instagram": "https://www.instagram.com/tsv08kulmbach/",
"facebook": "https://www.facebook.com/tsv1908kulmbach/",
"youtube": null
} }
], }
"data_protection_contact": "Fabian Früh",
"webdesign": "Felix Schneider",
"memberships": [
"Bayerischer Landes-Sportverband",
"Bayerischer Fußball-Verband",
"Bayerischer Turnverband"
],
"social": {
"instagram": "https://www.instagram.com/tsv08kulmbach/",
"facebook": "https://www.facebook.com/tsv1908kulmbach/",
"youtube": null
}
}

View File

@@ -1134,7 +1134,7 @@
} }
] ]
}, },
"published_at": "2026-07-31T12:40:07+00:00" "published_at": "2026-07-31T13:34:54+00:00"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 B

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 B

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

After

Width:  |  Height:  |  Size: 426 B