Compare commits

..

7 Commits

174 changed files with 4016 additions and 9 deletions

View File

@@ -54,7 +54,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Partner/Sponsoren | `data/partners.json` | — | | Partner/Sponsoren | `data/partners.json` | — |
| Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen | | Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen |
| Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) | | Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) |
| Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`), `cover` (`teaser` einer Sonderausgabe), `opponent_short` (gedruckter Anzeigename des Gegners) und `gegner_teams` (weitere Mannschaften mit Gegnerseite).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt | | Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `beitrag` (Text/Quelle/Bildnachweis; `image` bleibt script-verwaltet), `rueckblick` (Text/Bildnachweis; `image` bleibt script-verwaltet), `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`), `cover` (`teaser` einer Sonderausgabe), `opponent_short` (gedruckter Anzeigename des Gegners) und `gegner_teams` (weitere Mannschaften mit Gegnerseite).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt |
| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen | | Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen |
| Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) | | Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) |
| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) | | Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) |
@@ -521,6 +521,19 @@ Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
## Stadionzeitung (Ausgaben-Archiv) ## Stadionzeitung (Ausgaben-Archiv)
**CLI-Abrufe bei lokalem DNS-Ausfall:** `TSV_HTTP_DOH_URL=https://1.1.1.1/dns-query`
kann für `bin/matchcenter-sync.php` und `bin/instagram-add.php` gesetzt werden.
Optional, keine Änderung am System-DNS; Cron/Web ignoriert den Override.
**Redaktioneller Lesetipp (24.10.2026):** optionales chat-gepflegtes `beitrag`-Feld
(`kicker`, `heading`, `paragraphs[]`, `source`, `source_url` als HTTPS-Adresse,
`cta_label`, `image_credit`, `image.alt`). `bin/stadionzeitung-add.php` übernimmt
das Feld über erneute Läufe, schreibt `image` aus `<ordner>/beitrag.*` und erzeugt
`qr-beitrag.png` direkt zur externen Quelle. Die Seite steht im anzeigenfreien
Aufschlag hinter Vorwort/Interview; `cover.teaser` kündigt sie unabhängig von
einem Interview an. Rendering gemeinsam für Viewer/Druck über
`stadionzeitung-beitrag.php`; Preflight prüft Quelle, Foto und QR.
Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) als Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) als
**Liste über die volle Breite, Cover links und die Angaben rechts daneben** (dasselbe Muster wie **Liste über die volle Breite, Cover links und die Angaben rechts daneben** (dasselbe Muster wie
`.veranstaltung-card`, und aus demselben Grund: der Bestand ist klein). Vorher war das ein `.veranstaltung-card`, und aus demselben Grund: der Bestand ist klein). Vorher war das ein
@@ -834,6 +847,19 @@ Raster: 6×4-Gitter, das erste Foto 4×2, die Anordnung je Anzahl (1 bis 7) als
`__bento--nN` mit `grid-template-areas`. Entstanden als Füllseite, die eine 43-seitige Ausgabe `__bento--nN` mit `grid-template-areas`. Entstanden als Füllseite, die eine 43-seitige Ausgabe
auf 44 brachte. auf 44 brachte.
**Redaktioneller Spielrückblick (16.10.2026).** Optionales Feld `rueckblick` einer Ausgabe:
`kicker`, `heading`, `meta`, `result`, `paragraphs[]`, `credit`, `source_url`
sind Chat-Pflege; `image` (inklusive chat-gepflegtem Alt-Text) verwaltet das Add-Skript aus
`<ordner>/rueckblick.*`. Die Live-Seite steht nach den Ergebnissen, vor der Galerie. Das Foto
liegt dauerhaft im Ausgaben-Ordner. Bei erneutem Erzeugen muss die Quelle wieder mitgeliefert
werden; fehlt sie, bricht das Skript vor dem Leeren des Zielordners ab. Preflight prüft Foto
und Bildnachweis. Der gemeinsame Bild-/Textnachweis steht einmal direkt unter dem Foto
(im Web zur Quelle verlinkt, auch im Druck lesbar). Kein Bild hochskalieren oder fremd einbinden.
Für die Untersteinach-Ausgabe: öffentliches Derby-Vorschaubild
`https://www.anpfiff.info/upload/images/Teaser8/843447.jpg` (560 × 270), Bildquelle anpfiff.info.
Felix hat die Nutzungsgenehmigung im Chat am 16.10.2026 bestätigt. Ein individueller Fotograf
ist am frei sichtbaren Bild nicht ausgewiesen; deshalb keinen Namen vermuten.
**Gegner- und Ergebnisseite neu gestaltet (19.09.2026).** Beide Seiten waren inhaltlich richtig und **Gegner- und Ergebnisseite neu gestaltet (19.09.2026).** Beide Seiten waren inhaltlich richtig und
gestalterisch leer, der Umbau lief über mehrere verworfene Entwürfe. Was davon bleibt: gestalterisch leer, der Umbau lief über mehrere verworfene Entwürfe. Was davon bleibt:
- **Ergebnisse als Anzeigetafel** (Vorbild kicker/Sofascore): Wappen und Name je Seite, das Ergebnis - **Ergebnisse als Anzeigetafel** (Vorbild kicker/Sofascore): Wappen und Name je Seite, das Ergebnis

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/** Redaktioneller Lesetipp. Text/Quelle: Chat-Pflege; Foto/QR: add-Skript. */
$beitrag = $issue['beitrag'] ?? null;
if (!is_array($beitrag)) {
return;
}
$sourceUrl = (string) ($beitrag['source_url'] ?? '');
$validUrl = filter_var($sourceUrl, FILTER_VALIDATE_URL)
&& parse_url($sourceUrl, PHP_URL_SCHEME) === 'https';
$qrPath = 'img/stadionzeitung/' . (string) ($issue['slug'] ?? '') . '/qr-beitrag.png';
?>
<div class="stadionzeitung-beitrag">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker"><?= e((string) ($beitrag['kicker'] ?? 'Lesetipp')) ?></p>
<h2 class="stadionzeitung-page__heading"><?= e((string) ($beitrag['heading'] ?? '')) ?></h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<figure class="stadionzeitung-beitrag__figure">
<?php component('img', ['image' => $beitrag['image'] ?? [], 'sizes' => '(min-width: 520px) 430px, 90vw', 'class' => 'stadionzeitung-beitrag__foto', 'eager' => $eager ?? false]); ?>
<figcaption class="stadionzeitung-rueckblick__credit"><?= e((string) ($beitrag['image_credit'] ?? '')) ?></figcaption>
</figure>
<?php foreach ($beitrag['paragraphs'] ?? [] as $paragraph): ?>
<p class="stadionzeitung-vorwort__text"><?= e((string) $paragraph) ?></p>
<?php endforeach; ?>
<p class="stadionzeitung-beitrag__source"><?= e((string) ($beitrag['source'] ?? '')) ?></p>
<?php if ($validUrl): ?>
<a class="stadionzeitung-page__cta" href="<?= e($sourceUrl) ?>" target="_blank" rel="noopener noreferrer">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label"><?= e((string) ($beitrag['cta_label'] ?? 'Lesetipp')) ?></p>
<p class="stadionzeitung-page__cta-title">Den vollständigen Beitrag lesen</p>
<p class="stadionzeitung-page__cta-link"><?= e((string) parse_url($sourceUrl, PHP_URL_HOST)) ?></p>
</div>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $qrPath)): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qrPath)) ?>" alt="QR-Code zum Originalartikel" width="100" height="100" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Zum Artikel</span>
</div>
<?php endif; ?>
</a>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/** Redaktioneller Spielrückblick. Text/Quelle: Chat-Pflege; image: add-Skript. */
$rueckblick = $issue['rueckblick'] ?? [];
$sourceUrl = (string) ($rueckblick['source_url'] ?? '');
$sourceLink = filter_var($sourceUrl, FILTER_VALIDATE_URL)
&& in_array(parse_url($sourceUrl, PHP_URL_SCHEME), ['https', 'http'], true);
?>
<div class="stadionzeitung-rueckblick">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker"><?= e((string) ($rueckblick['kicker'] ?? 'Rückblick')) ?></p>
<h2 class="stadionzeitung-page__heading"><?= e((string) ($rueckblick['heading'] ?? '')) ?></h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<div class="stadionzeitung-rueckblick__scoreline">
<p class="stadionzeitung-ergebnisse__result"><?= e((string) ($rueckblick['result'] ?? '')) ?></p>
<p class="stadionzeitung-page__sub"><?= e((string) ($rueckblick['meta'] ?? '')) ?></p>
</div>
<figure class="stadionzeitung-rueckblick__figure">
<?php component('img', [
'image' => $rueckblick['image'],
'sizes' => '(min-width: 520px) 430px, 90vw',
'class' => 'stadionzeitung-rueckblick__foto',
'eager' => $eager ?? false,
]); ?>
<figcaption class="stadionzeitung-rueckblick__credit">
<?php if ($sourceLink): ?>
<a href="<?= e($sourceUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e((string) ($rueckblick['credit'] ?? '')) ?></a>
<?php else: ?>
<?= e((string) ($rueckblick['credit'] ?? '')) ?>
<?php endif; ?>
</figcaption>
</figure>
<div class="stadionzeitung-rueckblick__text">
<?php foreach ($rueckblick['paragraphs'] ?? [] as $paragraph): ?>
<p class="stadionzeitung-vorwort__text"><?= e((string) $paragraph) ?></p>
<?php endforeach; ?>
</div>
</div>

View File

@@ -50,6 +50,9 @@ $type = $entry['type'] ?? 'image';
<?php case 'interview': ?> <?php case 'interview': ?>
<?php component('stadionzeitung-interview', ['issue' => $issue]); ?> <?php component('stadionzeitung-interview', ['issue' => $issue]); ?>
<?php break; ?> <?php break; ?>
<?php case 'beitrag': ?>
<?php component('stadionzeitung-beitrag', ['issue' => $issue, 'eager' => $eager]); ?>
<?php break; ?>
<?php case 'aufruf': ?> <?php case 'aufruf': ?>
<?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?> <?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?>
<?php break; ?> <?php break; ?>
@@ -100,6 +103,9 @@ $type = $entry['type'] ?? 'image';
<?php case 'galerie': ?> <?php case 'galerie': ?>
<?php component('stadionzeitung-galerie', ['issue' => $issue]); ?> <?php component('stadionzeitung-galerie', ['issue' => $issue]); ?>
<?php break; ?> <?php break; ?>
<?php case 'rueckblick': ?>
<?php component('stadionzeitung-rueckblick', ['issue' => $issue, 'eager' => $eager]); ?>
<?php break; ?>
<?php case 'momente': ?> <?php case 'momente': ?>
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?> <?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
<?php break; ?> <?php break; ?>

View File

@@ -149,6 +149,11 @@ if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
// --- HTTP-Helfer, unverändert aus bin/instagram-sync.php übernommen --- // --- HTTP-Helfer, unverändert aus bin/instagram-sync.php übernommen ---
$fetch = static function (string $url, array $headers = []): string|false { $fetch = static function (string $url, array $headers = []): string|false {
$ch = curl_init($url); $ch = curl_init($url);
// Optional wie im Matchcenter-Sync, wenn der lokale DNS-Dienst ausfällt.
$dohUrl = getenv('TSV_HTTP_DOH_URL') ?: '';
if ($dohUrl !== '') {
curl_setopt($ch, CURLOPT_DOH_URL, $dohUrl);
}
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,

View File

@@ -94,6 +94,11 @@ if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) --- // --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
$fetch = static function (string $url, bool $json = true) use ($referer): string|false { $fetch = static function (string $url, bool $json = true) use ($referer): string|false {
$ch = curl_init($url); $ch = curl_init($url);
// Optionaler CLI-Ausweg bei defektem lokalem DNS; keine Systemänderung.
$dohUrl = PHP_SAPI === 'cli' ? (getenv('TSV_HTTP_DOH_URL') ?: '') : '';
if ($dohUrl !== '') {
curl_setopt($ch, CURLOPT_DOH_URL, $dohUrl);
}
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,

View File

@@ -393,6 +393,45 @@ if (is_file($szFile)) {
if (is_file($szFile)) { if (is_file($szFile)) {
$szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []); $szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []);
foreach ($szData['issues'] ?? [] as $ausgabe) { foreach ($szData['issues'] ?? [] as $ausgabe) {
if (is_array($ausgabe['beitrag'] ?? null)) {
$beitrag = $ausgabe['beitrag'];
foreach (['heading', 'source', 'source_url', 'image_credit'] as $feld) {
if (trim((string) ($beitrag[$feld] ?? '')) === '') {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrag ohne {$feld}.";
}
}
$quelle = (string) ($beitrag['source_url'] ?? '');
if (!filter_var($quelle, FILTER_VALIDATE_URL) || parse_url($quelle, PHP_URL_SCHEME) !== 'https') {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrag mit ungültiger Quellen-URL.";
}
$foto = $beitrag['image'] ?? [];
if (empty($foto['base']) || empty($foto['widths']) || empty($foto['alt'])) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitragsfoto oder Alt-Text fehlt.";
}
foreach ($foto['widths'] ?? [] as $w) {
if (!is_file(PUBLIC_PATH . '/assets/' . ($foto['base'] ?? '') . "-{$w}.jpg")) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitragsfoto fehlt ({$w}px).";
}
}
if (!is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/' . $ausgabe['slug'] . '/qr-beitrag.png')) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrags-QR fehlt.";
}
}
if (is_array($ausgabe['rueckblick'] ?? null)) {
$rueckblick = $ausgabe['rueckblick'];
$foto = $rueckblick['image'] ?? [];
if (empty($foto['base']) || empty($foto['widths'])) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Foto zum Spielrückblick fehlt.";
}
foreach ($foto['widths'] ?? [] as $w) {
if (!is_file(PUBLIC_PATH . '/assets/' . ($foto['base'] ?? '') . "-{$w}.jpg")) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Rückblick-Foto fehlt ({$w}px).";
}
}
if (trim((string) ($rueckblick['credit'] ?? '')) === '') {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Bildnachweis zum Spielrückblick fehlt.";
}
}
foreach ($ausgabe['galerie']['bilder'] ?? [] as $bild) { foreach ($ausgabe['galerie']['bilder'] ?? [] as $bild) {
foreach ($bild['widths'] ?? [] as $w) { foreach ($bild['widths'] ?? [] as $w) {
$datei = PUBLIC_PATH . '/assets/' . ($bild['base'] ?? '') . "-{$w}.jpg"; $datei = PUBLIC_PATH . '/assets/' . ($bild['base'] ?? '') . "-{$w}.jpg";

View File

@@ -160,6 +160,8 @@ $vorwort = $findOne('vorwort');
$rueckseite = $findOne('rueckseite'); $rueckseite = $findOne('rueckseite');
$spieler = $findOne('spieler'); $spieler = $findOne('spieler');
$verfasser = $findOne('verfasser'); $verfasser = $findOne('verfasser');
$rueckblickFoto = $findOne('rueckblick');
$beitragFoto = $findOne('beitrag');
$galerieQuellen = $findAll('galerie'); $galerieQuellen = $findAll('galerie');
// Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften // Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften
// Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch // Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch
@@ -200,6 +202,16 @@ foreach ($existing['issues'] ?? [] as $entry) {
$oldVorwort = $oldIssue['vorwort'] ?? null; $oldVorwort = $oldIssue['vorwort'] ?? null;
// Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso. // Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso.
$oldInterview = $oldIssue['interview'] ?? null; $oldInterview = $oldIssue['interview'] ?? null;
$beitrag = $oldIssue['beitrag'] ?? null;
if (is_array($beitrag) && $beitragFoto === null) {
$fail('Redaktioneller Beitrag vorhanden, aber beitrag.* fehlt im Ablieferordner.');
}
if (is_array($beitrag)) {
$beitragLink = trim((string) ($beitrag['source_url'] ?? ''));
if (!filter_var($beitragLink, FILTER_VALIDATE_URL) || parse_url($beitragLink, PHP_URL_SCHEME) !== 'https') {
$fail('Beitrag: source_url muss eine gültige HTTPS-Adresse sein.');
}
}
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt. // Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
$oldAufruf = $oldIssue['aufruf'] ?? null; $oldAufruf = $oldIssue['aufruf'] ?? null;
// Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm, // Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm,
@@ -213,6 +225,10 @@ $oldOpponentShort = $oldIssue['opponent_short'] ?? null;
// Galerie-Seite: Kopf (kicker/heading/text) und Alt-Texte sind Chat-Pflege, // Galerie-Seite: Kopf (kicker/heading/text) und Alt-Texte sind Chat-Pflege,
// die Bilder schreibt dieser Lauf aus <ordner>/galerie/ (siehe unten). // die Bilder schreibt dieser Lauf aus <ordner>/galerie/ (siehe unten).
$oldGalerie = is_array($oldIssue['galerie'] ?? null) ? $oldIssue['galerie'] : null; $oldGalerie = is_array($oldIssue['galerie'] ?? null) ? $oldIssue['galerie'] : null;
$rueckblick = is_array($oldIssue['rueckblick'] ?? null) ? $oldIssue['rueckblick'] : null;
if ($rueckblick !== null && $rueckblickFoto === null) {
$fail('Spielrückblick vorhanden, aber rueckblick.* fehlt im Ablieferordner.');
}
$oldGegnerTeams = is_array($oldIssue['gegner_teams'] ?? null) ? array_values($oldIssue['gegner_teams']) : null; $oldGegnerTeams = is_array($oldIssue['gegner_teams'] ?? null) ? array_values($oldIssue['gegner_teams']) : null;
$veranstaltung = null; $veranstaltung = null;
if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') { if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') {
@@ -412,6 +428,10 @@ try {
if ($oldInterview !== null) { if ($oldInterview !== null) {
$aufschlag[] = $live('interview'); $aufschlag[] = $live('interview');
} }
if (is_array($beitrag)) {
$beitrag['image'] = $bild($beitragFoto, 'beitrag', (string) ($beitrag['image']['alt'] ?? $beitrag['heading'] ?? 'Vereinsbeitrag'));
$aufschlag[] = $live('beitrag');
}
// Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien // Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien
// Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller // Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller
// Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld). // Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld).
@@ -437,6 +457,13 @@ try {
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true]; $bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
// Redaktioneller Spielrückblick: Text und Bildnachweis sind Chat-Pflege,
// das Foto kommt aus dem Ablieferordner und bleibt in dieser Ausgabe.
if ($rueckblick !== null) {
$rueckblick['image'] = $bild($rueckblickFoto, 'rueckblick', (string) ($rueckblick['image']['alt'] ?? $rueckblick['heading'] ?? 'Spielrückblick'));
$bloecke[] = ['name' => 'rueckblick', 'seiten' => [$live('rueckblick')], 'anzeigen_danach' => true];
}
// Galerie direkt hinter dem Ergebnis-Rückblick: erst das Ergebnis, dann die // Galerie direkt hinter dem Ergebnis-Rückblick: erst das Ergebnis, dann die
// Bilder dazu. Eigener Block, eine Anzeige dazwischen ist unbedenklich. // Bilder dazu. Eigener Block, eine Anzeige dazwischen ist unbedenklich.
if (!empty($galerie['bilder'])) { if (!empty($galerie['bilder'])) {
@@ -749,6 +776,12 @@ foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'his
// (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url() // (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. // wie überall, damit der gedruckte Code auf die echte Domain zeigt.
$aufrufLink = trim((string) ($oldAufruf['link'] ?? '')); $aufrufLink = trim((string) ($oldAufruf['link'] ?? ''));
$beitragLink = trim((string) ($beitrag['source_url'] ?? ''));
if ($beitragLink !== '') {
if (!generate_qr_png($beitragLink, "{$destDir}/qr-beitrag.png")) {
$fail('QR-Code für den redaktionellen Beitrag konnte nicht erzeugt werden.');
}
}
if ($aufrufLink !== '') { if ($aufrufLink !== '') {
if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) { if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) {
$log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt."); $log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt.");
@@ -826,10 +859,12 @@ $issues[] = [
'qr_image' => $qrImage, 'qr_image' => $qrImage,
'vorwort' => $oldVorwort, 'vorwort' => $oldVorwort,
'interview' => $oldInterview, 'interview' => $oldInterview,
'beitrag' => $beitrag,
'aufruf' => $oldAufruf, 'aufruf' => $oldAufruf,
'veranstaltung' => $oldVeranstaltung, 'veranstaltung' => $oldVeranstaltung,
'cover' => $oldCover, 'cover' => $oldCover,
'galerie' => $galerie, 'galerie' => $galerie,
'rueckblick' => $rueckblick,
'verfasser_photo' => $verfasserPhoto, 'verfasser_photo' => $verfasserPhoto,
'termine_snapshot' => $termineSnapshot, 'termine_snapshot' => $termineSnapshot,
'images' => $images, 'images' => $images,

View File

@@ -1,7 +1,106 @@
{ {
"fetched_at": 1789636733, "fetched_at": 1792834512,
"username": "tsv08kulmbach", "username": "tsv08kulmbach",
"posts": [ "posts": [
{
"shortcode": "De0vwrajLXG",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1792731262,
"is_video": false,
"image": "img/instagram/De0vwrajLXG.jpg",
"url": "https://www.instagram.com/p/De0vwrajLXG/"
},
{
"shortcode": "Derh4uhDLDh",
"caption": "🔴⚫️ Ergebnisse \nKann sich sehen lassen 🙌",
"alt": "🔴⚫️ Ergebnisse",
"taken_at": 1792421998,
"is_video": false,
"image": "img/instagram/Derh4uhDLDh.jpg",
"url": "https://www.instagram.com/p/Derh4uhDLDh/"
},
{
"shortcode": "DehQgDrDB0Z",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1792077339,
"is_video": false,
"image": "img/instagram/DehQgDrDB0Z.jpg",
"url": "https://www.instagram.com/p/DehQgDrDB0Z/"
},
{
"shortcode": "DeeldfjMZEm",
"caption": "Neue Trikots für unsere E-Jugend! ⚽️😍\n„Euer Ort, euer Verein, euer Trikot“ – unter diesem Motto sorgt die bundesweite TUI-Aktion auch beim TSV 08 Kulmbach für strahlende Gesichter! 👕😊\nDas ReiseCenter Schaffranek sicherte sich unter mehr als 500 Bewerbern einen von 200 Trikotsätzen. Wer sich darüber freuen durfte, entschieden anschließend die Verantwortlichen im ReiseCenter per Los: Sie befüllten die Lostrommel mit den Namen aller Vereine im Kulmbacher Stadtgebiet, die Jugendarbeit bis zur E-Jugend anbieten, und führten die Ziehung durch. Das gezogene Los: TSV 08 Kulmbach! 🍀🙌\nMelanie Schaffranek überreichte die neuen Trikots bei unserem E-Jugendfestival und konnte unsere Nachwuchskicker direkt darin in Aktion erleben. ⚽\nEin herzliches Dankeschön an TUI sowie an Melanie Schaffranek und das gesamte Team des ReiseCenter Schaffranek für diese tolle Unterstützung unserer Jugendarbeit! ❤️🖤\nUnsere E-Jugend trägt das TUI-Lächeln ab sofort auf dem Trikot – und die Freude darüber im Gesicht! 😃\n\n#tsv08kulmbach #jugendfussball #reisetui \nDanke @reisebuero_schaffranek",
"alt": "Neue Trikots für unsere E-Jugend! ⚽️😍",
"taken_at": 1791987664,
"is_video": false,
"image": "img/instagram/DeeldfjMZEm.jpg",
"url": "https://www.instagram.com/p/DeeldfjMZEm/"
},
{
"shortcode": "DePJUbkjKPc",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1791469594,
"is_video": false,
"image": "img/instagram/DePJUbkjKPc.jpg",
"url": "https://www.instagram.com/p/DePJUbkjKPc/"
},
{
"shortcode": "DeG8wTzjAVR",
"caption": "Ergebnisse vom Wochenende 🔴⚫️\n\nDerbysieg für unsere Erste 🤹🏻‍♂️ und unsere Frauen bleiben weiterhin ungeschlagen! 💪🏻",
"alt": "Ergebnisse vom Wochenende 🔴⚫️",
"taken_at": 1791194571,
"is_video": false,
"image": "img/instagram/DeG8wTzjAVR.jpg",
"url": "https://www.instagram.com/p/DeG8wTzjAVR/"
},
{
"shortcode": "Dd9tmXplrZx",
"caption": "🔴⚫️ Vorschau inkl. Stadtderby 🔥",
"alt": "🔴⚫️ Vorschau inkl. Stadtderby 🔥",
"taken_at": 1790884635,
"is_video": false,
"image": "img/instagram/Dd9tmXplrZx.jpg",
"url": "https://www.instagram.com/p/Dd9tmXplrZx/"
},
{
"shortcode": "Dd1S1UkDOfO",
"caption": "🔴⚫️ Ergebnisse",
"alt": "🔴⚫️ Ergebnisse",
"taken_at": 1790602166,
"is_video": false,
"image": "img/instagram/Dd1S1UkDOfO.jpg",
"url": "https://www.instagram.com/p/Dd1S1UkDOfO/"
},
{
"shortcode": "DdtJFZajIds",
"caption": "🔴⚫️-Vorschau",
"alt": "🔴⚫️-Vorschau",
"taken_at": 1790328620,
"is_video": false,
"image": "img/instagram/DdtJFZajIds.jpg",
"url": "https://www.instagram.com/p/DdtJFZajIds/"
},
{
"shortcode": "Ddjq90MjLvH",
"caption": "Maximale Punkteausbeute für uns am Wochenende!\n❤️🖤",
"alt": "Maximale Punkteausbeute für uns am Wochenende!",
"taken_at": 1790010839,
"is_video": false,
"image": "img/instagram/Ddjq90MjLvH.jpg",
"url": "https://www.instagram.com/p/Ddjq90MjLvH/"
},
{
"shortcode": "DdZ3vzhDAZS",
"caption": "🔴⚫️ Vorschau",
"alt": "🔴⚫️ Vorschau",
"taken_at": 1789681996,
"is_video": false,
"image": "img/instagram/DdZ3vzhDAZS.jpg",
"url": "https://www.instagram.com/p/DdZ3vzhDAZS/"
},
{ {
"shortcode": "DdROjQqjI1_", "shortcode": "DdROjQqjI1_",
"caption": "Ergebnisse 🔴⚫️", "caption": "Ergebnisse 🔴⚫️",

File diff suppressed because it is too large Load Diff

View File

@@ -132,8 +132,9 @@
], ],
"recommendation": "Bei besonderem Potenzial für Wettkampf- oder Leistungsgruppen sprechen unsere Trainer eine Empfehlung aus.", "recommendation": "Bei besonderem Potenzial für Wettkampf- oder Leistungsgruppen sprechen unsere Trainer eine Empfehlung aus.",
"sessions": [ "sessions": [
{ "label": "Mäuse I", "day": "Dienstag", "time": "17:30–18:30 Uhr", "trainers": ["Maxi Roßberg*", "Katrin und Marcus Braese"] }, { "label": "Mäuse I", "day": "Dienstag", "time": "17:30–18:30 Uhr", "trainers": ["Maxi Roßberg*", "Katrin und Markus Braese", "Kristina Dorsch", "Katja Knappich"] },
{ "label": "Mäuse II", "day": "Donnerstag", "time": "16:30–17:30 Uhr", "trainers": ["Franziska Trotzke"] } { "label": "Mäuse II", "day": "Donnerstag", "time": "16:30–17:30 Uhr", "trainers": ["Franziska Trotzke", "Lisa Benker"] },
{ "label": "Mäuse III", "day": "Freitag", "time": "17:00–18:00 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch*"] }
] ]
}, },
{ {
@@ -211,9 +212,9 @@
"contact_email": "tina.kornatz@gmx.de", "contact_email": "tina.kornatz@gmx.de",
"contact_phone": "0163 6032410", "contact_phone": "0163 6032410",
"footnote": "In den Schulferien findet kein Training statt.", "footnote": "In den Schulferien findet kein Training statt.",
"notice_icon": "calendar-event", "notice_tone": "stop",
"notice_title": "Schnuppern nach Absprache", "notice_title": "Hinweis zur Warteliste",
"notice": "Schnuppertraining ist bei uns jederzeit nach vorheriger Absprache möglich. Melde dich davor einfach kurz bei Tina, dann finden wir einen passenden Termin.", "notice": "Aktuell sind alle Plätze beim Trampolinturnen vergeben. Wir nehmen dich oder dein Kind aber gerne auf die Warteliste. Melde dich dafür einfach kurz bei Tina.",
"groups": [ "groups": [
{ {
"name": "Kinder und Erwachsene", "name": "Kinder und Erwachsene",

View File

@@ -8628,6 +8628,85 @@ p.stadionzeitung-turnier__label:first-of-type {
object-fit: cover; object-fit: cover;
} }
/* === stadionzeitung-beitrag (redaktioneller Lesetipp) === */
.stadionzeitung-beitrag {
display: flex;
flex-direction: column;
min-height: 100%;
}
.stadionzeitung-beitrag__figure {
margin: 0 0 3cqi;
}
.stadionzeitung-beitrag .stadionzeitung-page__heading {
font-size: 7cqi;
}
.stadionzeitung-beitrag .stadionzeitung-vorwort__text {
font-size: 3.2cqi;
line-height: 1.4;
margin-bottom: 2.4cqi;
}
.stadionzeitung-beitrag__foto {
display: block;
width: 100%;
height: 48cqi;
object-fit: contain;
}
.stadionzeitung-beitrag__source {
font-size: 2.5cqi;
line-height: 1.4;
color: var(--clr-text-muted);
margin: 0 0 4cqi;
}
.stadionzeitung-beitrag .stadionzeitung-page__cta {
margin-top: auto;
color: inherit;
text-decoration: none;
}
.stadionzeitung-beitrag .stadionzeitung-page__cta-qr {
width: 20cqi;
height: 20cqi;
}
/* === stadionzeitung-rueckblick (redaktionelle Seite mit Bildnachweis) === */
.stadionzeitung-rueckblick {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* Kopf/Trennlinie, Ergebnis und Fließtext verwenden die vorhandenen
Heft-Klassen. Hier nur die Anordnung von Bild und Ergebnis definieren. */
.stadionzeitung-rueckblick__scoreline {
display: flex;
align-items: center;
gap: 4cqi;
margin-bottom: 4cqi;
}
.stadionzeitung-rueckblick__scoreline .stadionzeitung-ergebnisse__result {
flex: 0 0 auto;
margin: 0;
}
.stadionzeitung-rueckblick__figure {
margin: 0 0 3cqi;
}
.stadionzeitung-rueckblick__foto {
display: block;
width: 100%;
height: auto;
max-height: 65cqi;
object-fit: contain;
}
.stadionzeitung-rueckblick__credit {
font-size: 2.5cqi;
line-height: 1.4;
color: var(--clr-text-muted);
margin-top: 1cqi;
}
/* === stadionzeitung-galerie (Live-Seite, optional: Fotos vom letzten Spiel) === */ /* === stadionzeitung-galerie (Live-Seite, optional: Fotos vom letzten Spiel) === */
/* Bento-Raster auf einem 6×4-Gitter: das erste Foto ist der Aufmacher (4×2), /* Bento-Raster auf einem 6×4-Gitter: das erste Foto ist der Aufmacher (4×2),
die übrigen legen sich je nach Anzahl darum. Die Fläche unter dem Kopf gehört die übrigen legen sich je nach Anzahl darum. Die Fläche unter dem Kopf gehört

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Some files were not shown because too many files have changed in this diff Show More