Compare commits
61 Commits
2f8619e78d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e761231442 | |||
| 0a01134583 | |||
| 5854e452f7 | |||
| f2f193b223 | |||
| b4d2cde149 | |||
| 0d80f69a76 | |||
| 6313b98f9e | |||
| f88b5f1dd1 | |||
| 736a588a88 | |||
| 550616aacf | |||
| c688d155cc | |||
| 1839632b66 | |||
| d410c0bedf | |||
| 133c3f926e | |||
| 146471bae8 | |||
| f3f004d13e | |||
| 7bbf783562 | |||
| f21224ea84 | |||
| 592acf02b0 | |||
| 63a045835e | |||
| 06a18a2d19 | |||
| e7572b34d6 | |||
| 921a55635c | |||
| 25f36e5a53 | |||
| 6a9244d847 | |||
| 8c15b2584b | |||
| c4ffe4a1f4 | |||
| 75c06dbd04 | |||
| 6bc6b0d599 | |||
| 9244149bac | |||
| 97676e91ee | |||
| a975bed33c | |||
| e0b2184372 | |||
| c7b23b9f39 | |||
| f6efdfb4b4 | |||
| e24b63147d | |||
| 24a3d6234c | |||
| f678608140 | |||
| 8be0441341 | |||
| de5132c2ec | |||
| 7223122220 | |||
| 344727bed0 | |||
| a63822f3d4 | |||
| 827a717a02 | |||
| 05c64c8f12 | |||
| 964ffb932b | |||
| 913c058c93 | |||
| f1ba364922 | |||
| 748f029afb | |||
| 2066b43a57 | |||
| a3cfd4941d | |||
| e8296a465b | |||
| 1213fa4ca1 | |||
| 8ed5707d24 | |||
| 3cf8c2688b | |||
| dd18906348 | |||
| 4f1bfce8b2 | |||
| f0ec8614b2 | |||
| a495250db8 | |||
| aee8a3a901 | |||
| e6f72a0e4b |
28
.gitignore
vendored
@@ -17,22 +17,30 @@
|
|||||||
!/storage/cache/.gitkeep
|
!/storage/cache/.gitkeep
|
||||||
!/storage/ratelimit/.gitkeep
|
!/storage/ratelimit/.gitkeep
|
||||||
|
|
||||||
# Maschinenverwaltet (bin/instagram-sync.php)
|
# Maschinenverwaltet: die JSON-Caches bleiben draußen, der Sync baut sie neu.
|
||||||
|
#
|
||||||
|
# Die BILDER dagegen sind versioniert, obwohl sie auch der Sync herunterlädt:
|
||||||
|
# eine VERÖFFENTLICHTE Stadionzeitung friert Verweise auf genau diese Dateien
|
||||||
|
# dauerhaft ein (Wappen in Tabellen/Ergebnissen/Vorschau, Instagram-Fotos auf der
|
||||||
|
# Momente-Seite). Ein frischer Klon zeigte sonst 37 kaputte Bilder im Heft, und
|
||||||
|
# der Instagram-Sync räumt alte Posts irgendwann weg — dann bräche eine
|
||||||
|
# archivierte Ausgabe nachträglich. Maschinenverwaltet und flüchtig ist nicht
|
||||||
|
# dasselbe wie maschinenverwaltet und für immer referenziert.
|
||||||
/data/instagram.json
|
/data/instagram.json
|
||||||
/public/assets/img/instagram/*
|
|
||||||
!/public/assets/img/instagram/.gitkeep
|
|
||||||
|
|
||||||
# Maschinenverwaltet (bin/matchcenter-sync.php)
|
|
||||||
/data/matchcenter.json
|
/data/matchcenter.json
|
||||||
/public/assets/img/crests/*
|
|
||||||
!/public/assets/img/crests/.gitkeep
|
|
||||||
|
|
||||||
# Dateiablage /dateien — Nutzerdaten und Laufzeit-Kram der Files Gallery.
|
# Dateiablage /dateien — Laufzeit-Kram der Files Gallery.
|
||||||
# Committed bleiben nur system/users/*/config.php (Rechte, keine Passwörter) und
|
# Committed bleiben nur system/users/*/config.php (Rechte, keine Passwörter) und
|
||||||
# system/include/* (Skin). system/config/config.php generiert die App selbst und
|
# system/include/* (Skin). system/config/config.php generiert die App selbst und
|
||||||
# überschreibt sie bei jedem Update — unsere Werte stehen in _filesconfig.php.
|
# überschreibt sie bei jedem Update — unsere Werte stehen in _filesconfig.php.
|
||||||
/storage/dateien/uploads/*
|
#
|
||||||
!/storage/dateien/uploads/.gitkeep
|
# uploads/ IST versioniert (Beschluss 31.07.2026) — das Material des Vereins ist
|
||||||
|
# damit gesichert und lokal verfügbar. Zwei Bedingungen tragen das:
|
||||||
|
# 1. Der Ordner ist ein Schreibziel für ANDERE. Deshalb gilt: erst auf dem
|
||||||
|
# Server committen und pushen, dann lokal pullen, dann entwickeln. Nie
|
||||||
|
# umgekehrt, sonst arbeiten zwei Stände gegeneinander.
|
||||||
|
# 2. Git vergisst nichts. Eine gelöschte Datei bleibt in der Historie, das
|
||||||
|
# Repo schrumpft nie wieder. Große Videos deshalb bewusst sparsam.
|
||||||
/storage/dateien/system/cache/
|
/storage/dateien/system/cache/
|
||||||
/storage/dateien/system/config/
|
/storage/dateien/system/config/
|
||||||
|
|
||||||
|
|||||||
325
CLAUDE.md
@@ -7,7 +7,9 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
|
|||||||
|
|
||||||
- **Frontend:** Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node.
|
- **Frontend:** Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node.
|
||||||
- **Backend:** Vanilla PHP ≥ 8.1, Composer nur für `phpmailer/phpmailer`. Keine Template-Engine.
|
- **Backend:** Vanilla PHP ≥ 8.1, Composer nur für `phpmailer/phpmailer`. Keine Template-Engine.
|
||||||
- **Hosting-Ziel:** Apache Shared Hosting, PHP 8.x, `.htaccess`, Cronjobs verfügbar.
|
- **Hosting-Ziel:** All-Inkl Shared Hosting, PHP 8.x, `.htaccess`, SSH (seit 31.07.2026) und
|
||||||
|
Cronjobs, die aber **nur URLs aufrufen können** — deshalb läuft der Takt über
|
||||||
|
`public/cron.php`, siehe dort. Deploy-Anleitung Schritt für Schritt: **`docs/deploy.md`**.
|
||||||
- **Lokales Dev:** `php -d upload_max_filesize=250M -d post_max_size=260M -S localhost:8000 -t public public/index.php`
|
- **Lokales Dev:** `php -d upload_max_filesize=250M -d post_max_size=260M -S localhost:8000 -t public public/index.php`
|
||||||
(index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt). Die beiden `-d`-Schalter
|
(index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt). Die beiden `-d`-Schalter
|
||||||
brauchst du für die Dateiablage `/dateien`: der CLI-Server liest keine `.user.ini`, und mit den
|
brauchst du für die Dateiablage `/dateien`: der CLI-Server liest keine `.user.ini`, und mit den
|
||||||
@@ -52,10 +54,10 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
|
|||||||
| Partner/Sponsoren | `data/partners.json` | — |
|
| Partner/Sponsoren | `data/partners.json` | — |
|
||||||
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
|
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
|
||||||
| 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` und `interview`.** 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`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`) und `cover` (`teaser` einer Sonderausgabe).** 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` 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, 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`) |
|
||||||
| News (`/news` + optionale Detailseiten) | `data/news.json` (redaktionelle Meldungen; `artikel[]` = Fließtext-Blöcke, `status` = Entwurf/veröffentlicht) — Instagram-Posts und Ergebnisse kommen weiterhin nur aus `data/instagram.json`/`data/matchcenter.json`, werden auf der News-Seite nur gemergt | Von Claude im Chat gepflegt wie `events.json`/`teams.json` — **keine** Automatisierung, kein Bin-Skript. Instagram/Ergebnisse nie hier duplizieren. `text` bleibt der Teaser (Feed, Meta-Description, Article-Schema), der Artikel steht in `artikel[]` |
|
| News (`/news` + optionale Detailseiten) | `data/news.json` (redaktionelle Meldungen; `artikel[]` = Fließtext-Blöcke, `status` = Entwurf/veröffentlicht) — Instagram-Posts und Ergebnisse kommen weiterhin nur aus `data/instagram.json`/`data/matchcenter.json`, werden auf der News-Seite nur gemergt | Von Claude im Chat gepflegt wie `events.json`/`teams.json` — **keine** Automatisierung, kein Bin-Skript. Instagram/Ergebnisse nie hier duplizieren. `text` bleibt der Teaser (Feed, Meta-Description, Article-Schema), der Artikel steht in `artikel[]` |
|
||||||
| Dateiablage `/dateien` (Konfiguration) | `public/dateien/_filesconfig.php` (Rechte pro Konto: `storage/dateien/system/users/admin/config.php`) | **`storage/dateien/system/config/config.php` von Hand editieren** — die App generiert sie selbst und überschreibt sie bei jedem Update. Passwörter/Lizenz gehören in `config.php` (`dateien.*`) |
|
| Dateiablage `/dateien` (Konfiguration) | `public/dateien/_filesconfig.php` (Rechte pro Konto: `storage/dateien/system/users/admin/config.php`) | **`storage/dateien/system/config/config.php` von Hand editieren** — die App generiert sie selbst und überschreibt sie bei jedem Update. Passwörter/Lizenz gehören in `config.php` (`dateien.*`) |
|
||||||
| Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
|
| Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
|
||||||
@@ -70,6 +72,13 @@ Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pag
|
|||||||
`sportsevent_nodes()` (SportsEvent-Knoten für kommende Spiele, Matchcenter) und
|
`sportsevent_nodes()` (SportsEvent-Knoten für kommende Spiele, Matchcenter) und
|
||||||
`job_posting_schema()` (JobPosting `VOLUNTEER` für Ehrenamtsstellen, `/mitmachen`, verweist via `#club`).
|
`job_posting_schema()` (JobPosting `VOLUNTEER` für Ehrenamtsstellen, `/mitmachen`, verweist via `#club`).
|
||||||
Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt und Markup nie auseinanderlaufen.
|
Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt und Markup nie auseinanderlaufen.
|
||||||
|
Ein FAQ-Eintrag darf zusätzlich `ctas[]` tragen (Knöpfe unter der Antwort, gerendert über die
|
||||||
|
`cta`-Komponente) — für Antworten, an deren Ende ein Handgriff steht, etwa „so meldest du dich an".
|
||||||
|
**Die Antwort muss den Weg trotzdem in Worten nennen:** `faq_schema()` liest nur `q` und `a`, bei
|
||||||
|
Google steht sonst eine Antwort ohne Ziel. Und **keine zweite Sektion mit denselben Knöpfen daneben**
|
||||||
|
— ein `cta-band` unter den Beiträgen von `/turnen` war gebaut und wurde am 31.07.2026 wieder
|
||||||
|
entfernt (Entscheidung Felix): dieselbe Sache zweimal auf einer Seite schwächt beide Stellen, und
|
||||||
|
man weiß nicht mehr, welcher der richtige Weg ist.
|
||||||
Redaktioneller Text aus `data/*.json` darf interne Deeplinks als `[Label](slug)` tragen (auch
|
Redaktioneller Text aus `data/*.json` darf interne Deeplinks als `[Label](slug)` tragen (auch
|
||||||
`[Label](#anker)` bzw. `slug#anker`) — `inline_links_html()` rendert sie als `<a>`,
|
`[Label](#anker)` bzw. `slug#anker`) — `inline_links_html()` rendert sie als `<a>`,
|
||||||
`inline_links_text()` hält HTML-freie Ausgaben (FAQPage-Schema) sauber. Genutzt von den
|
`inline_links_text()` hält HTML-freie Ausgaben (FAQPage-Schema) sauber. Genutzt von den
|
||||||
@@ -118,7 +127,18 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
|
|||||||
Quelle für Termine-Badges und Hero-Beschriftung) und `upcoming_highlights(3)` (die nächsten
|
Quelle für Termine-Badges und Hero-Beschriftung) und `upcoming_highlights(3)` (die nächsten
|
||||||
Termine für den Startseiten-Hero, s. Hero-Regel).
|
Termine für den Startseiten-Hero, s. Hero-Regel).
|
||||||
- **CSS:** 6 Dateien als `<link>` in fester Reihenfolge: tokens → reset → base → layout → components →
|
- **CSS:** 6 Dateien als `<link>` in fester Reihenfolge: tokens → reset → base → layout → components →
|
||||||
utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit
|
utilities. Kein `@import`, **kein Inline-Style — die CSP erzwingt das, und zwar nur in
|
||||||
|
Produktion.** `default-src 'self'` ohne `style-src` heißt: der Browser verwirft jedes
|
||||||
|
`style`-Attribut und jeden `<style>`-Block stillschweigend. Lokal fällt das **nie** auf,
|
||||||
|
weil `php -S` keine `.htaccess` liest. Genau so blieben die Banner-Slides beim ersten
|
||||||
|
Deploy schwarz (31.07.2026): die Komponente setzte `style="--banner-bg: url(…)"`.
|
||||||
|
**Die zwei Auswege, wenn ein Wert aus `data/*.json` ins Aussehen muss:**
|
||||||
|
ein **Bild** wird ein `<img>` (die CSP erlaubt `img-src 'self' data:`, und `srcset`
|
||||||
|
gibt es gratis dazu), eine **Farbe** wird ein Token in `tokens.css` plus eine Klasse in
|
||||||
|
`components.css`, und die JSON nennt nur den Namen (`"accent": "breadcrumb"`).
|
||||||
|
`bin/preflight.php` bricht mit Fehler ab, wenn irgendwo unter `app/` ein `style`-Attribut
|
||||||
|
auftaucht. Per JavaScript gesetzte Styles (`element.style.…`) sind nicht betroffen.
|
||||||
|
Komponenten-Styles in `components.css` mit
|
||||||
Banner-Kommentaren (`/* === hero === */`).
|
Banner-Kommentaren (`/* === hero === */`).
|
||||||
**Kontext-Regeln setzen Custom Properties, kein Layout.** Wenn eine Komponente in einem Kontext
|
**Kontext-Regeln setzen Custom Properties, kein Layout.** Wenn eine Komponente in einem Kontext
|
||||||
anders aussehen soll, gehört auf `.kontext .komponente` nur die Variable (`--crest-size`,
|
anders aussehen soll, gehört auf `.kontext .komponente` nur die Variable (`--crest-size`,
|
||||||
@@ -164,7 +184,11 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
|
|||||||
- **Varianten statt Zweitformular:** das Kontaktformular hat zwei Ausprägungen, gesteuert vom
|
- **Varianten statt Zweitformular:** das Kontaktformular hat zwei Ausprägungen, gesteuert vom
|
||||||
Hidden-Feld `department` (`''` = allgemein, `'turnen'` = Turnabteilung auf `/turnen`).
|
Hidden-Feld `department` (`''` = allgemein, `'turnen'` = Turnabteilung auf `/turnen`).
|
||||||
`contact_variant()` (`helpers.php`) ist die **einzige** Quelle für Auswahlliste, Feldregeln
|
`contact_variant()` (`helpers.php`) ist die **einzige** Quelle für Auswahlliste, Feldregeln
|
||||||
(Betreff-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide dort. Ein
|
(Betreff-Feld, Geburtsdatum-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide
|
||||||
|
dort. Ein variantenspezifisches Feld braucht **drei** Stellen: den Schalter in
|
||||||
|
`contact_variant()`, die bedingte Ausgabe in `contact-form.php` und in der Action beides —
|
||||||
|
Validierung **und** ein Zurücksetzen auf leer, wenn die Variante das Feld nicht anbietet (sonst
|
||||||
|
könnte ein manipulierter POST es an einer Variante vorbei einschmuggeln). Ein
|
||||||
unbekannter Wert fällt auf die allgemeine Variante zurück, ein manipuliertes Feld erreicht also
|
unbekannter Wert fällt auf die allgemeine Variante zurück, ein manipuliertes Feld erreicht also
|
||||||
keinen fremden Empfänger. Die Turn-Variante geht an `club.email_turnen`, hat die Disziplinen aus
|
keinen fremden Empfänger. Die Turn-Variante geht an `club.email_turnen`, hat die Disziplinen aus
|
||||||
`data/turnen.json` als Auswahl (Pflicht, ersetzt den Betreff) und eine freiwillige Nachricht.
|
`data/turnen.json` als Auswahl (Pflicht, ersetzt den Betreff) und eine freiwillige Nachricht.
|
||||||
@@ -215,7 +239,9 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
|
|||||||
Web wie CLI. Kein `error_log()`, kein `file_put_contents()` auf eine Logdatei, keine eigene
|
Web wie CLI. Kein `error_log()`, kein `file_put_contents()` auf eine Logdatei, keine eigene
|
||||||
Log-Closure in einem neuen Skript.
|
Log-Closure in einem neuen Skript.
|
||||||
- Kanal = Dateiname unter `storage/logs/`: `mail` · `spam` · `instagram` · `matchcenter` ·
|
- Kanal = Dateiname unter `storage/logs/`: `mail` · `spam` · `instagram` · `matchcenter` ·
|
||||||
`stadionzeitung` · `veranstaltungen`
|
`stadionzeitung` · `veranstaltungen` · `chronik` · `filesgallery` · `logrotate`
|
||||||
|
(letzterer nur für den Übersprungen-Hinweis des Cron-Endpoints, der seinen Kanal aus dem
|
||||||
|
Job-Namen bildet)
|
||||||
(`php-errors.log` schreibt PHP selbst, eigenes Format).
|
(`php-errors.log` schreibt PHP selbst, eigenes Format).
|
||||||
- Level mit klarer Bedeutung: **INFO** Normalbetrieb (auch ein geblockter Bot — der Schutz
|
- Level mit klarer Bedeutung: **INFO** Normalbetrieb (auch ein geblockter Bot — der Schutz
|
||||||
arbeitet, das ist kein Fehler) · **WARN** Auffälligkeit, Betrieb läuft weiter (Einzelbild
|
arbeitet, das ist kein Fehler) · **WARN** Auffälligkeit, Betrieb läuft weiter (Einzelbild
|
||||||
@@ -264,7 +290,29 @@ nicht durchgezogen.
|
|||||||
darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen
|
darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen
|
||||||
Abel verwenden, ggf. mit `letter-spacing` + uppercase. `base.css` setzt das durch: `h1`–`h3` tragen
|
Abel verwenden, ggf. mit `letter-spacing` + uppercase. `base.css` setzt das durch: `h1`–`h3` tragen
|
||||||
Coolvetica, **`h4`–`h6` laufen über `--ff-body` + uppercase + `--ls-caps`**.
|
Coolvetica, **`h4`–`h6` laufen über `--ff-body` + uppercase + `--ls-caps`**.
|
||||||
|
- **Coolvetica unter ~40px braucht `letter-spacing: var(--ls-heading)`** (0.012em, also 0,4px bei
|
||||||
|
34px). Der Schnitt ist condensed: bei 60px (Abschnitts-h2) trägt er ohne Sperrung, bei 34px in einer
|
||||||
|
schmalen Spalte kleben die Versalien und der Titel wird schwer lesbar (aufgefallen an den
|
||||||
|
Beitragskarten, 31.07.2026). **Nicht `--ls-caps` (0.08em) nehmen** — das ist für kleine Labels
|
||||||
|
gedacht und lässt eine Überschrift auseinandergezogen wirken. Die globalen `h1`–`h3` stehen
|
||||||
|
bewusst auf `letter-spacing: normal`, gesperrt wird pro Komponente.
|
||||||
- Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
|
- Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
|
||||||
|
**Gemessene Zahl dazu:** `#e20612` auf der Glas-Karte (`rgba(0 0 0 / 0.22)` über `#222`) erreicht
|
||||||
|
**3,50:1** und verfehlt die 4,5:1 für Fließtext; Weiß liegt bei 17,22:1. Genau daran sind die
|
||||||
|
Beitragspreise hängengeblieben, die zuerst rot waren. Hervorhebung über Schriftart und Größe
|
||||||
|
(Coolvetica 28px gegen 21px Fließtext) trägt ohne Farbe.
|
||||||
|
- **Hervorgehobene Hinweise laufen über `component('notice')`** (`app/components/notice.php`,
|
||||||
|
Klassen `.notice`/`.notice--info`/`.notice--stop`), nie als eigenes Markup. Die Tonlage trägt
|
||||||
|
Bedeutung und ist keine Geschmacksfrage: **`--stop` (gefüllt rot) heißt „geht nicht"**
|
||||||
|
(Wartelisten-Stopp der Turnabteilung), **`--info` (Glas-Fläche, Rot nur im Icon) heißt „geht"**
|
||||||
|
(neue Kinder dürfen zum Schnuppern kommen). Eine Einladung in Alarmrot liest sich wie eine
|
||||||
|
Absage, und wenn alles rot ist, stumpft Rot ab und der echte Stopp verliert seine Wirkung.
|
||||||
|
**`--info` ist der Default, Rot die Ausnahme.** Der Text und die Tonlage stehen in der
|
||||||
|
`data/*.json` der Seite (`notice`, `notice_title`, `notice_tone`, `notice_icon`), nie im
|
||||||
|
Template; eingebettet wird über eine Zusatzklasse, die **nur** Position bzw. Breite setzt
|
||||||
|
(`.turnen-dash__notice` = `grid-area`, `.contact__notice` = Formularbreite).
|
||||||
|
Eine Überschrift ist Pflicht, wenn der Hinweis rot ist: ohne sie liest sich die rote Fläche
|
||||||
|
wie eine Fehlermeldung.
|
||||||
- Alle Werte als CSS Custom Properties in `tokens.css`; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
|
- Alle Werte als CSS Custom Properties in `tokens.css`; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
|
||||||
|
|
||||||
### Token-Disziplin (gilt für jede CSS-Änderung)
|
### Token-Disziplin (gilt für jede CSS-Änderung)
|
||||||
@@ -274,6 +322,7 @@ Keine rohen Werte in `base/layout/components/utilities.css` — es gibt für all
|
|||||||
| Was | Token | Anmerkung |
|
| Was | Token | Anmerkung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `padding` / `margin` / `gap` | `--space-4xs … --space-7xl` (14 Stufen, 4px-Raster) | `--space-4xs` (2px) **nur** für optische Korrekturen (Icon-/Baseline-Nudges), nie fürs Layout |
|
| `padding` / `margin` / `gap` | `--space-4xs … --space-7xl` (14 Stufen, 4px-Raster) | `--space-4xs` (2px) **nur** für optische Korrekturen (Icon-/Baseline-Nudges), nie fürs Layout |
|
||||||
|
| Innenabstand von Karten-Flächen | `--pad-card` (20…32px) / `--pad-card-lg` (24…40px) | Keine festen Werte. Auf einem 309px-Handy stapeln sich Container- und Kartenabstand sonst so, dass dem Eingabefeld nur 71% der Bildschirmbreite bleiben. Die Obergrenzen entsprechen den früheren festen Werten, am Desktop ändert sich dadurch nichts. Ein harter Breakpoint (etwa „ab 310px ohne Abstand") wäre die Alternative, sieht aber bei 311px wieder falsch aus |
|
||||||
| `font-size` (Text) | `--fs-300 … --fs-1000` | `--fs-550`/`--fs-690` sind die fluiden Zwischenstufen (Lead, Karten-Überschrift) |
|
| `font-size` (Text) | `--fs-300 … --fs-1000` | `--fs-550`/`--fs-690` sind die fluiden Zwischenstufen (Lead, Karten-Überschrift) |
|
||||||
| `font-size` (Icons) | `--icon-s/m/l/xl` (rem), `--icon-inline-sm/--icon-inline/--icon-inline-lg` (em) | em-Stufen für Icons, die mit ihrer Textzeile mitwachsen sollen |
|
| `font-size` (Icons) | `--icon-s/m/l/xl` (rem), `--icon-inline-sm/--icon-inline/--icon-inline-lg` (em) | em-Stufen für Icons, die mit ihrer Textzeile mitwachsen sollen |
|
||||||
| `letter-spacing` | `--ls-tight/caps/wide/wider` | |
|
| `letter-spacing` | `--ls-tight/caps/wide/wider` | |
|
||||||
@@ -329,13 +378,29 @@ Keine rohen Werte in `base/layout/components/utilities.css` — es gibt für all
|
|||||||
- [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
|
- [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
|
||||||
- [ ] `php -l` sauber; Seite lokal geprüft
|
- [ ] `php -l` sauber; Seite lokal geprüft
|
||||||
|
|
||||||
## Cron-Endpoint `public/cron.php` (Ausnahme 3b)
|
## Cron-Endpoint `public/cron.php` (Ausnahme 3b) — **der Takt-Geber**
|
||||||
|
|
||||||
**Der Tarif kennt keine zeitgesteuerten Aufgaben** — das KAS meldet „in deinem Tarif nicht verfügbar",
|
**Die KAS-Cronjobs können ausschließlich URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026:
|
||||||
weder Shell- noch URL-Cronjobs (geprüft 27.07.2026). Die Sync-Skripte müssen aber getaktet laufen, also
|
das Anlege-Formular hat nur ein Feld „Protokoll / Pfad" mit `https://`). Der Tarif hat inzwischen SSH
|
||||||
kommt der Takt von außen: ein externer Cron-Dienst ruft
|
und Cronjobs, aber ein `php bin/…` ist dort nicht eintragbar. Deshalb ruft der **KAS-Cron diesen
|
||||||
`https://…/cron.php?job=matchcenter` bzw. `?job=instagram` auf, der Endpoint startet das Skript
|
Endpoint** auf, und er startet das Skript serverseitig. Der Ablauf steht in **`docs/deploy.md`**, Teil D,
|
||||||
serverseitig. Schlüssel aus `config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter.
|
die drei Zeitpunkte in `config/config.example.php`.
|
||||||
|
|
||||||
|
Das ist besser als der ursprünglich geplante externe Cron-Dienst: **der Aufrufer ist der Hoster
|
||||||
|
selbst**, der Schlüssel verlässt den Webspace nie, es ist überhaupt kein Dritter beteiligt.
|
||||||
|
`cron.key` muss also gesetzt sein — steht er leer, läuft **kein** Sync mehr.
|
||||||
|
|
||||||
|
Ein externer Cron-Dienst ruft, falls je nötig, dieselben URLs auf:
|
||||||
|
`https://…/cron.php?job=matchcenter` · `?job=instagram` · `?job=logrotate`. Schlüssel aus
|
||||||
|
`config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter (im KAS notgedrungen als
|
||||||
|
Parameter, das Formular kennt keine Header).
|
||||||
|
|
||||||
|
**Warum es drei Jobs sind:** `logrotate` kam am 31.07.2026 dazu, weil dieser Endpoint der einzige Weg
|
||||||
|
ist, überhaupt etwas getaktet laufen zu lassen. Ohne ihn liefe die Logpflege nie automatisch und die
|
||||||
|
2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Der Job passt in denselben Rahmen wie die Syncs: kein
|
||||||
|
Parameter aus der URL, keine Verbindung nach außen, er schreibt nur in `storage/logs`. Eine **vierte**
|
||||||
|
Ausnahme braucht wieder denselben Aufwand an Begründung. `bin/log-rotate.php` trägt dafür denselben
|
||||||
|
Riegel wie die Syncs (`PHP_SAPI !== 'cli' && !defined('CRON_HTTP')`) und `require_once`.
|
||||||
|
|
||||||
**Warum dieser Weg und nicht GitHub Actions mit FTP-Upload:** dort müsste ein Dritter Schreibzugang
|
**Warum dieser Weg und nicht GitHub Actions mit FTP-Upload:** dort müsste ein Dritter Schreibzugang
|
||||||
zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der ausschließlich „starte einen der beiden
|
zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der ausschließlich „starte einen der beiden
|
||||||
@@ -363,8 +428,8 @@ Besucher keine Aktualisierung, und Fire-and-Forget ist auf Shared Hosting unzuve
|
|||||||
`$argv` wird im Endpoint gesetzt, weil es im Web-SAPI nicht existiert.
|
`$argv` wird im Endpoint gesetzt, weil es im Web-SAPI nicht existiert.
|
||||||
|
|
||||||
`bin/preflight.php` prüft Schlüssellänge, Verwechslung mit `app_secret` und ob `min_interval` den
|
`bin/preflight.php` prüft Schlüssellänge, Verwechslung mit `app_secret` und ob `min_interval` den
|
||||||
gewünschten Takt sperrt. **Wenn der Tarif je Cronjobs bekommt:** `cron.key` leeren, dann ist der
|
gewünschten Takt sperrt. Der damals hier notierte Ausstieg („wenn der Tarif je Cronjobs bekommt,
|
||||||
Endpoint zu, und der Takt läuft wieder wie in `config.example.php` dokumentiert.
|
`cron.key` leeren") ist am 31.07.2026 genau so eingetreten und vollzogen.
|
||||||
|
|
||||||
**Offen:** ob der Instagram-Scraper von der Server-IP überhaupt durchkommt. Instagram sperrt
|
**Offen:** ob der Instagram-Scraper von der Server-IP überhaupt durchkommt. Instagram sperrt
|
||||||
Rechenzentren; hier lief er nur über einen privaten Anschluss. Einmal auf dem Server prüfen. Falls er
|
Rechenzentren; hier lief er nur über einen privaten Anschluss. Einmal auf dem Server prüfen. Falls er
|
||||||
@@ -403,8 +468,15 @@ Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
|
|||||||
|
|
||||||
## Stadionzeitung (Ausgaben-Archiv)
|
## Stadionzeitung (Ausgaben-Archiv)
|
||||||
|
|
||||||
Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) mit
|
Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) als
|
||||||
Vorschau-Karten, pro Ausgabe ein Swipe-Viewer (`/stadionzeitung/<ausgabe-slug>`, dynamische Route,
|
**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
|
||||||
|
horizontales Regal mit 280px-Karten; bei **einer** veröffentlichten Ausgabe stand die Karte allein
|
||||||
|
links auf einer 1280px breiten Seite (geändert 31.07.2026). Der Titel der Zeile lautet
|
||||||
|
**„Stadionzeitung zum Heimspiel gegen <Gegner>"** (nicht nur „Gegen <Gegner>"): so ist die Zeile für
|
||||||
|
sich verständlich, auch als Link-Text für Screenreader. Als Teaser darunter steht die **Coverzeile des
|
||||||
|
Interviews** (`interview.teaser`, dieselbe Quelle wie „Im Heft" auf dem Cover) — ohne Interview
|
||||||
|
entfällt sie. Pro Ausgabe ein Swipe-Viewer (`/stadionzeitung/<ausgabe-slug>`, dynamische Route,
|
||||||
siehe „Architektur-Muster"). **Bewusst kein PDF auf der Website** — die Ausgabe entsteht ohnehin in
|
siehe „Architektur-Muster"). **Bewusst kein PDF auf der Website** — die Ausgabe entsteht ohnehin in
|
||||||
Canva, jede Seite wird dort direkt als Bild (PNG/JPG) statt als PDF exportiert. Ein PDF-Export der
|
Canva, jede Seite wird dort direkt als Bild (PNG/JPG) statt als PDF exportiert. Ein PDF-Export der
|
||||||
Muster-Datei war ~80 MB (hochauflösende Druck-Scans) und hätte serverseitig Ghostscript/Imagick
|
Muster-Datei war ~80 MB (hochauflösende Druck-Scans) und hätte serverseitig Ghostscript/Imagick
|
||||||
@@ -444,17 +516,23 @@ kein Download-Link — nur die Web-Ansicht. Der Ablauf pro Heimspiel ist damit:
|
|||||||
**Feste Vorlage** (seit Interview 29.07.2026, Anzeigen-Verteilung überarbeitet 31.07.2026) — der
|
**Feste Vorlage** (seit Interview 29.07.2026, Anzeigen-Verteilung überarbeitet 31.07.2026) — der
|
||||||
Inhalt ist eine Liste von **Blöcken**, die Anzeigen füllen die Lücken dazwischen (⌷ = Lücke):
|
Inhalt ist eine Liste von **Blöcken**, die Anzeigen füllen die Lücken dazwischen (⌷ = Lücke):
|
||||||
```
|
```
|
||||||
Cover → [Vorwort → Interview (nur wenn die Ausgabe eins trägt)] ⌷ Ergebnis-Rückblick ⌷
|
Cover → [Vorwort → Programm* → Interview → Aufruf (jedes nur wenn die Ausgabe es trägt)] ⌷
|
||||||
|
[Turnierpläne*: je Turnier eine Seite, EIN Block] ⌷ Ergebnis-Rückblick ⌷
|
||||||
|
[Gegnerstatistik: normal nur die tragende Mannschaft, in der Sportfest-Ausgabe je
|
||||||
|
Mannschaft mit Heimspiel am Fest eine — EIN Block, automatisch aus deren Tabelle] ⌷
|
||||||
[je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine
|
[je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine
|
||||||
Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
|
Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
|
||||||
News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
|
News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
|
||||||
Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten
|
Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten
|
||||||
Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) ⌷
|
Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) →
|
||||||
|
Danke-Seite (Partner-Logo-Wand + QR auf /partner-werden, ohne Lücke davor: Akquise und Dank
|
||||||
|
gehören zusammen) ⌷
|
||||||
Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim
|
Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim
|
||||||
Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite
|
Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite
|
||||||
```
|
```
|
||||||
Alle Seiten außer Rückseite und Vorwort-Fallback sind Live-Seiten. Nach dem Cover, im Aufschlag
|
Alle Seiten außer Rückseite und Vorwort-Fallback sind Live-Seiten. Nach dem Cover, im Aufschlag
|
||||||
und vor der Rückseite steht **keine** Lücke (Begründung unten).
|
und vor der Rückseite steht **keine** Lücke (Begründung unten). \* = nur Sonderausgabe, siehe
|
||||||
|
„Sportfest-Ausgabe" unten.
|
||||||
**Doppelseiten-Regel fürs Falt-Heft:** aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander
|
**Doppelseiten-Regel fürs Falt-Heft:** aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander
|
||||||
(gerade Seite links). Die erste Tabellenseite muss deshalb auf einer GERADEN Seitenzahl liegen, damit
|
(gerade Seite links). Die erste Tabellenseite muss deshalb auf einer GERADEN Seitenzahl liegen, damit
|
||||||
die Tabellen nebeneinander stehen. **Das rechnet das Skript selbst** (seit 31.07.2026): es verschiebt
|
die Tabellen nebeneinander stehen. **Das rechnet das Skript selbst** (seit 31.07.2026): es verschiebt
|
||||||
@@ -475,12 +553,30 @@ Lücken bekommen 7 Lücken zwei. Zwei hintereinander ist das Optimum, nicht eins
|
|||||||
Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf.
|
Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf.
|
||||||
Ein `images[]`-Eintrag hat
|
Ein `images[]`-Eintrag hat
|
||||||
`"type": "image"` (wie gehabt, `base`/`widths`/`alt`) oder `"type": "live"` (`page`: `cover` |
|
`"type": "image"` (wie gehabt, `base`/`widths`/`alt`) oder `"type": "live"` (`page`: `cover` |
|
||||||
`vorwort` | `interview` | `tabelle` | `termine` | `news` | `ergebnisse` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `momente` | `impressum`, kein Bild;
|
`vorwort` | `interview` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `impressum`, kein Bild;
|
||||||
`tabelle` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der Team-Key
|
`tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der
|
||||||
der Ausgabe). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
|
Team-Key der Ausgabe; `turnier` mit `turnier`: Key aus `turniere[]` der Ausgaben-Veranstaltung;
|
||||||
|
`danke` mit `anzeige`: das platzierte Anzeigenmotiv, siehe unten). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
|
||||||
wird jede Seite (Bild wie Live) über `app/components/stadionzeitung-seite.php`** — die eine
|
wird jede Seite (Bild wie Live) über `app/components/stadionzeitung-seite.php`** — die eine
|
||||||
gemeinsame Render-Stelle für Viewer und Druckfassung, nie zwei Implementierungen derselben Seite.
|
gemeinsame Render-Stelle für Viewer und Druckfassung, nie zwei Implementierungen derselben Seite.
|
||||||
|
|
||||||
|
**Danke-Seite + platzierte Anzeigen (03.09.2026).** Der Dank an die Partner war bis dahin unten an
|
||||||
|
ein Anzeigen-Bild geklebt (ein Canva-Motiv, das Sponsorenanzeige und Dank in einer Datei hatte) und
|
||||||
|
bestand aus Wappen, Titel und einer Zeile auf leerer Fläche. Jetzt ist es eine Live-Seite
|
||||||
|
(`stadionzeitung-danke.php`): die **Logo-Wand aus `data/partners.json`** (Single Source, dieselbe
|
||||||
|
Quelle wie die Startseite), Kopf und der QR auf `/partner-werden`. Dazu ein neues, generisches Feld
|
||||||
|
im Anzeigenbestand: **`platzierung`** nimmt eine Anzeige aus dem Streu-Pool und gibt ihr einen festen
|
||||||
|
Platz auf einer Live-Seite (derzeit `danke`, dort sitzt die Emons-Anzeige über der Logo-Wand).
|
||||||
|
- **Das Motiv steht im Seiteneintrag, nicht in der Komponente**: `bin/stadionzeitung-add.php` schreibt
|
||||||
|
es als `anzeige` in den `images[]`-Eintrag. Dadurch ist es eingefroren wie jede andere Anzeige, und
|
||||||
|
eine veröffentlichte Ausgabe ändert sich nicht, wenn der Sponsor später ein neues Motiv liefert.
|
||||||
|
- **Die Seitenzahl bleibt gleich**: eine Anzeige verlässt den Pool, eine Live-Seite kommt dazu.
|
||||||
|
- Das Kombi-Motiv wurde an seiner roten Trennlinie zerschnitten, damit die Sponsorenanzeige
|
||||||
|
vollständig bleibt und der Dank nicht zweimal im Heft steht.
|
||||||
|
- **Falle in `bin/anzeige-add.php`** (dabei gefunden und behoben): das Aufräum-Muster lautete
|
||||||
|
`{$slug}-*.jpg` und traf damit jeden Slug, der den neuen als Präfix hat — `emons` löschte die
|
||||||
|
Motive von `emons-partnerdank`. Jetzt `{$slug}-[0-9]*.jpg`.
|
||||||
|
|
||||||
**Entwurf / Veröffentlicht (`status`-Feld):** `bin/stadionzeitung-add.php` legt jede Ausgabe als
|
**Entwurf / Veröffentlicht (`status`-Feld):** `bin/stadionzeitung-add.php` legt jede Ausgabe als
|
||||||
`entwurf` an — unsichtbar in Archiv und Sitemap, `noindex`, aber unter ihrer direkten URL erreichbar
|
`entwurf` an — unsichtbar in Archiv und Sitemap, `noindex`, aber unter ihrer direkten URL erreichbar
|
||||||
(zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen
|
(zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen
|
||||||
@@ -489,8 +585,41 @@ die Live-Seiten (Tabellen/News/Ergebnisse/Vorschau) bei jedem Aufruf frisch aus
|
|||||||
Stand als `snapshot`-Feld ein (`stadionzeitung_build_snapshot()`, dieselbe Berechnung wie die
|
Stand als `snapshot`-Feld ein (`stadionzeitung_build_snapshot()`, dieselbe Berechnung wie die
|
||||||
Entwurfs-Vorschau — `stadionzeitung_live_data()` in `helpers.php` entscheidet snapshot vs. live) und
|
Entwurfs-Vorschau — `stadionzeitung_live_data()` in `helpers.php` entscheidet snapshot vs. live) und
|
||||||
setzt `status: veroeffentlicht`. Ab dann ändert sich die Ausgabe nie mehr („gedruckt = fixiert");
|
setzt `status: veroeffentlicht`. Ab dann ändert sich die Ausgabe nie mehr („gedruckt = fixiert");
|
||||||
sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync. `--entwurf` zieht zurück
|
sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync — **auf dem Server**, dort
|
||||||
(Snapshot wird verworfen). Ein erneuter `stadionzeitung-add.php`-Lauf setzt eine veröffentlichte
|
liegt der frische Sync-Stand (`data/matchcenter.json` ist gitignored, lokal also älter).
|
||||||
|
`--entwurf` zieht zurück (Snapshot wird verworfen).
|
||||||
|
|
||||||
|
**Nach dem Veröffentlichen auf dem Server SOFORT committen und pushen** — das ist keine Kür,
|
||||||
|
sondern Teil des Vorgangs. `bin/stadionzeitung-publish.php` schreibt den Snapshot in
|
||||||
|
`data/stadionzeitung.json` und legt die eingefrorenen `fix-*`-Bilder an; beides existiert danach
|
||||||
|
zunächst **nur auf dem Server**. Bleibt es dort liegen, passieren zwei Dinge: ein `git pull` auf dem
|
||||||
|
Server scheitert beim nächsten Deploy an der geänderten Datei, und wer sie dort verwirft, verliert
|
||||||
|
die veröffentlichten Ausgaben ersatzlos (ein lokaler Stand setzt sie auf `entwurf` zurück, weil er
|
||||||
|
die Snapshots nie gesehen hat).
|
||||||
|
**Am 03.09.2026 genau so eingetreten:** drei veröffentlichte Ausgaben (01.08., 15.08., 29.08.)
|
||||||
|
lagen samt Snapshots und 70 `fix-*`-Dateien ausschließlich auf dem Server, der Pull der
|
||||||
|
Sportfest-Ausgabe brach mit „local changes would be overwritten" ab. Reparatur, in dieser
|
||||||
|
Reihenfolge: Server-Datei außerhalb des Repos sichern → `git checkout --` + `git pull` →
|
||||||
|
Ausgaben-Listen **je Slug** zusammenführen, **Server als Wahrheit**, aus dem Repo nur ergänzen was
|
||||||
|
dort fehlt → `git add data/stadionzeitung.json public/assets/img/stadionzeitung` → committen und
|
||||||
|
pushen. Ein Merge über den Dateiinhalt wäre keine Option gewesen: 1600 geänderte Zeilen JSON,
|
||||||
|
Konflikte mitten in eingefrorenen Datensätzen.
|
||||||
|
|
||||||
|
**Mit dem Snapshot werden auch die BILDER eingefroren** (seit 31.07.2026): das Skript kopiert
|
||||||
|
alles, worauf er zeigt (`img/crests/`, `img/instagram/`, `img/news/`), als `fix-*` in den
|
||||||
|
Ausgaben-Ordner und schreibt die Pfade um. **Grund:** vorher fror nur die Daten ein, die Pfade
|
||||||
|
zeigten weiter in Ordner, die die Sync-Skripte verwalten — `matchcenter-sync` räumt Wappen weg,
|
||||||
|
die der BFV nicht mehr führt (eine Saison), `instagram-sync` ältere Posts (Wochen). Am 31.07.2026
|
||||||
|
nachgewiesen: ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe wäre also von
|
||||||
|
selbst zerfallen. `img/news` ist mit dabei, weil ein Artikelbild im Chat ersetzt werden kann.
|
||||||
|
**Varianten-Sets** (`{base, widths}`) werden über alle Breiten kopiert und nur umgeschrieben, wenn
|
||||||
|
**alle** da sind — ein halbes `srcset` wäre schlimmer als der alte Pfad. Fehlt eine Quelle, bleibt
|
||||||
|
der Pfad stehen und es gibt eine WARN (die `crest`-Komponente fällt auf Initialen zurück; ein
|
||||||
|
fehlendes Bild darf das Veröffentlichen nicht verhindern). Flache `fix-*`-Dateinamen statt eines
|
||||||
|
Unterordners, weil die Aufräum-Logik in `stadionzeitung-add.php` nur Dateien entfernt und ein
|
||||||
|
Unterordner als Waise bliebe. `--entwurf` löscht sie wieder, sie gehören zum Snapshot.
|
||||||
|
`bin/preflight.php` warnt, wenn eine veröffentlichte Ausgabe noch auf `img/crests` oder
|
||||||
|
`img/instagram` zeigt — Reparatur: `--entwurf`, dann neu veröffentlichen. Ein erneuter `stadionzeitung-add.php`-Lauf setzt eine veröffentlichte
|
||||||
Ausgabe zurück auf Entwurf (Seiten ersetzt = Stand muss neu geprüft und veröffentlicht werden).
|
Ausgabe zurück auf Entwurf (Seiten ersetzt = Stand muss neu geprüft und veröffentlicht werden).
|
||||||
Ausgaben ohne `status`-Feld gelten als veröffentlicht (Bestandsschutz). Die Kontakt-Seite ist
|
Ausgaben ohne `status`-Feld gelten als veröffentlicht (Bestandsschutz). Die Kontakt-Seite ist
|
||||||
bewusst NICHT im Snapshot: sie liest immer `data/ansprechpartner.json` (ein Snapshot würde nur
|
bewusst NICHT im Snapshot: sie liest immer `data/ansprechpartner.json` (ein Snapshot würde nur
|
||||||
@@ -545,6 +674,73 @@ Fragen/Antworten, Claude trägt sie ins `interview`-Feld der Ausgabe ein (`tease
|
|||||||
Live-Seite nur ein, wenn das Feld existiert — Ausgaben ohne Interview haben weder Seite noch
|
Live-Seite nur ein, wenn das Feld existiert — Ausgaben ohne Interview haben weder Seite noch
|
||||||
Teaser-Zeile. Fragen in Abel fett, Antworten in `--ff-serif` (dieselbe Begründung wie beim Vorwort).
|
Teaser-Zeile. Fragen in Abel fett, Antworten in `--ff-serif` (dieselbe Begründung wie beim Vorwort).
|
||||||
|
|
||||||
|
**Sportfest-Ausgabe (03.09.2026): ein Slug-Verweis schaltet drei Seitentypen zu.** Trägt eine Ausgabe
|
||||||
|
das chat-gepflegte Feld `veranstaltung` (Slug aus `data/veranstaltungen.json`), kommen dazu: die
|
||||||
|
**Programm-Seite** (`stadionzeitung-programm.php`, aus `programm[]` der Veranstaltung), ein Block
|
||||||
|
**Turnierplan-Seiten** (`stadionzeitung-turnier.php`, je Eintrag in `turniere[]` eine Seite) und eine
|
||||||
|
**Gegnerseite je Mannschaft mit Heimspiel im Zeitraum** der Veranstaltung statt nur für die tragende.
|
||||||
|
Dazu trägt `cover.teaser` die Coverzeile „Im Heft"; das Feld überlebt erneute Skript-Läufe wie
|
||||||
|
`vorwort`. **`stadionzeitung_teaser()`** ist die eine Quelle dieser Zeile für Cover **und**
|
||||||
|
Archivkarte: `cover.teaser` vor `interview.teaser` — eine Sonderausgabe ohne Interview hat sonst keine.
|
||||||
|
**Das Cover einer Sonderausgabe zeigt das FEST, nicht die Begegnung** (Felix, 03.09.2026): Wappen
|
||||||
|
und „Heimspiel gegen …" entfallen, an ihrer Stelle steht der Veranstaltungstitel in der
|
||||||
|
Display-Schrift plus Zeitraum (Quelle: `veranstaltungen.json`, nie neu getippt). Grund: ein Fest
|
||||||
|
bringt mehrere Heimspiele mit, eines davon aufs Cover zu heben wäre schlicht falsch. Ausgelöst wird
|
||||||
|
das vom `veranstaltung`-Feld, nicht von einem eigenen Schalter.
|
||||||
|
**Statt des Spielerfotos steht das HAUPTPLAKAT der Veranstaltung rechts** (erstes Plakat aus
|
||||||
|
`veranstaltungen.json`, dieselbe Quelle wie die Detailseite, nie eine Kopie): leicht schräg gelegt
|
||||||
|
(4 Grad) und an zwei gegenüberliegenden Ecken gerundet, links oben und rechts unten, mit Schatten
|
||||||
|
statt Rahmen — wie ein aufs Cover gelegter Aushang (Felix, 03.09.2026). Es ist mit 66cqi deutlich
|
||||||
|
kleiner als das Spielerfoto (98cqi): ein Freisteller ist schmal, ein Plakat im Hochformat läge bei
|
||||||
|
derselben Höhe über der Typografie unten links. Die Textspalte wird dafür auf 48cqi verengt.
|
||||||
|
Ohne Plakat greift das Spielerfoto, ohne beides trägt der Hintergrund die Seite. `box-shadow` ist
|
||||||
|
hier unbedenklich; die Druck-Falle des Projekts betrifft `text-shadow` auf umbrechendem Text.
|
||||||
|
|
||||||
|
**Sonst bleibt das Cover unverändert, und das ist eine Entscheidung:** eine rote Kennung
|
||||||
|
„Sportfest-Ausgabe" als Kasten im Kopf und ein kräftiger roter Doppelrahmen waren gebaut und wurden
|
||||||
|
am 03.09.2026 wieder entfernt (Felix: „sieht schrecklich aus"). Der Rahmen lief auf z2 quer über das
|
||||||
|
freigestellte Spielerfoto, und der Kasten stach aus dem ruhigen Kopf heraus. Die Ausgabe kennzeichnet
|
||||||
|
sich durch den großen Fest-Titel unten links ausreichend selbst. **Kein zweites Rot in den Kopf, und
|
||||||
|
keinen farbigen Rahmen über das Spielerfoto** — der Kicker bleibt „Saison · Datum" wie überall.
|
||||||
|
- **Die Daten liegen in `veranstaltungen.json`, nicht in der Ausgabe** — eine Quelle für Website und
|
||||||
|
Heft: dasselbe `programm[]` rendert `veranstaltung-programm.php` auf `/veranstaltungen/<slug>` und
|
||||||
|
`stadionzeitung-programm.php` auf dem A5-Blatt (zwei Darstellungen, nie zwei Datenstände).
|
||||||
|
`turniere[]` ist bisher **nur** Heft-Inhalt (Website-Darstellung offen), Feldform:
|
||||||
|
`{key, title, date, start, spieldauer, felder?, modus?, teams[], spiele[]{nr, zeit, feld?, heim, gast}}`.
|
||||||
|
- **`stadionzeitung_gegner_fixtures()`** (`helpers.php`) ist die EINZIGE Stelle, die entscheidet,
|
||||||
|
welche Mannschaft eine Gegnerseite bekommt. `bin/stadionzeitung-add.php` plant die Seiten damit,
|
||||||
|
`stadionzeitung_build_snapshot()` baut die Daten damit — sonst entstünde eine Seite ohne Daten oder
|
||||||
|
umgekehrt. `gegner` ist deshalb eine **Map je Team-Key** (wie `tabellen`) statt eines einzelnen
|
||||||
|
Objekts; Ausgaben ohne `team` am Seiteneintrag fallen auf die tragende Mannschaft zurück. Ein
|
||||||
|
eingefrorener Snapshot mit der alten Ein-Objekt-Form existiert nicht (die einzige veröffentlichte
|
||||||
|
Ausgabe ist älter als die Gegnerseite und trägt dort `null`), deshalb gibt es dafür bewusst
|
||||||
|
**keinen** Kompatibilitäts-Zweig.
|
||||||
|
- **Die Vorschau überspringt jedes Spiel mit eigener Gegnerseite**, nicht mehr nur das Heftspiel:
|
||||||
|
Zweite und Frauen stünden sonst zweimal im Heft. Sie zeigt dann die Spiele nach dem Fest.
|
||||||
|
- **Programm und Turnierpläne sind live, ohne Snapshot** (wie Kontakt/Aufruf): chat-gepflegte Daten,
|
||||||
|
die kein Sync wegräumt. Eine spätere Korrektur am Programm ändert damit auch eine archivierte
|
||||||
|
Ausgabe — bewusst in Kauf genommen, dieselbe Abwägung wie bei der Kontakt-Seite.
|
||||||
|
- **Die Turnierseite ist zum Beschreiben:** Spielplan mit LEERER Ergebnisspalte (Schreiblinien) und
|
||||||
|
leere Abschlusstabelle. Die Zeilenhöhe macht dort die **Schreiblinie**, nicht die Schriftgröße
|
||||||
|
(gemessen: eine kleinere Schrift brachte genau einen Pixel) — wer die E-Jugend-Seite mit zehn
|
||||||
|
Spielen aufs Blatt bringen muss, geht an `.stadionzeitung-turnier--dicht .__blank`. Die
|
||||||
|
Programm-Seite läuft aus demselben Grund mit straffen Abständen: zwölf Punkte plus drei Tagestitel
|
||||||
|
liefen mit den üblichen 3.3cqi 59px über die Blattkante. **Nach jeder Änderung Seitenhöhe messen.**
|
||||||
|
- **Beim Messen die scrollbaren Seiten mitnehmen.** Vorwort, Termine und Sportheim tragen
|
||||||
|
`overflow-y: auto`: dort wächst nicht die Seite, sondern das Kind wird scrollbar — eine Messung
|
||||||
|
von `scrollHeight - clientHeight` auf `.stadionzeitung-page` meldet 0, und im Druck fehlt trotzdem
|
||||||
|
das Ende des Textes. Genau so fiel ein sechsabsätziges Vorwort erst in der Druckvorschau auf
|
||||||
|
(03.09.2026, seitdem vier Absätze). Verlässlich ist: Unterkante der Signatur gegen die Blattkante
|
||||||
|
prüfen, im Viewer **und** in `?druck=1`.
|
||||||
|
- **Seitenzahl aufs Vielfache von 4 bringen, ohne Inhalt zu erfinden:** eine zeitgebundene
|
||||||
|
Eigenanzeige lässt sich per `bis` in `data/anzeigen.json` für diese Ausgabe abschalten (der Filter
|
||||||
|
vergleicht mit dem Ausgabedatum). So kam die Sportfest-Ausgabe von 49 auf 48 Seiten: das
|
||||||
|
Programmplakat als Anzeige wurde abgelöst, weil die gesetzte Programmseite es ersetzt.
|
||||||
|
- **Ablauf ist zweistufig** (wie beim Vorwort): erst `bin/stadionzeitung-add.php` laufen lassen, dann
|
||||||
|
`veranstaltung`/`cover` in die Ausgabe eintragen, dann **erneut** laufen lassen. Vorher existiert
|
||||||
|
die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder
|
||||||
|
Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler.
|
||||||
|
|
||||||
**Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite —
|
**Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite —
|
||||||
`app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein**
|
`app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein**
|
||||||
Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das
|
Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das
|
||||||
@@ -583,10 +779,24 @@ bedrucktes Blatt ~140mm) proportional exakt gleich aus — mit rem-Maßen lief d
|
|||||||
Kontext über die feste A5-Fläche (reproduzierbar: abgeschnittene Vorwort-Signatur, beschnittene
|
Kontext über die feste A5-Fläche (reproduzierbar: abgeschnittene Vorwort-Signatur, beschnittene
|
||||||
Tabellen-Legende). Bewusste Ausnahme von der Token-Disziplin (siehe oben): ein bedrucktes
|
Tabellen-Legende). Bewusste Ausnahme von der Token-Disziplin (siehe oben): ein bedrucktes
|
||||||
A5-Blatt ist kein responsives Website-Element und braucht eine eigene Skala. `letter-spacing`
|
A5-Blatt ist kein responsives Website-Element und braucht eine eigene Skala. `letter-spacing`
|
||||||
bleibt trotzdem über `--ls-*` (dimensionslose `em`-Vielfache, kein Konflikt). **Falle beim
|
bleibt trotzdem über `--ls-*` (dimensionslose `em`-Vielfache, kein Konflikt). **Falle beim Bauen (31.07.2026):** ein zusätzlicher Kommentar wurde versehentlich HINTER das
|
||||||
|
schließende `*/` des bestehenden gesetzt. Damit stand roher Text vor der Regel, der CSS-Parser
|
||||||
|
verwarf den kompletten Regelblock — und die A5-Höhe im Swipe-Viewer war wirkungslos, ohne dass
|
||||||
|
irgendetwas einen Fehler meldete. Die Seiten fielen dadurch auf Inhaltshöhe zurück und waren 350
|
||||||
|
bis 559px hoch statt einheitlich 515. **`php -l` prüft CSS nicht**; wer hier etwas ändert, zählt
|
||||||
|
`/*` gegen `*/` und misst eine Seitenhöhe nach.
|
||||||
|
|
||||||
|
**Falle beim
|
||||||
Containment:** `width: auto` + `max-height` kollabiert eine inline-size-contained Box auf ihr
|
Containment:** `width: auto` + `max-height` kollabiert eine inline-size-contained Box auf ihr
|
||||||
Padding (Inhalt zählt nicht mehr) — im Swipe-Modus bekommt `.stadionzeitung-page` deshalb eine
|
Padding (Inhalt zählt nicht mehr) — im Swipe-Modus bekommen `.stadionzeitung-page` **und
|
||||||
definite Breite (`min(calc(72vh * 148 / 210), 100%)`), nie `width: auto`. Das Cover war der
|
`.stadionzeitung-cover`** deshalb eine definite Breite (`min(calc(72vh * 148 / 210), 100%)`),
|
||||||
|
nie `width: auto`. **Beide stehen dafür in EINER Regel**, und das ist der Punkt: das Cover hatte
|
||||||
|
als eigene Klasse zunächst nur eine „harmlose Höhen-Deckelung" mit `width: auto` und blieb am
|
||||||
|
iPhone unsichtbar, während Chrome am Desktop es zeigte (31.07.2026). Der Unterschied entsteht,
|
||||||
|
weil `.stadionzeitung-viewer__page` `display: flex` ist: auf einem Flex-Kind heißt `width: auto`
|
||||||
|
„richte dich nach dem Inhalt", Chrome rettet sich über `aspect-ratio` plus gestreckte Höhe,
|
||||||
|
Safari nicht. **Chrome kann diesen Fehler nicht reproduzieren** — wer hier etwas ändert, prüft
|
||||||
|
es an einem echten iOS-Gerät, nicht im Desktop-Browser. Das Cover war der
|
||||||
Anfang dieses Prinzips:
|
Anfang dieses Prinzips:
|
||||||
Kontrast der Schlagzeile kommt aus der flachen Verlauf-Fläche selbst, bewusst **ohne** `text-shadow`:
|
Kontrast der Schlagzeile kommt aus der flachen Verlauf-Fläche selbst, bewusst **ohne** `text-shadow`:
|
||||||
Chrome rendert `text-shadow` auf mehrzeiligem, umbrechendem Text (lange Gegner-Namen) beim
|
Chrome rendert `text-shadow` auf mehrzeiligem, umbrechendem Text (lange Gegner-Namen) beim
|
||||||
@@ -653,8 +863,12 @@ nicht `.stadionzeitung-page` (Details siehe unten). Bild-, Live- wie Cover-Seite
|
|||||||
A5-Seitenverhältnis (148:210), damit sich das Durchblättern gleichmäßig anfühlt.
|
A5-Seitenverhältnis (148:210), damit sich das Durchblättern gleichmäßig anfühlt.
|
||||||
|
|
||||||
**Druck — die Druckfassung `?druck=1`** (seit Interview 29.07.2026): das Heft wird als **gefaltetes
|
**Druck — die Druckfassung `?druck=1`** (seit Interview 29.07.2026): das Heft wird als **gefaltetes
|
||||||
A5-Heft** produziert (A4 quer beidseitig drucken, Blätter stapeln, mittig falten). Der
|
A5-Heft** produziert (A4 quer beidseitig drucken, Blätter stapeln, mittig falten). Erreichbar
|
||||||
„Druckfassung"-Link im Viewer führt auf dieselbe Ausgaben-Route mit `?druck=1` —
|
**nur über die URL**: an die Ausgaben-Route ein `?druck=1` anhängen. Der frühere
|
||||||
|
„Druckfassung"-Button im Viewer ist am 31.07.2026 entfernt worden (Felix): die Druckfassung ist
|
||||||
|
Werkzeug für die Heft-Produktion, kein Besucher-Angebot, und als sichtbarer Knopf neben Blättern
|
||||||
|
und Vollbild wurde er versehentlich geklickt. **Nicht als Link wieder einbauen** — wer sie
|
||||||
|
braucht, kennt die URL.
|
||||||
`app/components/stadionzeitung-druck.php` montiert dort die Seiten in
|
`app/components/stadionzeitung-druck.php` montiert dort die Seiten in
|
||||||
**Broschüren-Reihenfolge** (Sattelheftung: Blatt 1 vorne = letzte Seite + Cover, hinten = Seite 2 +
|
**Broschüren-Reihenfolge** (Sattelheftung: Blatt 1 vorne = letzte Seite + Cover, hinten = Seite 2 +
|
||||||
vorletzte, …). Die Montage liegt bewusst im PHP, nicht im Druck-CSS: CSS kann Seiten nicht über
|
vorletzte, …). Die Montage liegt bewusst im PHP, nicht im Druck-CSS: CSS kann Seiten nicht über
|
||||||
@@ -664,8 +878,37 @@ Druckfassung füllt notfalls mit weißen Leerseiten VOR der Rückseite auf. Auf
|
|||||||
die Route eine Blatt-Vorschau (A4-quer-Flächen mit „Blatt 1 · Vorderseite"-Labels) samt Anleitung
|
die Route eine Blatt-Vorschau (A4-quer-Flächen mit „Blatt 1 · Vorderseite"-Labels) samt Anleitung
|
||||||
(**beidseitig, an der kurzen Kante spiegeln**); `assets/js/stadionzeitung-druck.js` liefert nur den
|
(**beidseitig, an der kurzen Kante spiegeln**); `assets/js/stadionzeitung-druck.js` liefert nur den
|
||||||
„Jetzt drucken"-Button (`window.print()`, progressive enhancement — ohne JS Strg/Cmd+P). **Kein
|
„Jetzt drucken"-Button (`window.print()`, progressive enhancement — ohne JS Strg/Cmd+P). **Kein
|
||||||
serverseitiges PDF-Tool** (passt zur Ghostscript/Imagick-Vermeidung oben). Bürodrucker mit weißem
|
serverseitiges PDF-Tool** (passt zur Ghostscript/Imagick-Vermeidung oben).
|
||||||
Rand ist der Anspruch (bewusst gleichmäßiges Passepartout, kein Randabfallend). `@media print`
|
|
||||||
|
**Alle Bögen laufen RANDABFALLEND** (`@page { margin: 0 }`, Beschluss 31.07.2026 — vorher galt hier
|
||||||
|
„Bürodrucker mit weißem Rand ist der Anspruch, bewusst gleichmäßiges Passepartout"). Der Wechsel kam
|
||||||
|
aus einem gemessenen Fehler: die Innenblätter hatten einen 5mm-Rahmen, die zwei Umschlagbögen nicht,
|
||||||
|
und der Blatt-Inhalt war über eine `aspect-ratio` bemaßt und nur **199,1mm** hoch statt 210mm. Die
|
||||||
|
fehlenden 10,9mm sammelten sich **immer unten**, weil der Inhalt oben ausgerichtet ist — gemessen an
|
||||||
|
Blatt 4: mit `margin: 5mm` oben 4,8 / unten 5,8mm, mit `margin: 0` oben 0,0 / unten 10,9mm. Ein Rand
|
||||||
|
kaschierte den Fehler, behob ihn aber nicht. Randabfallend ist die saubere Lösung, weil **zwei A5
|
||||||
|
exakt ein A4 quer sind** (2 × 148,5 = 297mm, Überschuss null): es bleibt nichts zu verteilen.
|
||||||
|
Nachgemessen im erzeugten PDF: oben und unten je 0,0mm auf jedem Bogen. Verbleibendes Weiß steckt in
|
||||||
|
den **Anzeigenmotiven selbst** (viele Sponsoren gestalten auf weißem Grund, 100% weiße Randpixel) und
|
||||||
|
ist nicht unser Layout. Den Rand liefert der Bürodrucker aus seinem Hardware-Minimum (~4 bis 5mm);
|
||||||
|
unser Fließtext verliert dabei nichts, die Live-Seiten haben 6,7cqi = 9,9mm Innenabstand.
|
||||||
|
**Keine `@page`-Randbreite wieder einführen, ohne die Blatt-Höhe gegenzurechnen** — sonst ist der
|
||||||
|
Überschuss zurück.
|
||||||
|
|
||||||
|
Es gibt bewusst auch **keinen Falz-Steg** mehr: die beiden Seiten stoßen mittig aneinander, der
|
||||||
|
Innenabstand der Seiten (9,9mm) hält den Inhalt aus dem Knick. Vorher hatten nur die Innenbögen 2%
|
||||||
|
Steg, der Umschlagbogen nicht — dadurch waren die Seiten dort 441px statt 448,5px breit, was im Heft
|
||||||
|
als „das Programm ist schmaler als das Vorwort drüber" auffiel. **Soll der Steg je zurück, dann für
|
||||||
|
ALLE Bögen**, nie nur für einen Teil.
|
||||||
|
|
||||||
|
**Drei Seiten haben eine eigene `aspect-ratio` und heben per negativem Margin das Seiten-Padding auf,
|
||||||
|
damit ihr Foto bis an die Kante läuft: Vorwort, Sportheim und Termine.** Alle drei brauchen deshalb
|
||||||
|
`min-height: calc(100% + var(--space-l) * 2)` und stehen dafür in **einer** gemeinsamen Regel — beim
|
||||||
|
Vorwort allein behoben fehlte sie den anderen zwei, und die Sportheim-Seite war dadurch 625,7px hoch
|
||||||
|
in einer 636,4px hohen Seite (10,7px weißer Streifen unter dem Foto). Eine vierte Seite mit diesem
|
||||||
|
Konstrukt gehört in denselben Selektor.
|
||||||
|
|
||||||
|
`@media print`
|
||||||
(`:has()`-Scope auf Viewer/Druckfassung, andere Seiten bleiben unangetastet) regelt nur Layout —
|
(`:has()`-Scope auf Viewer/Druckfassung, andere Seiten bleiben unangetastet) regelt nur Layout —
|
||||||
Farben sind digital bereits hell. Der normale Viewer druckt als einfacher Seiten-Stapel (Fallback);
|
Farben sind digital bereits hell. Der normale Viewer druckt als einfacher Seiten-Stapel (Fallback);
|
||||||
fürs Heft immer die Druckfassung nehmen. Bei Änderungen an Komponenten, die in einer Ausgabe
|
fürs Heft immer die Druckfassung nehmen. Bei Änderungen an Komponenten, die in einer Ausgabe
|
||||||
@@ -906,8 +1149,22 @@ mitziehen.
|
|||||||
das neue Jahr erneut aufrufen. Das Jahr außen hält die oberste Ebene kurz, die Abteilungen innen sind
|
das neue Jahr erneut aufrufen. Das Jahr außen hält die oberste Ebene kurz, die Abteilungen innen sind
|
||||||
dieselben wie die Bereiche der Website (dann ist beim Hochladen klar, wohin das Material gehört).
|
dieselben wie die Bereiche der Website (dann ist beim Hochladen klar, wohin das Material gehört).
|
||||||
Tiefer wird nichts vorgegeben; Ereignisse bündeln die Hochladenden selbst als `2026-08-15 Sommerfest`.
|
Tiefer wird nichts vorgegeben; Ereignisse bündeln die Hochladenden selbst als `2026-08-15 Sommerfest`.
|
||||||
Weil `uploads/` gitignored ist, muss das Skript **auf dem Server** laufen — Ordner per FTP hochzuladen
|
Das Skript läuft **auf dem Server** — Ordner per FTP hochzuladen erzeugt bei Umlauten Doppelgänger
|
||||||
erzeugt bei Umlauten Doppelgänger (macOS NFD vs. Linux NFC).
|
(macOS NFD vs. Linux NFC).
|
||||||
|
|
||||||
|
**`uploads/` ist versioniert** (Beschluss 31.07.2026, vorher gitignored): das Material des Vereins ist
|
||||||
|
damit gesichert und beim Entwickeln lokal da, und ein frischer Klon ist wirklich vollständig. Der
|
||||||
|
Ordner ist aber das einzige Verzeichnis im Repo, in das **andere Menschen** schreiben — daraus folgt
|
||||||
|
die eine Regel, die das Ganze zusammenhält:
|
||||||
|
|
||||||
|
> **Erst auf dem Server committen und pushen, dann lokal pullen, dann entwickeln.** Nie umgekehrt.
|
||||||
|
|
||||||
|
Hält man sie ein, kann kein Deploy hochgeladene Fotos überschreiben (ein `pull` fasst neue Dateien
|
||||||
|
auf dem Server ohnehin nicht an, aber ein `reset`/`checkout` schon). Zweiter Preis, dauerhaft: **Git
|
||||||
|
vergisst nichts** — eine gelöschte Datei bleibt in der Historie, das Repo schrumpft nie wieder.
|
||||||
|
Deshalb Videos sparsam: zwei Sportfest-Clips machten allein 85 der ersten 174 MB, und für die
|
||||||
|
Website wären sie ohnehin neu zu kodieren. Wird es unbequem, ist der Schnitt `*.mp4` auszunehmen und
|
||||||
|
Videos separat zu sichern; die Fotos und die `.quelle.txt`-Belege der Chronik sind der wertvolle Teil.
|
||||||
|
|
||||||
**Genau ein Patch in der Fremd-Datei** (`index.php:122`, `$localconfigpath` absolut statt relativ) —
|
**Genau ein Patch in der Fremd-Datei** (`index.php:122`, `$localconfigpath` absolut statt relativ) —
|
||||||
ohne ihn startet die App bei abweichendem Arbeitsverzeichnis ohne Login. **Update ausschließlich über
|
ohne ihn startet die App bei abweichendem Arbeitsverzeichnis ohne Login. **Update ausschließlich über
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ $name = $field('name');
|
|||||||
$email = $field('email');
|
$email = $field('email');
|
||||||
$phone = $field('phone');
|
$phone = $field('phone');
|
||||||
$interest = $field('interest');
|
$interest = $field('interest');
|
||||||
|
$birthdate = $field('birthdate');
|
||||||
$subject = $field('subject');
|
$subject = $field('subject');
|
||||||
$message = $field('message');
|
$message = $field('message');
|
||||||
$privacy = ($_POST['privacy'] ?? '') === '1';
|
$privacy = ($_POST['privacy'] ?? '') === '1';
|
||||||
@@ -74,6 +75,9 @@ if (!$variant['interest_required']) {
|
|||||||
if (!$variant['ask_subject']) {
|
if (!$variant['ask_subject']) {
|
||||||
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
|
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
|
||||||
}
|
}
|
||||||
|
if (!$variant['ask_birthdate']) {
|
||||||
|
$birthdate = ''; // dito — nur die Turn-Variante fragt danach
|
||||||
|
}
|
||||||
|
|
||||||
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
||||||
$errors = [];
|
$errors = [];
|
||||||
@@ -86,6 +90,21 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
|||||||
if (mb_strlen($phone) > 50) {
|
if (mb_strlen($phone) > 50) {
|
||||||
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
|
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
|
||||||
}
|
}
|
||||||
|
// Geburtsdatum: optional, aber wenn angegeben, dann ein echtes Datum in einem
|
||||||
|
// plausiblen Bereich. type=date liefert YYYY-MM-DD; createFromFormat mit
|
||||||
|
// anschließendem Vergleich weist auch Scheindaten wie 2019-02-31 ab (PHP würde
|
||||||
|
// die sonst stillschweigend auf den 3. März verschieben).
|
||||||
|
if ($birthdate !== '') {
|
||||||
|
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
|
||||||
|
$heute = new DateTimeImmutable('today');
|
||||||
|
if ($d === false || $d->format('Y-m-d') !== $birthdate) {
|
||||||
|
$errors['contact-birthdate'] = 'Bitte gib das Geburtsdatum im Format TT.MM.JJJJ an.';
|
||||||
|
} elseif ($d > $heute) {
|
||||||
|
$errors['contact-birthdate'] = 'Das Geburtsdatum kann nicht in der Zukunft liegen.';
|
||||||
|
} elseif ($d < new DateTimeImmutable('1900-01-01')) {
|
||||||
|
$errors['contact-birthdate'] = 'Bitte prüfe das Geburtsdatum.';
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!in_array($interest, $interests, true)) {
|
if (!in_array($interest, $interests, true)) {
|
||||||
$errors['contact-interest'] = $variant['interest_required']
|
$errors['contact-interest'] = $variant['interest_required']
|
||||||
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
|
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
|
||||||
@@ -110,7 +129,8 @@ if ($errors !== []) {
|
|||||||
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
|
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
|
||||||
render_form_invalid($return, [
|
render_form_invalid($return, [
|
||||||
'name' => $name, 'email' => $email, 'phone' => $phone, 'interest' => $interest,
|
'name' => $name, 'email' => $email, 'phone' => $phone, 'interest' => $interest,
|
||||||
'subject' => $subject, 'message' => $message, 'privacy' => $privacy ? '1' : '',
|
'birthdate' => $birthdate, 'subject' => $subject, 'message' => $message,
|
||||||
|
'privacy' => $privacy ? '1' : '',
|
||||||
], $errors);
|
], $errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +168,7 @@ $body = $variant['context'] . " über tsv08kulmbach.de\n"
|
|||||||
. "E-Mail: {$email}\n"
|
. "E-Mail: {$email}\n"
|
||||||
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
|
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
|
||||||
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
|
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
|
||||||
|
. ($birthdate !== '' ? 'Geburtstag des Kindes: ' . german_date($birthdate) . "\n" : '')
|
||||||
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
|
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
|
||||||
. str_repeat('-', 40) . "\n\n"
|
. str_repeat('-', 40) . "\n\n"
|
||||||
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
|
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
|
||||||
|
|||||||
@@ -6,11 +6,23 @@ declare(strict_types=1);
|
|||||||
* Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth).
|
* Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth).
|
||||||
* Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo.
|
* Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo.
|
||||||
* Pro Slide optional: "variant" (CSS-Modifier, z. B. "house" für Eigenwerbung),
|
* Pro Slide optional: "variant" (CSS-Modifier, z. B. "house" für Eigenwerbung),
|
||||||
* "tag" (Badge-Text, Default "Anzeige"), "background" (Bild), "cta.internal"
|
* "tag" (Badge-Text, Default "Anzeige"), "cta.internal"
|
||||||
* (interner Link ohne target=_blank/rel=sponsored, für eigene Seiten/Anker),
|
* (interner Link ohne target=_blank/rel=sponsored, für eigene Seiten/Anker),
|
||||||
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
|
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
|
||||||
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot
|
* für dunkle Firmenlogos).
|
||||||
* der Buttons für diese Slide, z. B. Sponsor-CI).
|
*
|
||||||
|
* "background" ist ein Varianten-Set {base, widths} und wird als <img> gerendert,
|
||||||
|
* NICHT als CSS-Hintergrund. Grund: die CSP (default-src 'self', kein style-src)
|
||||||
|
* verwirft jedes style-Attribut, eine Inline-Variable --banner-bg käme also nie an.
|
||||||
|
* Das fiel lokal nie auf, weil `php -S` keine .htaccess und damit keine CSP kennt —
|
||||||
|
* erst auf echtem Apache blieben die Slides schwarz (31.07.2026). Als <img> ist es
|
||||||
|
* ohnehin besser: srcset lädt am Handy die 640er statt der 1600er. Ein String bleibt
|
||||||
|
* als Einzeldatei zulässig (Bestandsschutz, kein srcset).
|
||||||
|
*
|
||||||
|
* "accent" ist ein NAME, keine Farbe ("breadcrumb") — die Farbe steht als Token in
|
||||||
|
* tokens.css, die Klasse ad-banner__slide--accent-<name> in components.css. Ein
|
||||||
|
* beliebiger Hex-Wert aus der JSON käme nur per Inline-Style in die Seite, und den
|
||||||
|
* verwirft die CSP. Neuer Sponsor mit eigener CI = ein Token plus eine Klasse.
|
||||||
* Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
|
* Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
|
||||||
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
|
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
|
||||||
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
|
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
|
||||||
@@ -34,25 +46,30 @@ $total = count($banners);
|
|||||||
$variant = $banner['variant'] ?? null;
|
$variant = $banner['variant'] ?? null;
|
||||||
$accent = $banner['accent'] ?? null;
|
$accent = $banner['accent'] ?? null;
|
||||||
|
|
||||||
|
// Slug-Filter für beides: der Wert landet in einem Klassennamen, nie ungeprüft.
|
||||||
|
$slug = static fn (string $v): string => (string) preg_replace('/[^a-z0-9-]+/', '', strtolower($v));
|
||||||
|
|
||||||
$classes = 'ad-banner__slide';
|
$classes = 'ad-banner__slide';
|
||||||
if ($bg) {
|
if ($bg) {
|
||||||
$classes .= ' ad-banner__slide--bg';
|
$classes .= ' ad-banner__slide--bg';
|
||||||
}
|
}
|
||||||
if ($accent) {
|
if ($accent) {
|
||||||
$classes .= ' ad-banner__slide--accent';
|
$classes .= ' ad-banner__slide--accent ad-banner__slide--accent-' . $slug((string) $accent);
|
||||||
}
|
}
|
||||||
if ($variant) {
|
if ($variant) {
|
||||||
$classes .= ' ad-banner__slide--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
|
$classes .= ' ad-banner__slide--' . $slug((string) $variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$styleParts = [];
|
// Hintergrundbild als Bild-Prop für die img-Komponente. alt="" — der Hintergrund
|
||||||
if ($bg) {
|
// ist Dekoration, die Aussage der Slide steht als Text daneben.
|
||||||
$styleParts[] = '--banner-bg: url(' . asset($bg) . ')';
|
$bgImage = null;
|
||||||
|
if (is_array($bg) && !empty($bg['base'])) {
|
||||||
|
$bgImage = ['base' => $bg['base'], 'widths' => $bg['widths'] ?? [1600], 'alt' => ''];
|
||||||
|
} elseif (is_string($bg) && $bg !== '') {
|
||||||
|
$datei = PUBLIC_PATH . '/assets/' . ltrim($bg, '/');
|
||||||
|
[$w, $h] = is_file($datei) ? (getimagesize($datei) ?: [0, 0]) : [0, 0];
|
||||||
|
$bgImage = ['src' => $bg, 'width' => $w, 'height' => $h, 'alt' => ''];
|
||||||
}
|
}
|
||||||
if ($accent) {
|
|
||||||
$styleParts[] = '--banner-accent: ' . $accent;
|
|
||||||
}
|
|
||||||
$style = $styleParts !== [] ? ' style="' . e(implode('; ', $styleParts)) . '"' : '';
|
|
||||||
?>
|
?>
|
||||||
<?php
|
<?php
|
||||||
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
|
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
|
||||||
@@ -69,7 +86,17 @@ $total = count($banners);
|
|||||||
. '</a>';
|
. '</a>';
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<li class="<?= e($classes) ?>"<?= $style ?> data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
<li class="<?= e($classes) ?>" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
||||||
|
<?php if ($bgImage !== null): ?>
|
||||||
|
<?php /* Erstes Kind und absolut hinter dem Inhalt. Die erste Slide lädt eager
|
||||||
|
(sie ist above the fold und trägt das LCP), die folgenden lazy. */ ?>
|
||||||
|
<?php component('img', [
|
||||||
|
'image' => $bgImage,
|
||||||
|
'class' => 'ad-banner__bg',
|
||||||
|
'sizes' => '(max-width: 1200px) 100vw, 1200px',
|
||||||
|
'eager' => $i === 0,
|
||||||
|
]); ?>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!empty($banner['logo'])): ?>
|
<?php if (!empty($banner['logo'])): ?>
|
||||||
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
|
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
|
||||||
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>"<?= img_intrinsic_attrs($banner['logo']) ?> loading="lazy" decoding="async">
|
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>"<?= img_intrinsic_attrs($banner['logo']) ?> loading="lazy" decoding="async">
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ declare(strict_types=1);
|
|||||||
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
|
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
|
||||||
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
|
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
|
||||||
* Startseite springt.
|
* Startseite springt.
|
||||||
|
* $notice string optional — hervorgehobener Hinweis DIREKT über dem Formular, plus
|
||||||
|
* $noticeTitle, $noticeTone und $noticeIcon (siehe components/notice.php). Für Dinge, die man wissen muss, BEVOR
|
||||||
|
* man schreibt (Wartelisten-Status der Turnabteilung). Steht bewusst außerhalb
|
||||||
|
* des <form> und damit außerhalb der role="status"-Region: das ist ein
|
||||||
|
* dauerhafter Hinweis, keine Rückmeldung zum Absenden — in der Live-Region
|
||||||
|
* würde ihn ein Screenreader bei jeder Formularmeldung erneut vorlesen.
|
||||||
|
* Der Text selbst gehört in die data/*.json der aufrufenden Seite, damit er
|
||||||
|
* nicht zweimal existiert (auf /turnen ist es derselbe Hinweis, den
|
||||||
|
* group-schedule.php auf der Kinderturn-Unterseite als Kachel zeigt).
|
||||||
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
|
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
|
||||||
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
|
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
|
||||||
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
|
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
|
||||||
@@ -21,6 +30,10 @@ declare(strict_types=1);
|
|||||||
$club = json_load('club');
|
$club = json_load('club');
|
||||||
$return = $return ?? '';
|
$return = $return ?? '';
|
||||||
$intro = $intro ?? null;
|
$intro = $intro ?? null;
|
||||||
|
$notice = $notice ?? '';
|
||||||
|
$noticeTitle = $noticeTitle ?? '';
|
||||||
|
$noticeTone = $noticeTone ?? 'info';
|
||||||
|
$noticeIcon = $noticeIcon ?? 'info-circle';
|
||||||
|
|
||||||
$variant = contact_variant((string) ($department ?? ''));
|
$variant = contact_variant((string) ($department ?? ''));
|
||||||
$interests = $variant['interests'];
|
$interests = $variant['interests'];
|
||||||
@@ -43,6 +56,14 @@ $f = [
|
|||||||
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
|
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
|
||||||
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
|
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
|
||||||
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
|
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
|
||||||
|
// Optional und nur in der Turn-Variante (siehe contact_variant). max/min
|
||||||
|
// begrenzen die Auswahl im Datepicker schon im Browser; die Action prüft es
|
||||||
|
// serverseitig noch einmal.
|
||||||
|
'birthdate' => [
|
||||||
|
'type' => 'date', 'name' => 'birthdate', 'id' => 'contact-birthdate',
|
||||||
|
'label' => 'Geburtsdatum des Kindes', 'hint' => 'Optional. Hilft uns, die passende Gruppe zu finden.',
|
||||||
|
'min' => '1900-01-01', 'max' => date('Y-m-d'),
|
||||||
|
],
|
||||||
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
|
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
|
||||||
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
|
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
|
||||||
'privacy' => [
|
'privacy' => [
|
||||||
@@ -60,6 +81,19 @@ $f = [
|
|||||||
<p><?= e($intro['text']) ?></p>
|
<p><?= e($intro['text']) ?></p>
|
||||||
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
|
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($notice !== ''): ?>
|
||||||
|
<?php /* level h3, weil der Intro-Block direkt darüber die h2 der Sektion trägt — so
|
||||||
|
bleibt die Überschriften-Folge ohne Sprung. Kein role="alert": der Hinweis steht
|
||||||
|
schon beim Laden da und ist keine Reaktion auf eine Eingabe. */ ?>
|
||||||
|
<?php component('notice', [
|
||||||
|
'title' => $noticeTitle,
|
||||||
|
'text' => $notice,
|
||||||
|
'tone' => $noticeTone,
|
||||||
|
'icon' => $noticeIcon,
|
||||||
|
'level' => 'h3',
|
||||||
|
'class' => 'contact__notice',
|
||||||
|
]); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
|
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
|
||||||
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
||||||
@@ -79,6 +113,9 @@ $f = [
|
|||||||
<div class="form__row">
|
<div class="form__row">
|
||||||
<?php component('form-field', ['field' => $f['phone']]); ?>
|
<?php component('form-field', ['field' => $f['phone']]); ?>
|
||||||
<?php component('form-field', ['field' => $f['interest']]); ?>
|
<?php component('form-field', ['field' => $f['interest']]); ?>
|
||||||
|
<?php if ($variant['ask_birthdate']): ?>
|
||||||
|
<?php component('form-field', ['field' => $f['birthdate']]); ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
|
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* FAQ-Accordion. Props:
|
* FAQ-Accordion. Props:
|
||||||
* $faq array [{q, a}, …]
|
* $faq array [{q, a, ctas?: [{label, slug, hash?, style?}]}, …]
|
||||||
|
* ctas ist optional und rendert Knöpfe unter der Antwort (über die cta-Komponente),
|
||||||
|
* für Antworten, an deren Ende ein Handgriff steht — etwa „so meldest du dich an".
|
||||||
|
* Das FAQPage-Schema ist davon unberührt: faq_schema() liest nur q und a. Die
|
||||||
|
* Antwort muss den Weg deshalb IN WORTEN nennen, sonst steht bei Google eine
|
||||||
|
* Antwort ohne Ziel.
|
||||||
* $title string (optional, Default 'Häufige Fragen')
|
* $title string (optional, Default 'Häufige Fragen')
|
||||||
* $anchor string (optional, Default 'faq')
|
* $anchor string (optional, Default 'faq')
|
||||||
* Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert
|
* Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert
|
||||||
@@ -27,7 +32,16 @@ if ($faq === []) {
|
|||||||
<?php foreach ($faq as $item): ?>
|
<?php foreach ($faq as $item): ?>
|
||||||
<details class="faq__item">
|
<details class="faq__item">
|
||||||
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
|
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
|
||||||
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
|
<div class="faq__a">
|
||||||
|
<p><?= inline_links_html($item['a']) ?></p>
|
||||||
|
<?php if (!empty($item['ctas'])): ?>
|
||||||
|
<div class="faq__actions">
|
||||||
|
<?php foreach ($item['ctas'] as $cta): ?>
|
||||||
|
<?php component('cta', ['cta' => $cta]); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ declare(strict_types=1);
|
|||||||
* Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare.
|
* Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare.
|
||||||
*
|
*
|
||||||
* Props: $field (array):
|
* Props: $field (array):
|
||||||
* type text | email | tel | select | textarea | checkbox (Default: text)
|
* type text | email | tel | date | select | textarea | checkbox (Default: text)
|
||||||
* name, label Pflicht
|
* name, label Pflicht
|
||||||
* id Default: field-<name>
|
* id Default: field-<name>
|
||||||
* required bool
|
* required bool
|
||||||
* autocomplete, maxlength, rows (textarea)
|
* autocomplete, maxlength, rows (textarea), min/max (date)
|
||||||
* options[] + placeholder (select)
|
* options[] + placeholder (select)
|
||||||
* hint optionaler Hilfetext (aria-describedby)
|
* hint optionaler Hilfetext (aria-describedby)
|
||||||
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
|
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
|
||||||
|
|||||||
@@ -5,13 +5,43 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props:
|
* Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props:
|
||||||
* $location array{venue, street?, zip?, city?} (optional) — Trainingsort + „Route planen"
|
* $location array{venue, street?, zip?, city?} (optional) — Trainingsort + „Route planen"
|
||||||
* $lead string (optional) — verantwortliche Übungsleitung (Akzent-Kachel)
|
* $lead string (optional) — Ansprechperson der Disziplin (Akzent-Kachel)
|
||||||
|
* $contactEmail string (optional) — persönliche E-Mail der Ansprechperson für die
|
||||||
|
* Kontakt-Kachel. Überschreibt die Abteilungs-Mail (club.email_turnen), falls
|
||||||
|
* gesetzt — genutzt, wenn eine Disziplin einen eigenen direkten Draht angibt.
|
||||||
|
* $contactPhone string (optional) — Telefonnummer der Ansprechperson (Anzeige-Form). Ergibt
|
||||||
|
* einen zusätzlichen tel:-Link in der Kontakt-Kachel. tel:-Href wie in person-card.php.
|
||||||
* $notice string (optional) — hervorgehobener Hinweis (z. B. Wartelisten-Status)
|
* $notice string (optional) — hervorgehobener Hinweis (z. B. Wartelisten-Status)
|
||||||
|
* $noticeTitle string (optional) — Überschrift dazu
|
||||||
|
* $noticeTone 'info' (Default) | 'stop' — Tonlage, siehe components/notice.php
|
||||||
|
* $noticeIcon string (optional) — Icon-Name, siehe components/notice.php
|
||||||
|
* Gerendert wird über component('notice') — dieselbe Komponente, die
|
||||||
|
* contact-form.php über dem Formular benutzt, damit derselbe Hinweis an
|
||||||
|
* beiden Orten gleich aussieht und nur EINMAL in data/turnen.json steht.
|
||||||
* $groups array von Gruppen:
|
* $groups array von Gruppen:
|
||||||
* { name, level?, intro?, details?:[{heading,text}], recommendation?, note?,
|
* { name, level?, contact?, intro?, details?:[{heading,text}], recommendation?,
|
||||||
* trainers?:[…], sessions:[{ label?, day, time, note?, trainers?:[…] }] }
|
* note?, trainers?:[…], sessions:[{ label?, day, time, note?, trainers?:[…] }] }
|
||||||
|
* contact = Ansprechperson NUR dieser Gruppe. Braucht es, sobald eine Disziplin
|
||||||
|
* mehrere Gruppen mit verschiedenen Ansprechpersonen hat (Wettkampfturnen:
|
||||||
|
* Flamingos → Theresa Diener, Leistungsturnen → Franziska Trotzke) — die
|
||||||
|
* Kontakt-Kachel oben kann nur EINE nennen und wäre für die andere falsch.
|
||||||
|
* Beschriftet als „Kontakt" wie die Kachel, nicht als „Ansprechpartner":
|
||||||
|
* das Label kennt das Geschlecht der Person nicht (Haus-Stil Paarformen).
|
||||||
* $footnote string (optional) — Erläuterung (z. B. zur Sternchen-Kennzeichnung)
|
* $footnote string (optional) — Erläuterung (z. B. zur Sternchen-Kennzeichnung)
|
||||||
* $groupsTitle string (optional, Default 'Unsere Gruppen')
|
* $groupsTitle string (optional, Default 'Unsere Gruppen')
|
||||||
|
* $groupsTitleHidden bool (optional) — H2 bleibt im DOM (Dokumentgliederung für
|
||||||
|
* Screenreader, Sektion behält ihren Namen), ist aber visuell versteckt.
|
||||||
|
* Dasselbe Muster wie $title_hidden in team-cards. NICHT die Überschrift
|
||||||
|
* weglassen: dann hätte die Sektion keinen zugänglichen Namen mehr.
|
||||||
|
* $layout 'tabs' (Default) | 'stacked' — greift nur bei beschreibenden Gruppen.
|
||||||
|
* 'stacked' zeigt alle Karten offen untereinander (wenige Gruppen),
|
||||||
|
* 'tabs' ordnet viele textreiche Gruppen (Kinderturnen).
|
||||||
|
* $groupsInDash bool (optional) — nur für SCHLICHTE Gruppen (ohne intro/details): zieht die
|
||||||
|
* Gruppen-Karten als volle Bodenreihe MIT INS Bento-Dashboard, ohne eigene Sektion
|
||||||
|
* und ohne „Unsere Gruppen"-Überschrift. Für Disziplinen mit wenigen kurzen Gruppen
|
||||||
|
* (Trampolin), damit das Dashboard ein zusammenhängender Block bleibt statt einer
|
||||||
|
* losgelösten Kartenreihe darunter. Bei beschreibenden Gruppen (Wettkampfturnen,
|
||||||
|
* Kinderturnen) wirkungslos — die brauchen Platz und ihre eigene Sektion.
|
||||||
* $anchor string (optional, Default 'training')
|
* $anchor string (optional, Default 'training')
|
||||||
*
|
*
|
||||||
* Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard.
|
* Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard.
|
||||||
@@ -20,9 +50,17 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
$anchor = $anchor ?? 'training';
|
$anchor = $anchor ?? 'training';
|
||||||
$groupsTitle = $groupsTitle ?? 'Unsere Gruppen';
|
$groupsTitle = $groupsTitle ?? 'Unsere Gruppen';
|
||||||
|
$groupsTitleHidden = $groupsTitleHidden ?? false;
|
||||||
|
$layout = ($layout ?? 'tabs') === 'stacked' ? 'stacked' : 'tabs';
|
||||||
$lead = $lead ?? '';
|
$lead = $lead ?? '';
|
||||||
|
$contactEmail = $contactEmail ?? '';
|
||||||
|
$contactPhone = $contactPhone ?? '';
|
||||||
$notice = $notice ?? '';
|
$notice = $notice ?? '';
|
||||||
|
$noticeTitle = $noticeTitle ?? '';
|
||||||
|
$noticeTone = $noticeTone ?? 'info';
|
||||||
|
$noticeIcon = $noticeIcon ?? 'info-circle';
|
||||||
$footnote = $footnote ?? '';
|
$footnote = $footnote ?? '';
|
||||||
|
$groupsInDash = !empty($groupsInDash);
|
||||||
$groups = $groups ?? [];
|
$groups = $groups ?? [];
|
||||||
$location = $location ?? [];
|
$location = $location ?? [];
|
||||||
|
|
||||||
@@ -31,7 +69,8 @@ if ($groups === []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$club = json_load('club');
|
$club = json_load('club');
|
||||||
$email = $club['email_turnen'] ?? ($club['email'] ?? '');
|
$email = $contactEmail !== '' ? $contactEmail : ($club['email_turnen'] ?? ($club['email'] ?? ''));
|
||||||
|
$telHref = $contactPhone !== '' ? 'tel:' . preg_replace('/[^+\d]/', '', $contactPhone) : '';
|
||||||
|
|
||||||
$single = count($groups) === 1;
|
$single = count($groups) === 1;
|
||||||
$rich = false;
|
$rich = false;
|
||||||
@@ -42,6 +81,9 @@ foreach ($groups as $g) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schlichte Gruppen direkt ins Dashboard ziehen (nur wenn mehrere und keine „reich").
|
||||||
|
$groupsInline = $groupsInDash && !$single && !$rich;
|
||||||
|
|
||||||
$hasVenue = !empty($location['venue']);
|
$hasVenue = !empty($location['venue']);
|
||||||
$mapsQuery = $hasVenue ? rawurlencode(trim(
|
$mapsQuery = $hasVenue ? rawurlencode(trim(
|
||||||
($location['venue'] ?? '') . ', '
|
($location['venue'] ?? '') . ', '
|
||||||
@@ -50,7 +92,14 @@ $mapsQuery = $hasVenue ? rawurlencode(trim(
|
|||||||
', '
|
', '
|
||||||
)) : '';
|
)) : '';
|
||||||
|
|
||||||
/** Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Übungsleitung) ausgeben. */
|
/**
|
||||||
|
* Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Trainerteam) ausgeben.
|
||||||
|
*
|
||||||
|
* Das Label heißt „Trainerteam", nicht „Übungsleitung": in der Liste stehen auch
|
||||||
|
* Personen, die KEINE Übungsleitung sind (Eva Wünsch aus der Abteilung, 31.07.2026).
|
||||||
|
* Wer eine ist, trägt ein Sternchen, und das erklärt die Fußnote der Sektion. Die alte
|
||||||
|
* Seite schrieb dafür „TRAINERTEAM (* = ÜBUNGSLEITER)". Nicht zurückbenennen.
|
||||||
|
*/
|
||||||
$renderSession = static function (array $s): void {
|
$renderSession = static function (array $s): void {
|
||||||
?>
|
?>
|
||||||
<li class="schedule-row">
|
<li class="schedule-row">
|
||||||
@@ -65,93 +114,20 @@ $renderSession = static function (array $s): void {
|
|||||||
<span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
|
<span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($s['trainers'])): ?>
|
<?php if (!empty($s['trainers'])): ?>
|
||||||
<span class="schedule-row__trainers"><span class="schedule-row__trainers-label">Übungsleitung:</span> <?= e(implode(', ', $s['trainers'])) ?></span>
|
<span class="schedule-row__trainers"><span class="schedule-row__trainers-label">Trainerteam:</span> <?= e(implode(', ', $s['trainers'])) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<?php
|
<?php
|
||||||
};
|
};
|
||||||
|
|
||||||
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : [];
|
/**
|
||||||
$soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
* Inhalt einer beschreibenden Gruppen-Karte (ohne das <article> drumherum). Als Closure,
|
||||||
?>
|
* weil es diese Karte in zwei Fassungen gibt: als Tab-Panel (mit role/aria) und gestapelt
|
||||||
<section class="section turnen-dash-section" id="<?= e($anchor) ?>">
|
* (ohne). Vorher stand das Markup nur in der Tab-Fassung; eine zweite Kopie daneben wäre
|
||||||
<div class="container">
|
* die Stelle, an der beide auseinanderlaufen.
|
||||||
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?>">
|
*/
|
||||||
<?php if ($notice !== ''): ?>
|
$renderCardBody = static function (array $g) use ($renderSession): void {
|
||||||
<article class="bento__tile turnen-dash__notice">
|
?>
|
||||||
<?= icon('info-circle', 'turnen-dash__notice-icon') ?>
|
|
||||||
<p><?= e($notice) ?></p>
|
|
||||||
</article>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($single): ?>
|
|
||||||
<article class="bento__tile turnen-dash__training">
|
|
||||||
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
|
|
||||||
<ul class="bento__sessions">
|
|
||||||
<?php foreach ($soleSessions as $s): ?>
|
|
||||||
<li>
|
|
||||||
<span class="bento__day"><?= e($s['day']) ?></span>
|
|
||||||
<span class="bento__time"><?= e($s['time']) ?></span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php if ($soleNote !== ''): ?>
|
|
||||||
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
|
|
||||||
</article>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($lead !== '' || $email !== ''): ?>
|
|
||||||
<article class="bento__tile bento__tile--accent turnen-dash__contact">
|
|
||||||
<span class="bento__stat-label">Kontakt</span>
|
|
||||||
<?php if ($lead !== ''): ?>
|
|
||||||
<span class="bento__stat-value"><?= e($lead) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($email !== ''): ?>
|
|
||||||
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($hasVenue): ?>
|
|
||||||
<article class="bento__tile turnen-dash__venue">
|
|
||||||
<div class="turnen-dash__venue-main">
|
|
||||||
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
|
|
||||||
<p class="bento__address">
|
|
||||||
<strong><?= e($location['venue']) ?></strong>
|
|
||||||
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
|
|
||||||
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
|
|
||||||
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
|
|
||||||
</article>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<?php if (!$single): ?>
|
|
||||||
<section class="section group-list-section" id="gruppen">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="group-list__title"><?= e($groupsTitle) ?></h2>
|
|
||||||
<?php if ($rich): ?>
|
|
||||||
<div class="group-tabs" data-group-tabs>
|
|
||||||
<div class="group-tabs__bar" role="tablist" aria-label="<?= e($groupsTitle) ?>">
|
|
||||||
<?php foreach ($groups as $i => $g): ?>
|
|
||||||
<button class="tab group-tabs__tab" role="tab" type="button"
|
|
||||||
id="gtab-<?= $i ?>"
|
|
||||||
aria-controls="gpanel-<?= $i ?>"
|
|
||||||
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
|
|
||||||
><?= e($g['name']) ?></button>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php foreach ($groups as $i => $g): ?>
|
|
||||||
<article class="group-card group-tabs__panel" role="tabpanel"
|
|
||||||
id="gpanel-<?= $i ?>"
|
|
||||||
aria-labelledby="gtab-<?= $i ?>"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div class="group-card__body">
|
<div class="group-card__body">
|
||||||
<div class="group-card__header">
|
<div class="group-card__header">
|
||||||
<h3 class="group-card__name"><?= e($g['name']) ?></h3>
|
<h3 class="group-card__name"><?= e($g['name']) ?></h3>
|
||||||
@@ -180,20 +156,27 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
|||||||
<ul class="schedule-list">
|
<ul class="schedule-list">
|
||||||
<?php foreach ($g['sessions'] as $s): $renderSession($s); endforeach; ?>
|
<?php foreach ($g['sessions'] as $s): $renderSession($s); endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
<?php if (!empty($g['contact'])): ?>
|
||||||
|
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Kontakt:</span> <?= e($g['contact']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!empty($g['trainers'])): ?>
|
<?php if (!empty($g['trainers'])): ?>
|
||||||
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Übungsleitung:</span> <?= e(implode(', ', $g['trainers'])) ?></p>
|
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Trainerteam:</span> <?= e(implode(', ', $g['trainers'])) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($g['note'])): ?>
|
<?php if (!empty($g['note'])): ?>
|
||||||
<p class="group-card__note text-muted"><?= e($g['note']) ?></p>
|
<p class="group-card__note text-muted"><?= e($g['note']) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</article>
|
<?php
|
||||||
<?php endforeach; ?>
|
};
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
/**
|
||||||
<div class="group-list group-list--grid">
|
* Schlichte Gruppen-Karte (Name + Trainingszeiten). Als Closure, weil sie an zwei Stellen
|
||||||
<?php foreach ($groups as $g): ?>
|
* gebraucht wird: im Raster der eigenen Gruppen-Sektion und — bei $groupsInline — als Kachel
|
||||||
|
* direkt im Bento-Dashboard.
|
||||||
|
*/
|
||||||
|
$renderSimpleCard = static function (array $g) use ($renderSession): void {
|
||||||
|
?>
|
||||||
<article class="group-card">
|
<article class="group-card">
|
||||||
<div class="group-card__body">
|
<div class="group-card__body">
|
||||||
<div class="group-card__header">
|
<div class="group-card__header">
|
||||||
@@ -211,7 +194,123 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</article>
|
</article>
|
||||||
|
<?php
|
||||||
|
};
|
||||||
|
|
||||||
|
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : [];
|
||||||
|
$soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||||
|
?>
|
||||||
|
<section class="section turnen-dash-section" id="<?= e($anchor) ?>">
|
||||||
|
<div class="container">
|
||||||
|
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?><?= $groupsInline ? ' turnen-dash--groups' : '' ?>">
|
||||||
|
<?php if ($notice !== ''): ?>
|
||||||
|
<?php component('notice', [
|
||||||
|
'title' => $noticeTitle,
|
||||||
|
'text' => $notice,
|
||||||
|
'tone' => $noticeTone,
|
||||||
|
'icon' => $noticeIcon,
|
||||||
|
'class' => 'turnen-dash__notice',
|
||||||
|
]); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($single): ?>
|
||||||
|
<article class="bento__tile turnen-dash__training">
|
||||||
|
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
|
||||||
|
<ul class="bento__sessions">
|
||||||
|
<?php foreach ($soleSessions as $s): ?>
|
||||||
|
<li>
|
||||||
|
<span class="bento__day"><?= e($s['day']) ?></span>
|
||||||
|
<span class="bento__time"><?= e($s['time']) ?></span>
|
||||||
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php if ($soleNote !== ''): ?>
|
||||||
|
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($lead !== '' || $email !== '' || $telHref !== ''): ?>
|
||||||
|
<article class="bento__tile bento__tile--accent turnen-dash__contact">
|
||||||
|
<span class="bento__stat-label">Kontakt</span>
|
||||||
|
<?php if ($lead !== ''): ?>
|
||||||
|
<span class="bento__stat-value"><?= e($lead) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($email !== ''): ?>
|
||||||
|
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($telHref !== ''): ?>
|
||||||
|
<a class="turnen-dash__mail" href="<?= e($telHref) ?>"><?= icon('telephone', 'turnen-dash__mail-icon') ?><?= e($contactPhone) ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($hasVenue): ?>
|
||||||
|
<article class="bento__tile turnen-dash__venue">
|
||||||
|
<div class="turnen-dash__venue-main">
|
||||||
|
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
|
||||||
|
<p class="bento__address">
|
||||||
|
<strong><?= e($location['venue']) ?></strong>
|
||||||
|
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
|
||||||
|
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
|
||||||
|
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($groupsInline): ?>
|
||||||
|
<div class="turnen-dash__groups group-list--grid">
|
||||||
|
<?php foreach ($groups as $g): $renderSimpleCard($g); endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($groupsInline && $footnote !== ''): ?>
|
||||||
|
<p class="group-list__footnote text-muted"><?= e($footnote) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if (!$single && !$groupsInline): ?>
|
||||||
|
<section class="section group-list-section<?= $groupsTitleHidden ? ' group-list-section--tight' : '' ?>" id="gruppen">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="group-list__title<?= $groupsTitleHidden ? ' visually-hidden' : '' ?>"><?= e($groupsTitle) ?></h2>
|
||||||
|
<?php if ($rich && $layout === 'stacked'): ?>
|
||||||
|
<?php /* Karten direkt untereinander, alle sichtbar. Für Disziplinen mit wenigen Gruppen:
|
||||||
|
Tabs verstecken dort mehr Inhalt als sie ordnen, und die offene Kartenreihe hält
|
||||||
|
den Bento-Eindruck der Seite. Bei vielen textreichen Gruppen (Kinderturnen) sind
|
||||||
|
Tabs weiter richtig, sonst wird die Seite endlos. */ ?>
|
||||||
|
<div class="group-list group-list--stacked">
|
||||||
|
<?php foreach ($groups as $g): ?>
|
||||||
|
<article class="group-card">
|
||||||
|
<?php $renderCardBody($g); ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($rich): ?>
|
||||||
|
<div class="group-tabs" data-group-tabs>
|
||||||
|
<div class="group-tabs__bar" role="tablist" aria-label="<?= e($groupsTitle) ?>">
|
||||||
|
<?php foreach ($groups as $i => $g): ?>
|
||||||
|
<button class="tab group-tabs__tab" role="tab" type="button"
|
||||||
|
id="gtab-<?= $i ?>"
|
||||||
|
aria-controls="gpanel-<?= $i ?>"
|
||||||
|
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
|
||||||
|
><?= e($g['name']) ?></button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php foreach ($groups as $i => $g): ?>
|
||||||
|
<article class="group-card group-tabs__panel" role="tabpanel"
|
||||||
|
id="gpanel-<?= $i ?>"
|
||||||
|
aria-labelledby="gtab-<?= $i ?>"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<?php $renderCardBody($g); ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="group-list group-list--grid">
|
||||||
|
<?php foreach ($groups as $g): $renderSimpleCard($g); endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($footnote !== ''): ?>
|
<?php if ($footnote !== ''): ?>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props:
|
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props:
|
||||||
* $image array{base: string, widths: int[], alt: string} — Varianten <base>-<w>.jpg
|
* $image array{base: string, widths: int[], alt: string, ext?: string} — Varianten
|
||||||
|
* <base>-<w>.<ext>, Default-Endung jpg. ext: 'png' für freigestellte Motive,
|
||||||
|
* deren Transparenz JPG zerstören würde (Spielerfoto auf dem Cover).
|
||||||
* ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
|
* ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
|
||||||
* $sizes string (optional, default '100vw')
|
* $sizes string (optional, default '100vw')
|
||||||
* $eager bool (optional) — above the fold: eager + fetchpriority high
|
* $eager bool (optional) — above the fold: eager + fetchpriority high
|
||||||
@@ -24,15 +26,16 @@ if (!empty($image['src'])): ?>
|
|||||||
|
|
||||||
$widths = $image['widths'];
|
$widths = $image['widths'];
|
||||||
$largest = max($widths);
|
$largest = max($widths);
|
||||||
|
$ext = strtolower((string) ($image['ext'] ?? 'jpg'));
|
||||||
$srcset = implode(', ', array_map(
|
$srcset = implode(', ', array_map(
|
||||||
static fn (int $w): string => asset("{$image['base']}-{$w}.jpg") . " {$w}w",
|
static fn (int $w): string => asset("{$image['base']}-{$w}.{$ext}") . " {$w}w",
|
||||||
$widths
|
$widths
|
||||||
));
|
));
|
||||||
|
|
||||||
// Maße der größten Variante für width/height (CLS-Vermeidung).
|
// Maße der größten Variante für width/height (CLS-Vermeidung).
|
||||||
[$w, $h] = (function () use ($image, $largest): array {
|
[$w, $h] = (function () use ($image, $largest, $ext): array {
|
||||||
static $dims = [];
|
static $dims = [];
|
||||||
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg";
|
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.{$ext}";
|
||||||
$key = $file;
|
$key = $file;
|
||||||
if (!isset($dims[$key])) {
|
if (!isset($dims[$key])) {
|
||||||
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
|
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
|
||||||
@@ -42,7 +45,7 @@ $srcset = implode(', ', array_map(
|
|||||||
})();
|
})();
|
||||||
?>
|
?>
|
||||||
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
|
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
|
||||||
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>"
|
src="<?= e(asset("{$image['base']}-{$largest}.{$ext}")) ?>"
|
||||||
srcset="<?= e($srcset) ?>"
|
srcset="<?= e($srcset) ?>"
|
||||||
sizes="<?= e($sizes) ?>"
|
sizes="<?= e($sizes) ?>"
|
||||||
alt="<?= e($image['alt']) ?>"
|
alt="<?= e($image['alt']) ?>"
|
||||||
|
|||||||
43
app/components/notice.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hervorgehobener Hinweis, den es in zwei Tonlagen gibt. Props:
|
||||||
|
* $text string — der Hinweis selbst (Pflicht, leer → nichts)
|
||||||
|
* $title string (optional) — Überschrift. Ohne sie liest sich besonders die rote
|
||||||
|
* Variante wie eine Fehlermeldung; mit ihr ist klar, dass es eine Information ist.
|
||||||
|
* $tone 'info' (Default) | 'stop' — 'info' ist die neutrale Glas-Fläche mit rotem Icon,
|
||||||
|
* 'stop' die gefüllte rote. Die Tonlage sagt, ob etwas GEHT oder NICHT geht:
|
||||||
|
* „neue Kinder können schnuppern" ist eine Einladung und darf nicht so aussehen
|
||||||
|
* wie „wir können niemanden aufnehmen". Rot ist die Ausnahme, nicht der Standard.
|
||||||
|
* $icon string (optional, Default 'info-circle') — Icon-Name aus public/assets/icons/
|
||||||
|
* $level 'h2' (Default) | 'h3' — Überschriftenebene, passend zur Umgebung, damit die
|
||||||
|
* Folge auf der Seite nicht springt
|
||||||
|
* $class string (optional) — Zusatzklassen für die Einbettung (Position/Breite im
|
||||||
|
* jeweiligen Layout). Farbe und Innenaufbau kommen ausschließlich von hier.
|
||||||
|
*
|
||||||
|
* Bewusst eine Komponente statt dreimal dasselbe Markup: der Hinweis steht auf
|
||||||
|
* /turnen über dem Anfrageformular, als Kachel auf /turnen/eltern-kind-turnen und
|
||||||
|
* auf /turnen/trampolin. Der TEXT gehört nie hierher, sondern in die data/*.json
|
||||||
|
* der Seite (dort steht auch, welche Tonlage gilt).
|
||||||
|
*/
|
||||||
|
$text = trim((string) ($text ?? ''));
|
||||||
|
if ($text === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$title = trim((string) ($title ?? ''));
|
||||||
|
$tone = ($tone ?? 'info') === 'stop' ? 'stop' : 'info';
|
||||||
|
$icon = (string) ($icon ?? 'info-circle');
|
||||||
|
$level = ($level ?? 'h2') === 'h3' ? 'h3' : 'h2';
|
||||||
|
$class = trim((string) ($class ?? ''));
|
||||||
|
?>
|
||||||
|
<div class="notice notice--<?= $tone ?><?= $class !== '' ? ' ' . e($class) : '' ?>">
|
||||||
|
<?= icon($icon, 'notice__icon') ?>
|
||||||
|
<div class="notice__body">
|
||||||
|
<?php if ($title !== ''): ?>
|
||||||
|
<<?= $level ?> class="notice__title"><?= e($title) ?></<?= $level ?>>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p><?= e($text) ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
155
app/components/stadionzeitung-aufruf.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufruf-Seite der Stadionzeitung (Live-Seite) — eine redaktionelle Content-
|
||||||
|
* Seite für einen Handlungsaufruf, zuerst gebaut für den Helferaufruf zum
|
||||||
|
* Sportfest, bewusst GENERISCH gehalten (nicht `sportfest`), damit sie später
|
||||||
|
* auch für andere Feste (Weihnachtsfeier o. Ä.) taugt.
|
||||||
|
*
|
||||||
|
* Der Inhalt ist KEIN Skript-Input: er wird wie Vorwort/Interview im Chat
|
||||||
|
* gepflegt und lebt im `aufruf`-Feld der Ausgabe in data/stadionzeitung.json:
|
||||||
|
* kicker kleine Zeile über der Überschrift (z. B. „Sportfest 2026")
|
||||||
|
* heading Überschrift der Seite
|
||||||
|
* intro Vorspann (Lese-Serif, wie Vorwort/Interview) — String ODER
|
||||||
|
* Liste von Zeilen (mehrere Zeilen = ein Absatz mit <br>)
|
||||||
|
* tasks_title Überschrift über der Aufgaben-Liste (optional)
|
||||||
|
* tasks[] wobei Hilfe gebraucht wird
|
||||||
|
* contacts_intro Zeile über den Ansprechpartnern (optional)
|
||||||
|
* contacts[] NAMEN — Nummer/Foto zieht die Seite per Namensabgleich aus
|
||||||
|
* data/ansprechpartner.json (geteilte Quelle, nie duplizieren;
|
||||||
|
* wie die Kontakt-Seite bewusst live statt Snapshot: ein Fest
|
||||||
|
* betrifft ohnehin die Zukunft, alte Nummern nützen niemandem)
|
||||||
|
* link interner Slug fürs Ziel (z. B. „veranstaltungen/sportfest-2026"),
|
||||||
|
* liefert Link-Zeile + QR im CTA-Fuß
|
||||||
|
* poster optional, kleines Plakat rechts neben Intro + Aufgaben
|
||||||
|
* (Varianten-Set {base,widths,alt} oder {src,…}) — z. B. das
|
||||||
|
* Sportfest-Vorderplakat aus data/veranstaltungen.json
|
||||||
|
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe weiter (wie
|
||||||
|
* `vorwort`/`interview`), plant die Seite nur ein wenn das Feld existiert und
|
||||||
|
* erzeugt den QR (qr-aufruf.png im Ausgaben-Ordner) aus `link`.
|
||||||
|
*
|
||||||
|
* Props: $issue (Eintrag aus data/stadionzeitung.json)
|
||||||
|
*/
|
||||||
|
$issue = $issue ?? [];
|
||||||
|
$aufruf = $issue['aufruf'] ?? null;
|
||||||
|
if (!is_array($aufruf)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$slug = (string) ($issue['slug'] ?? '');
|
||||||
|
$heading = trim((string) ($aufruf['heading'] ?? 'Mach mit'));
|
||||||
|
$kicker = trim((string) ($aufruf['kicker'] ?? 'Mitmachen'));
|
||||||
|
// intro darf ein String ODER eine Liste von Zeilen sein — mehrere Zeilen
|
||||||
|
// werden als EIN Absatz mit weichen Umbrüchen (<br>) gesetzt, damit ein kurzer
|
||||||
|
// Aufruf bewusst zeilenweise stehen kann statt als Fließblock.
|
||||||
|
$introLines = array_values(array_filter(
|
||||||
|
array_map(static fn ($l): string => trim((string) $l), (array) ($aufruf['intro'] ?? [])),
|
||||||
|
static fn (string $l): bool => $l !== ''
|
||||||
|
));
|
||||||
|
$tasksTitle = trim((string) ($aufruf['tasks_title'] ?? ''));
|
||||||
|
$tasks = array_values(array_filter(
|
||||||
|
array_map(static fn ($t): string => trim((string) $t), $aufruf['tasks'] ?? []),
|
||||||
|
static fn (string $t): bool => $t !== ''
|
||||||
|
));
|
||||||
|
$contactsIntro = trim((string) ($aufruf['contacts_intro'] ?? ''));
|
||||||
|
$link = trim((string) ($aufruf['link'] ?? ''));
|
||||||
|
$ctaLabel = trim((string) ($aufruf['cta_label'] ?? 'Alle Infos'));
|
||||||
|
$ctaTitle = trim((string) ($aufruf['cta_title'] ?? ''));
|
||||||
|
|
||||||
|
// Kontakte per Namensabgleich aus der geteilten Quelle auflösen (Nummer/Foto),
|
||||||
|
// die Reihenfolge folgt dem `contacts`-Feld. Ein unbekannter Name bleibt als
|
||||||
|
// reiner Name stehen (kein Fehler), damit ein Tippfehler die Seite nicht kippt.
|
||||||
|
$byName = [];
|
||||||
|
foreach (json_load('ansprechpartner')['persons'] ?? [] as $person) {
|
||||||
|
$name = (string) ($person['name'] ?? '');
|
||||||
|
if ($name !== '') {
|
||||||
|
$byName[$name] = $person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$contacts = [];
|
||||||
|
foreach ($aufruf['contacts'] ?? [] as $name) {
|
||||||
|
$name = trim((string) $name);
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$contacts[] = $byName[$name] ?? ['name' => $name];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qr = ($slug !== '' && is_file(PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}/qr-aufruf.png"))
|
||||||
|
? "img/stadionzeitung/{$slug}/qr-aufruf.png"
|
||||||
|
: null;
|
||||||
|
$linkLabel = $link !== '' ? club_domain($link) : '';
|
||||||
|
|
||||||
|
// Optionales kleines Plakat rechts neben Intro + Aufgaben (z. B. das Sportfest-
|
||||||
|
// Vorderplakat) — Varianten-Set {base,widths,alt} oder Einzeldatei {src,…}.
|
||||||
|
$poster = $aufruf['poster'] ?? null;
|
||||||
|
if (!is_array($poster) || (empty($poster['base']) && empty($poster['src']))) {
|
||||||
|
$poster = null;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="stadionzeitung-aufruf">
|
||||||
|
<header class="stadionzeitung-page__head">
|
||||||
|
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
|
||||||
|
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
|
||||||
|
</header>
|
||||||
|
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||||
|
<div class="stadionzeitung-aufruf__cols<?= $poster ? ' stadionzeitung-aufruf__cols--poster' : '' ?>">
|
||||||
|
<div class="stadionzeitung-aufruf__col-main">
|
||||||
|
<?php if ($introLines !== []): ?>
|
||||||
|
<p class="stadionzeitung-aufruf__intro"><?= implode('<br>', array_map('e', $introLines)) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($tasks !== []): ?>
|
||||||
|
<div class="stadionzeitung-aufruf__tasks">
|
||||||
|
<?php if ($tasksTitle !== ''): ?>
|
||||||
|
<p class="stadionzeitung-aufruf__tasks-title"><?= e($tasksTitle) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<ul class="stadionzeitung-aufruf__task-list">
|
||||||
|
<?php foreach ($tasks as $task): ?>
|
||||||
|
<li class="stadionzeitung-aufruf__task"><?= icon('check2', 'stadionzeitung-aufruf__task-icon') ?><span><?= e($task) ?></span></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($poster): ?>
|
||||||
|
<?php component('img', ['image' => $poster, 'sizes' => '150px', 'class' => 'stadionzeitung-aufruf__poster']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($contacts !== []): ?>
|
||||||
|
<div class="stadionzeitung-aufruf__contacts">
|
||||||
|
<?php if ($contactsIntro !== ''): ?>
|
||||||
|
<p class="stadionzeitung-aufruf__contacts-intro"><?= e($contactsIntro) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<ul class="stadionzeitung-aufruf__contact-list">
|
||||||
|
<?php foreach ($contacts as $contact): ?>
|
||||||
|
<li class="stadionzeitung-aufruf__contact">
|
||||||
|
<span class="stadionzeitung-aufruf__contact-name"><?= e((string) ($contact['name'] ?? '')) ?></span>
|
||||||
|
<?php if (!empty($contact['phone'])): ?>
|
||||||
|
<span class="stadionzeitung-aufruf__contact-phone"><?= icon('telephone', 'stadionzeitung-aufruf__contact-icon') ?><?= e((string) $contact['phone']) ?></span>
|
||||||
|
<?php elseif (!empty($contact['email'])): ?>
|
||||||
|
<span class="stadionzeitung-aufruf__contact-phone"><?= icon('envelope', 'stadionzeitung-aufruf__contact-icon') ?><?= e((string) $contact['email']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($linkLabel !== ''): ?>
|
||||||
|
<div class="stadionzeitung-page__cta">
|
||||||
|
<div class="stadionzeitung-page__cta-text">
|
||||||
|
<p class="stadionzeitung-page__cta-label"><?= e($ctaLabel) ?></p>
|
||||||
|
<?php if ($ctaTitle !== ''): ?>
|
||||||
|
<p class="stadionzeitung-page__cta-title"><?= e($ctaTitle) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="stadionzeitung-page__cta-link"><?= e($linkLabel) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php if ($qr !== null): ?>
|
||||||
|
<div class="stadionzeitung-page__cta-abriss">
|
||||||
|
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||||
|
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -31,11 +31,20 @@ $issues = $issues ?? [];
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="stadionzeitung-card__body">
|
<div class="stadionzeitung-card__body">
|
||||||
<h3 class="stadionzeitung-card__name">Gegen <?= e($issue['opponent']) ?></h3>
|
<h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e($issue['opponent']) ?></h3>
|
||||||
<p class="stadionzeitung-card__meta">
|
<p class="stadionzeitung-card__meta">
|
||||||
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
||||||
· <?= (int) $issue['pages'] ?> Seiten
|
· <?= (int) $issue['pages'] ?> Seiten
|
||||||
</p>
|
</p>
|
||||||
|
<?php /* Coverzeile als Teaser — dieselbe Quelle (stadionzeitung_teaser()), die auch
|
||||||
|
auf dem Cover unter „Im Heft" steht: cover.teaser einer Sonderausgabe, sonst
|
||||||
|
die Coverzeile des Interviews. Sie füllt die breite Zeile mit echtem Inhalt
|
||||||
|
statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte. Gibt es
|
||||||
|
beides nicht, entfällt die Zeile (wie bei .veranstaltung-card__text). */
|
||||||
|
$kartenTeaser = stadionzeitung_teaser($issue); ?>
|
||||||
|
<?php if ($kartenTeaser !== ''): ?>
|
||||||
|
<p class="stadionzeitung-card__text"><?= e($kartenTeaser) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
|
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -69,19 +69,82 @@ if ($match !== null) {
|
|||||||
|
|
||||||
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
|
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
|
||||||
$linkText = club_domain();
|
$linkText = club_domain();
|
||||||
$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : ''));
|
// Aufmacher unten links: normal die Begegnung (Wappen + „Heimspiel gegen …").
|
||||||
|
// Zeigt die Ausgabe auf eine Veranstaltung (Sportfest-Ausgabe), tritt an deren
|
||||||
|
// Stelle das FEST als Titelgeschichte — denn ein Fest bringt mehrere Heimspiele
|
||||||
|
// mit (Erste, Zweite, Frauen), und ein einzelnes davon aufs Cover zu heben wäre
|
||||||
|
// falsch (Felix, 03.09.2026). Titel und Zeitraum kommen aus
|
||||||
|
// data/veranstaltungen.json, nie neu getippt.
|
||||||
|
$feature = null;
|
||||||
|
$featureV = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||||
|
if ($featureV !== null) {
|
||||||
|
$feature = [
|
||||||
|
'title' => trim((string) ($featureV['title'] ?? '')),
|
||||||
|
'range' => german_date_range((string) ($featureV['date_start'] ?? ''), $featureV['date_end'] ?? null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bildmotiv rechts: normal das freigestellte Spielerfoto. Eine Sonderausgabe mit
|
||||||
|
// Veranstaltung zeigt stattdessen deren HAUPTPLAKAT (erstes Plakat, dieselbe
|
||||||
|
// Quelle wie /veranstaltungen/<slug>, nie eine Kopie) — leicht schräg gelegt mit
|
||||||
|
// zwei abgerundeten Ecken, wie ein aufs Cover gelegter Aushang (Felix,
|
||||||
|
// 03.09.2026). Ohne Plakat bleibt es beim Spielerfoto, ohne beides trägt der
|
||||||
|
// Hintergrund die Seite allein.
|
||||||
|
$poster = null;
|
||||||
|
if ($featureV !== null) {
|
||||||
|
$ersteseite = $featureV['plakate'][0] ?? null;
|
||||||
|
if (is_array($ersteseite) && !empty($ersteseite['base'])) {
|
||||||
|
$poster = $ersteseite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kicker: Saison · Datum, auf jedem Cover gleich. Eine Sonderausgabe bekommt
|
||||||
|
// bewusst KEINE zusätzliche Kennung im Kopf: ein roter Kasten „Sportfest-Ausgabe"
|
||||||
|
// war gebaut und am 03.09.2026 wieder entfernt (Felix) — er stach aus dem
|
||||||
|
// ruhigen Kopf heraus, und die Ausgabe kennzeichnet sich unten links ohnehin
|
||||||
|
// selbst durch den großen Fest-Titel.
|
||||||
|
$kicker = implode(' · ', array_filter([
|
||||||
|
$season,
|
||||||
|
$date !== '' ? german_date($date) : '',
|
||||||
|
], static fn (string $s): bool => $s !== ''));
|
||||||
|
|
||||||
// Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —
|
// Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —
|
||||||
// erscheint automatisch, sobald die Ausgabe ein Interview trägt
|
// aus stadionzeitung_teaser(): `cover.teaser` einer Sonderausgabe, sonst die
|
||||||
// (interview-Feld, chat-gepflegt wie das Vorwort).
|
// Coverzeile des Interviews. Dieselbe Funktion speist die Archivkarte.
|
||||||
$teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
$teaser = stadionzeitung_teaser($issue);
|
||||||
?>
|
?>
|
||||||
<div class="stadionzeitung-cover">
|
<div class="stadionzeitung-cover<?= $poster !== null ? ' stadionzeitung-cover--poster' : '' ?>">
|
||||||
<?php if ($heroImage): ?>
|
<?php if ($heroImage): ?>
|
||||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($playerImage)): ?>
|
<?php if ($poster !== null): ?>
|
||||||
<img class="stadionzeitung-cover__player" src="<?= e(asset($playerImage)) ?>" alt="" loading="lazy">
|
<?php /* Alt-Text bewusst leer: der Plakat-Alt-Text aus veranstaltungen.json
|
||||||
|
beschreibt die Termine, die drei Zeilen weiter unten als Text stehen —
|
||||||
|
auf dem Cover wäre er eine Dopplung fürs Screenreader-Ohr. */ ?>
|
||||||
|
<?php component('img', [
|
||||||
|
'image' => ['base' => $poster['base'], 'widths' => $poster['widths'] ?? [], 'alt' => ''],
|
||||||
|
'class' => 'stadionzeitung-cover__poster',
|
||||||
|
'sizes' => '(max-width: 600px) 55vw, 260px',
|
||||||
|
]); ?>
|
||||||
|
<?php elseif (!empty($playerImage)): ?>
|
||||||
|
<?php
|
||||||
|
/* Varianten-Set (base/widths/ext=png) statt der Originaldatei. Das Original ist
|
||||||
|
ein Freisteller mit 2584x5093 Pixeln 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 — das Cover blieb am iPhone leer, während Chrome
|
||||||
|
am Desktop es zeigte (31.07.2026). Dargestellt wird es mit 98cqi Höhe, bei
|
||||||
|
480px Seitenbreite also etwa 238x470 CSS-Pixel; die 1000er Variante deckt
|
||||||
|
damit auch Druck und dreifache Pixeldichte ab.
|
||||||
|
sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte. */
|
||||||
|
$playerBild = is_array($playerImage)
|
||||||
|
? $playerImage
|
||||||
|
: ['src' => $playerImage, 'width' => 0, 'height' => 0, 'alt' => ''];
|
||||||
|
?>
|
||||||
|
<?php component('img', [
|
||||||
|
'image' => $playerBild,
|
||||||
|
'class' => 'stadionzeitung-cover__player',
|
||||||
|
'sizes' => '(max-width: 600px) 50vw, 240px',
|
||||||
|
]); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="stadionzeitung-cover__inner">
|
<div class="stadionzeitung-cover__inner">
|
||||||
<header class="stadionzeitung-cover__masthead">
|
<header class="stadionzeitung-cover__masthead">
|
||||||
@@ -95,6 +158,17 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
|||||||
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
|
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="stadionzeitung-cover__corner">
|
<div class="stadionzeitung-cover__corner">
|
||||||
|
<?php if ($feature !== null): ?>
|
||||||
|
<?php /* Sonderausgabe: das Fest ist die Titelgeschichte, nicht eine der
|
||||||
|
Begegnungen. Titel in der Display-Schrift (Versalien wie der Masthead),
|
||||||
|
Zeitraum darunter als gesperrte Zeile. */ ?>
|
||||||
|
<div class="stadionzeitung-cover__feature">
|
||||||
|
<p class="stadionzeitung-cover__feature-title"><?= e($feature['title']) ?></p>
|
||||||
|
<?php if ($feature['range'] !== ''): ?>
|
||||||
|
<p class="stadionzeitung-cover__meta"><?= e($feature['range']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
<div class="stadionzeitung-cover__headline">
|
<div class="stadionzeitung-cover__headline">
|
||||||
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
|
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
|
||||||
ruhiger) — die beiden Logos sprechen für sich. */ ?>
|
ruhiger) — die beiden Logos sprechen für sich. */ ?>
|
||||||
@@ -107,6 +181,7 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
|||||||
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
|
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if ($teaser !== ''): ?>
|
<?php if ($teaser !== ''): ?>
|
||||||
<p class="stadionzeitung-cover__teaser">
|
<p class="stadionzeitung-cover__teaser">
|
||||||
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>
|
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>
|
||||||
|
|||||||
65
app/components/stadionzeitung-danke.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Danke-Seite der Stadionzeitung (Live-Seite): der Dank an unsere Partner mit
|
||||||
|
* der Logo-Wand aus data/partners.json (Single Source, dieselbe Quelle wie die
|
||||||
|
* Startseite) und dem QR auf /partner-werden. Gegenstück zur Akquise-Seite
|
||||||
|
* stadionzeitung-partner.php: hier stehen die, die schon dabei sind.
|
||||||
|
*
|
||||||
|
* Vorgeschichte: der Dank war Teil eines Canva-Bilds, das unten an die
|
||||||
|
* Emons-Anzeige geklebt war — eine leere Fläche mit Wappen und zwei Zeilen
|
||||||
|
* (Felix, 03.09.2026: „sieht schrecklich aus"). Seitdem eine echte Seite, die
|
||||||
|
* die Partner auch zeigt statt nur zu erwähnen.
|
||||||
|
*
|
||||||
|
* Die Anzeige oben ist KEIN Bestandteil der Komponente, sondern kommt als Prop:
|
||||||
|
* bin/stadionzeitung-add.php nimmt Anzeigen mit `platzierung: danke` aus dem
|
||||||
|
* Streu-Pool und schreibt das Motiv in den Seiteneintrag. Dadurch ist es in
|
||||||
|
* images[] eingefroren wie jede andere Anzeige — eine veröffentlichte Ausgabe
|
||||||
|
* ändert sich nicht mehr, wenn der Sponsor später ein neues Motiv liefert.
|
||||||
|
*
|
||||||
|
* Props: $anzeige (Varianten-Set {base,widths,alt} oder null)
|
||||||
|
*/
|
||||||
|
$anzeige = (is_array($anzeige ?? null) && !empty($anzeige['base'])) ? $anzeige : null;
|
||||||
|
$partners = json_load('partners')['partners'] ?? [];
|
||||||
|
$qr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
|
||||||
|
? 'img/stadionzeitung/qr-partner-werden.png'
|
||||||
|
: null;
|
||||||
|
?>
|
||||||
|
<div class="stadionzeitung-danke">
|
||||||
|
<?php if ($anzeige !== null): ?>
|
||||||
|
<?php component('img', ['image' => $anzeige, 'sizes' => '420px', 'class' => 'stadionzeitung-danke__anzeige']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<header class="stadionzeitung-page__head stadionzeitung-danke__head">
|
||||||
|
<p class="stadionzeitung-page__kicker">Unsere Partner</p>
|
||||||
|
<?php /* Überschrift bewusst OHNE Anrede: Partner sind Unternehmen, die wir mit
|
||||||
|
„Sie" ansprechen (Sprachregel, CLAUDE.md), ein „danke, dass ihr dabei
|
||||||
|
seid" würde sie duzen. „Danke für die Unterstützung" trägt den Dank
|
||||||
|
ohne diesen Konflikt, der CTA darunter bleibt in der Sie-Form. */ ?>
|
||||||
|
<h2 class="stadionzeitung-page__heading">Danke für die Unterstützung</h2>
|
||||||
|
</header>
|
||||||
|
<?php if ($partners !== []): ?>
|
||||||
|
<ul class="stadionzeitung-danke__logos">
|
||||||
|
<?php foreach ($partners as $partner): ?>
|
||||||
|
<?php if (empty($partner['logo'])) { continue; } ?>
|
||||||
|
<li class="stadionzeitung-danke__logo">
|
||||||
|
<img src="<?= e(asset((string) $partner['logo'])) ?>" alt="<?= e((string) ($partner['name'] ?? '')) ?>"<?= img_intrinsic_attrs((string) $partner['logo']) ?> loading="lazy" decoding="async">
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="stadionzeitung-page__cta">
|
||||||
|
<div class="stadionzeitung-page__cta-text">
|
||||||
|
<p class="stadionzeitung-page__cta-label">Mitmachen</p>
|
||||||
|
<p class="stadionzeitung-page__cta-title">Werden Sie unser Partner</p>
|
||||||
|
<p class="stadionzeitung-page__cta-link"><?= e(club_domain('partner-werden')) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php if ($qr !== null): ?>
|
||||||
|
<div class="stadionzeitung-page__cta-abriss">
|
||||||
|
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||||
|
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
96
app/components/stadionzeitung-gegner.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gegnerstatistik der Stadionzeitung. Alle Werte stammen aus dem bereits
|
||||||
|
* aufgebauten Stadionzeitungs-Snapshot; die Komponente rendert nur. Dadurch
|
||||||
|
* sind Entwurf und veröffentlichte Druckausgabe konsistent und archivfest.
|
||||||
|
*
|
||||||
|
* Props: $info stadionzeitung_live_data($issue)['gegner'][team] — ein Eintrag der
|
||||||
|
* Map je Team-Key (team_name/opponent/league/season/fixture/own/
|
||||||
|
* opponent_stats). Normal nur die tragende Mannschaft; in der
|
||||||
|
* Sportfest-Ausgabe eine Seite je Heimspiel am Fest, deshalb
|
||||||
|
* nennt der Kicker die Mannschaft.
|
||||||
|
*/
|
||||||
|
$info = is_array($info ?? null) ? $info : [];
|
||||||
|
$teamName = trim((string) ($info['team_name'] ?? ''));
|
||||||
|
$fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : [];
|
||||||
|
$own = is_array($info['own'] ?? null) ? $info['own'] : null;
|
||||||
|
$opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null;
|
||||||
|
$opponentName = (string) ($info['opponent'] ?? 'Unser Gegner');
|
||||||
|
|
||||||
|
$kickoff = (string) ($fixture['kickoff'] ?? '');
|
||||||
|
$kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : '';
|
||||||
|
$kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : '';
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
['label' => 'Tabellenplatz', 'key' => 'rank'],
|
||||||
|
['label' => 'Spiele', 'key' => 'played'],
|
||||||
|
['label' => 'Siege', 'key' => 'won'],
|
||||||
|
['label' => 'Unentschieden', 'key' => 'drawn'],
|
||||||
|
['label' => 'Niederlagen', 'key' => 'lost'],
|
||||||
|
['label' => 'Tordifferenz', 'key' => 'goal_diff', 'signed' => true],
|
||||||
|
['label' => 'Punkte', 'key' => 'points'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$format = static function (?array $row, string $key, bool $signed = false): string {
|
||||||
|
if ($row === null || !array_key_exists($key, $row)) {
|
||||||
|
return '–';
|
||||||
|
}
|
||||||
|
$value = (int) $row[$key];
|
||||||
|
return $signed && $value > 0 ? '+' . $value : (string) $value;
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="stadionzeitung-gegner">
|
||||||
|
<header class="stadionzeitung-page__head">
|
||||||
|
<p class="stadionzeitung-page__kicker"><?= e($teamName !== '' ? 'Unser Gegner · ' . $teamName : 'Unser Gegner') ?></p>
|
||||||
|
<h2 class="stadionzeitung-page__heading"><?= e($opponentName) ?></h2>
|
||||||
|
<?php if (!empty($info['league']) || !empty($info['season'])): ?>
|
||||||
|
<p class="stadionzeitung-page__sub"><?= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</header>
|
||||||
|
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<div class="stadionzeitung-gegner__duell">
|
||||||
|
<div class="stadionzeitung-gegner__club stadionzeitung-gegner__club--own">
|
||||||
|
<?php component('crest', ['src' => $fixture['home_crest'] ?? $own['crest'] ?? null, 'name' => $fixture['home'] ?? 'TSV 08 Kulmbach']); ?>
|
||||||
|
<strong><?= e((string) ($fixture['home'] ?? 'TSV 08 Kulmbach')) ?></strong>
|
||||||
|
</div>
|
||||||
|
<div class="stadionzeitung-gegner__match">
|
||||||
|
<span>gegen</span>
|
||||||
|
<?php if ($kickoffDate !== ''): ?>
|
||||||
|
<strong><?= e(german_date($kickoffDate)) ?></strong>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($kickoffTime !== ''): ?>
|
||||||
|
<span><?= e($kickoffTime) ?> Uhr</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="stadionzeitung-gegner__club">
|
||||||
|
<?php component('crest', ['src' => $fixture['away_crest'] ?? $opponent['crest'] ?? null, 'name' => $fixture['away'] ?? $opponentName]); ?>
|
||||||
|
<strong><?= e((string) ($fixture['away'] ?? $opponentName)) ?></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($own !== null && $opponent !== null): ?>
|
||||||
|
<div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich">
|
||||||
|
<div class="stadionzeitung-gegner__vergleich-head" role="row">
|
||||||
|
<?php /* Spaltenköpfe generisch: vorher standen hier „TSV 08" und „ASV" fest im
|
||||||
|
Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe
|
||||||
|
mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?>
|
||||||
|
<span role="columnheader">Wir</span>
|
||||||
|
<span role="columnheader">Vergleich</span>
|
||||||
|
<span role="columnheader">Gegner</span>
|
||||||
|
</div>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<div class="stadionzeitung-gegner__vergleich-row" role="row">
|
||||||
|
<strong role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
|
||||||
|
<span role="rowheader"><?= e((string) $row['label']) ?></span>
|
||||||
|
<strong role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
71
app/components/stadionzeitung-programm.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programm-Seite der Stadionzeitung (Live-Seite, Sportfest-Ausgabe): das
|
||||||
|
* Tagesprogramm einer Veranstaltung, je Tag eine Gruppe (Wochentag + Datum),
|
||||||
|
* darunter Zeit | Titel (+ Zusatz). Kopf im Muster der Geschwister-Seiten.
|
||||||
|
*
|
||||||
|
* Quelle ist AUSSCHLIESSLICH data/veranstaltungen.json → programm[] der
|
||||||
|
* Veranstaltung, auf die die Ausgabe zeigt (`veranstaltung`-Feld, Slug). Dieselbe
|
||||||
|
* Liste rendert die Website-Detailseite über veranstaltung-programm.php — eine
|
||||||
|
* Quelle für Heft und Website, hier nur eine zweite Darstellung fürs A5-Blatt
|
||||||
|
* (cqi-Maße statt Website-Tokens). Bewusst live, kein Snapshot: chat-gepflegte
|
||||||
|
* Daten wie bei kontakt/aufruf, kein Sync räumt hier etwas weg.
|
||||||
|
*
|
||||||
|
* Props: $issue (Eintrag aus data/stadionzeitung.json)
|
||||||
|
*/
|
||||||
|
$issue = $issue ?? [];
|
||||||
|
$v = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||||
|
$programm = is_array($v['programm'] ?? null) ? $v['programm'] : [];
|
||||||
|
if ($v === null || $programm === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$zeitraum = german_date_range((string) ($v['date_start'] ?? ''), $v['date_end'] ?? null);
|
||||||
|
$sub = trim(implode(' · ', array_filter([$zeitraum, (string) ($v['location'] ?? '')])));
|
||||||
|
?>
|
||||||
|
<div class="stadionzeitung-programm">
|
||||||
|
<header class="stadionzeitung-page__head">
|
||||||
|
<p class="stadionzeitung-page__kicker"><?= e((string) ($v['title'] ?? 'Veranstaltung')) ?></p>
|
||||||
|
<h2 class="stadionzeitung-page__heading">Programm</h2>
|
||||||
|
<?php if ($sub !== ''): ?>
|
||||||
|
<p class="stadionzeitung-page__sub"><?= e($sub) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</header>
|
||||||
|
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||||
|
<?php foreach ($programm as $tag): ?>
|
||||||
|
<?php
|
||||||
|
$tagIso = (string) ($tag['tag'] ?? '');
|
||||||
|
$weekday = $tagIso !== '' ? german_weekday($tagIso) : '';
|
||||||
|
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . ($tagIso !== '' ? german_date($tagIso) : ''));
|
||||||
|
$punkte = is_array($tag['punkte'] ?? null) ? $tag['punkte'] : [];
|
||||||
|
if ($punkte === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<section class="stadionzeitung-programm__tag">
|
||||||
|
<?php if ($tagText !== ''): ?>
|
||||||
|
<h3 class="stadionzeitung-programm__tag-titel"><time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time></h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
<ul class="stadionzeitung-programm__liste">
|
||||||
|
<?php foreach ($punkte as $punkt): ?>
|
||||||
|
<?php $zeit = trim((string) ($punkt['zeit'] ?? '')); ?>
|
||||||
|
<li class="stadionzeitung-programm__punkt">
|
||||||
|
<?php if ($zeit !== ''): ?>
|
||||||
|
<span class="stadionzeitung-programm__zeit"><time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="stadionzeitung-programm__body">
|
||||||
|
<span class="stadionzeitung-programm__titel"><?= e((string) ($punkt['titel'] ?? '')) ?></span>
|
||||||
|
<?php if (!empty($punkt['text'])): ?>
|
||||||
|
<span class="stadionzeitung-programm__text"><?= e((string) $punkt['text']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
@@ -9,9 +9,12 @@ declare(strict_types=1);
|
|||||||
* anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
|
* anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
|
||||||
* $entry Eintrag aus issue.images[]:
|
* $entry Eintrag aus issue.images[]:
|
||||||
* type 'image' (base/widths/alt),
|
* type 'image' (base/widths/alt),
|
||||||
* type 'live' (page: cover|vorwort|interview|tabelle|termine|
|
* type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier|
|
||||||
* news|ergebnisse|vorschau|kontakt; tabelle optional
|
* tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt|
|
||||||
* mit team: Team-Key aus matchcenter.json),
|
* mitglied|historie|sportheim|partner|danke|momente|impressum;
|
||||||
|
* tabelle und gegner optional mit team: Team-Key aus
|
||||||
|
* matchcenter.json; turnier mit turnier: Key aus
|
||||||
|
* veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung),
|
||||||
* type 'leer' (weiße Füllseite, nur Druckfassung — hält die
|
* type 'leer' (weiße Füllseite, nur Druckfassung — hält die
|
||||||
* Seitenzahl auf einem Vielfachen von 4)
|
* Seitenzahl auf einem Vielfachen von 4)
|
||||||
* $issue Eintrag aus data/stadionzeitung.json
|
* $issue Eintrag aus data/stadionzeitung.json
|
||||||
@@ -46,12 +49,30 @@ $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 'aufruf': ?>
|
||||||
|
<?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?>
|
||||||
|
<?php break; ?>
|
||||||
<?php case 'termine': ?>
|
<?php case 'termine': ?>
|
||||||
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
|
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
|
||||||
<?php break; ?>
|
<?php break; ?>
|
||||||
<?php case 'ergebnisse': ?>
|
<?php case 'ergebnisse': ?>
|
||||||
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
|
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
|
||||||
<?php break; ?>
|
<?php break; ?>
|
||||||
|
<?php case 'gegner': ?>
|
||||||
|
<?php
|
||||||
|
// `gegner` ist eine Map je Team-Key (wie `tabellen`), seit die Sportfest-
|
||||||
|
// Ausgabe je Heimspiel eine Gegnerseite hat. Ohne team-Feld (Ausgaben vor
|
||||||
|
// dieser Änderung) greift die tragende Mannschaft.
|
||||||
|
$gegnerTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
|
||||||
|
component('stadionzeitung-gegner', ['info' => $data['gegner'][$gegnerTeam] ?? null]);
|
||||||
|
?>
|
||||||
|
<?php break; ?>
|
||||||
|
<?php case 'programm': ?>
|
||||||
|
<?php component('stadionzeitung-programm', ['issue' => $issue]); ?>
|
||||||
|
<?php break; ?>
|
||||||
|
<?php case 'turnier': ?>
|
||||||
|
<?php component('stadionzeitung-turnier', ['issue' => $issue, 'turnier' => (string) ($entry['turnier'] ?? '')]); ?>
|
||||||
|
<?php break; ?>
|
||||||
<?php case 'vorschau': ?>
|
<?php case 'vorschau': ?>
|
||||||
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
|
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
|
||||||
<?php break; ?>
|
<?php break; ?>
|
||||||
@@ -70,6 +91,11 @@ $type = $entry['type'] ?? 'image';
|
|||||||
<?php case 'partner': ?>
|
<?php case 'partner': ?>
|
||||||
<?php component('stadionzeitung-partner', []); ?>
|
<?php component('stadionzeitung-partner', []); ?>
|
||||||
<?php break; ?>
|
<?php break; ?>
|
||||||
|
<?php case 'danke': ?>
|
||||||
|
<?php /* Das Anzeigenmotiv steht im Seiteneintrag (platzierung: danke), damit es
|
||||||
|
wie jede andere Anzeige in images[] eingefroren ist. */ ?>
|
||||||
|
<?php component('stadionzeitung-danke', ['anzeige' => $entry['anzeige'] ?? null]); ?>
|
||||||
|
<?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; ?>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
|||||||
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
|
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
|
||||||
* Icon-Liste statt Fließtext.
|
* Icon-Liste statt Fließtext.
|
||||||
*
|
*
|
||||||
* Die drei Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert
|
* Die Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert
|
||||||
* sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort
|
* sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort
|
||||||
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
|
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
|
||||||
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
|
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
|
||||||
@@ -60,6 +60,8 @@ $termineLink = club_domain('termine');
|
|||||||
} elseif (!$isMatch && !empty($item['location'])) {
|
} elseif (!$isMatch && !empty($item['location'])) {
|
||||||
$parts[] = (string) $item['location'];
|
$parts[] = (string) $item['location'];
|
||||||
}
|
}
|
||||||
|
$title = (string) ($item['title'] ?? '');
|
||||||
|
$meta = implode(' · ', $parts);
|
||||||
?>
|
?>
|
||||||
<li class="stadionzeitung-termine__item">
|
<li class="stadionzeitung-termine__item">
|
||||||
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
|
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
|
||||||
@@ -67,8 +69,8 @@ $termineLink = club_domain('termine');
|
|||||||
Blick unterscheidbar. */ ?>
|
Blick unterscheidbar. */ ?>
|
||||||
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
|
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
|
||||||
<div class="stadionzeitung-termine__body">
|
<div class="stadionzeitung-termine__body">
|
||||||
<p class="stadionzeitung-termine__title"><?= e((string) ($item['title'] ?? '')) ?></p>
|
<p class="stadionzeitung-termine__title" title="<?= e($title) ?>"><?= e($title) ?></p>
|
||||||
<p class="stadionzeitung-termine__meta"><?= e(implode(' · ', $parts)) ?></p>
|
<p class="stadionzeitung-termine__meta" title="<?= e($meta) ?>"><?= e($meta) ?></p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
158
app/components/stadionzeitung-turnier.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turnierplan-Seite der Stadionzeitung (Live-Seite, Sportfest-Ausgabe): EIN
|
||||||
|
* Jugendturnier pro Seite — Kopf (Veranstaltung · Tag / „Turnier F1-Jugend" /
|
||||||
|
* Beginn · Spieldauer · Felder · Modus), Teilnehmer, Spielplan mit LEERER
|
||||||
|
* Ergebnisspalte und eine leere Abschlusstabelle. Das Blatt ist zum
|
||||||
|
* Beschreiben gedacht: am Platz trägt man Ergebnisse und Plätze von Hand ein.
|
||||||
|
*
|
||||||
|
* Quelle ist AUSSCHLIESSLICH data/veranstaltungen.json → turniere[] der
|
||||||
|
* Veranstaltung, auf die die Ausgabe zeigt (`veranstaltung`-Feld); welches
|
||||||
|
* Turnier, sagt der `turnier`-Key des Seiteneintrags. Bewusst live, kein
|
||||||
|
* Snapshot (chat-gepflegte Daten, wie kontakt/aufruf/programm).
|
||||||
|
*
|
||||||
|
* Eintrag: key, title, date, start, spieldauer (Minuten), felder? (Zahl),
|
||||||
|
* modus? (Freitext), teams[], spiele[]{nr, zeit, feld?, heim, gast}
|
||||||
|
*
|
||||||
|
* Eigenes Team wie league-table tr.is-own hervorgehoben (Tönung + Abel-Fett);
|
||||||
|
* erkannt über den Vereinsnamen aus data/club.json als Präfix, damit auch
|
||||||
|
* „TSV 08 Kulmbach 2" getroffen wird.
|
||||||
|
*
|
||||||
|
* Props: $issue (Eintrag aus data/stadionzeitung.json), $turnier (Key)
|
||||||
|
*/
|
||||||
|
$issue = $issue ?? [];
|
||||||
|
$turnierKey = trim((string) ($turnier ?? ''));
|
||||||
|
$v = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||||
|
$t = null;
|
||||||
|
foreach ($v['turniere'] ?? [] as $eintrag) {
|
||||||
|
if (is_array($eintrag) && (string) ($eintrag['key'] ?? '') === $turnierKey) {
|
||||||
|
$t = $eintrag;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($v === null || $t === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clubName = trim((string) (json_load('club')['name'] ?? 'TSV 08 Kulmbach'));
|
||||||
|
$isOwn = static fn (string $team): bool => $clubName !== ''
|
||||||
|
&& str_starts_with(mb_strtolower(trim($team)), mb_strtolower($clubName));
|
||||||
|
|
||||||
|
$date = (string) ($t['date'] ?? '');
|
||||||
|
$weekday = $date !== '' ? german_weekday($date) : '';
|
||||||
|
$kicker = trim(implode(' · ', array_filter([
|
||||||
|
(string) ($v['title'] ?? ''),
|
||||||
|
trim(($weekday !== '' ? $weekday . ', ' : '') . ($date !== '' ? german_date($date) : '')),
|
||||||
|
])));
|
||||||
|
|
||||||
|
$teams = array_values(array_filter(
|
||||||
|
array_map(static fn ($x): string => trim((string) $x), $t['teams'] ?? []),
|
||||||
|
static fn (string $x): bool => $x !== ''
|
||||||
|
));
|
||||||
|
$spiele = array_values(array_filter($t['spiele'] ?? [], 'is_array'));
|
||||||
|
$felder = (int) ($t['felder'] ?? 0);
|
||||||
|
$hatFeld = $felder > 1;
|
||||||
|
|
||||||
|
$meta = [];
|
||||||
|
if (!empty($t['start'])) {
|
||||||
|
$meta[] = 'Beginn ' . (string) $t['start'] . ' Uhr';
|
||||||
|
}
|
||||||
|
if (!empty($t['spieldauer'])) {
|
||||||
|
$meta[] = 'Spieldauer ' . (int) $t['spieldauer'] . ' Min.';
|
||||||
|
}
|
||||||
|
if ($hatFeld) {
|
||||||
|
$meta[] = $felder . ' Felder';
|
||||||
|
}
|
||||||
|
if (!empty($t['modus'])) {
|
||||||
|
$meta[] = (string) $t['modus'];
|
||||||
|
}
|
||||||
|
$sub = trim(implode(' · ', $meta));
|
||||||
|
$title = trim((string) ($t['title'] ?? $turnierKey));
|
||||||
|
// Ab neun Spielen (E-Jugend: zehn) rücken die Zeilen enger, sonst läuft der
|
||||||
|
// Spielplan mit der Abschlusstabelle über die Blattkante.
|
||||||
|
$dicht = count($spiele) > 8;
|
||||||
|
?>
|
||||||
|
<div class="stadionzeitung-turnier<?= $dicht ? ' stadionzeitung-turnier--dicht' : '' ?>">
|
||||||
|
<header class="stadionzeitung-page__head">
|
||||||
|
<?php if ($kicker !== ''): ?>
|
||||||
|
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<h2 class="stadionzeitung-page__heading"><?= e('Turnier ' . $title) ?></h2>
|
||||||
|
<?php if ($sub !== ''): ?>
|
||||||
|
<p class="stadionzeitung-page__sub"><?= e($sub) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</header>
|
||||||
|
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||||
|
<?php if ($teams !== []): ?>
|
||||||
|
<p class="stadionzeitung-turnier__label">Teilnehmer</p>
|
||||||
|
<p class="stadionzeitung-turnier__teams">
|
||||||
|
<?php foreach ($teams as $i => $team): ?>
|
||||||
|
<?= $i > 0 ? ' · ' : '' ?><span class="stadionzeitung-turnier__team<?= $isOwn($team) ? ' is-own' : '' ?>"><?= e($team) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($spiele !== []): ?>
|
||||||
|
<table class="stadionzeitung-turnier__spiele">
|
||||||
|
<?php /* Sichtbares Label IST die Tabellenbeschriftung (caption), nicht zusätzlich
|
||||||
|
eine versteckte: sonst liest ein Screenreader beides und damit doppelt. */ ?>
|
||||||
|
<caption class="stadionzeitung-turnier__label">Spielplan</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nr.</th>
|
||||||
|
<th scope="col">Zeit</th>
|
||||||
|
<?php if ($hatFeld): ?>
|
||||||
|
<th scope="col">Feld</th>
|
||||||
|
<?php endif; ?>
|
||||||
|
<th scope="col">Begegnung</th>
|
||||||
|
<th scope="col" class="stadionzeitung-turnier__ergebnis">Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($spiele as $i => $spiel): ?>
|
||||||
|
<?php
|
||||||
|
$heim = trim((string) ($spiel['heim'] ?? ''));
|
||||||
|
$gast = trim((string) ($spiel['gast'] ?? ''));
|
||||||
|
$zeit = trim((string) ($spiel['zeit'] ?? ''));
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="stadionzeitung-turnier__nr"><?= e((string) ($spiel['nr'] ?? ($i + 1))) ?></td>
|
||||||
|
<td class="stadionzeitung-turnier__zeit"><?php if ($zeit !== ''): ?><time datetime="<?= e($date !== '' ? $date . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time><?php endif; ?></td>
|
||||||
|
<?php if ($hatFeld): ?>
|
||||||
|
<td class="stadionzeitung-turnier__nr"><?= e((string) ($spiel['feld'] ?? '')) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<td class="stadionzeitung-turnier__paarung"><span class="stadionzeitung-turnier__team<?= $isOwn($heim) ? ' is-own' : '' ?>"><?= e($heim) ?></span> <span class="stadionzeitung-turnier__vs">gegen</span> <span class="stadionzeitung-turnier__team<?= $isOwn($gast) ? ' is-own' : '' ?>"><?= e($gast) ?></span></td>
|
||||||
|
<td class="stadionzeitung-turnier__ergebnis"><span class="stadionzeitung-turnier__blank" aria-hidden="true"></span><span class="stadionzeitung-turnier__colon">:</span><span class="stadionzeitung-turnier__blank" aria-hidden="true"></span></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($teams !== []): ?>
|
||||||
|
<table class="stadionzeitung-turnier__tabelle">
|
||||||
|
<caption class="stadionzeitung-turnier__label">Platzierung zum Eintragen</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Pl.</th>
|
||||||
|
<th scope="col">Teilnehmer</th>
|
||||||
|
<th scope="col"><abbr title="Tore">T</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Tordifferenz">TD</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Punkte">Pkt</abbr></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php for ($i = 1; $i <= count($teams); $i++): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="stadionzeitung-turnier__nr"><?= $i ?>.</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props:
|
* Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props:
|
||||||
* $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status,
|
* $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status,
|
||||||
* images[]{type: 'image'{base,widths,alt} | 'live'{page[,team]}})
|
* images[]{type: 'image'{base,widths,alt} | 'live'{page[,team][,turnier]}})
|
||||||
*
|
*
|
||||||
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
|
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
|
||||||
* voll lesbar, sequentiell auch per Screenreader. Mit JS
|
* voll lesbar, sequentiell auch per Screenreader. Mit JS
|
||||||
@@ -71,7 +71,10 @@ $liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
|
|||||||
<span class="stadionzeitung-viewer__icon--close"><?= icon('x-lg') ?></span>
|
<span class="stadionzeitung-viewer__icon--close"><?= icon('x-lg') ?></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn--outline stadionzeitung-viewer__print" href="<?= e(url('stadionzeitung/' . ($issue['slug'] ?? '')) . '?druck=1') ?>">
|
<?php /* Hier stand ein „Druckfassung"-Knopf. Er ist raus (31.07.2026, Felix): die
|
||||||
<?= icon('printer') ?> Druckfassung
|
Druckfassung ist Werkzeug für die Heft-Produktion, nicht für Besucher — und
|
||||||
</a>
|
als sichtbarer Button neben Blättern und Vollbild wurde er versehentlich
|
||||||
|
geklickt und riss Leute aus dem Lesen. Die Route bleibt unverändert
|
||||||
|
erreichbar: an die Ausgaben-URL ein `?druck=1` anhängen. Nicht als Link
|
||||||
|
wieder einbauen; wer sie braucht, kennt sie. */ ?>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
198
app/helpers.php
@@ -208,8 +208,15 @@ function img_intrinsic_attrs(string $rel): string
|
|||||||
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
|
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
|
||||||
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
|
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
|
||||||
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
|
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
|
||||||
|
*
|
||||||
|
* $alpha = true schreibt stattdessen <destBase>-<breite>.png und erhält die
|
||||||
|
* Transparenz — nötig für freigestellte Motive (Spielerfoto auf dem
|
||||||
|
* Stadionzeitungs-Cover), bei denen JPG die Freistellung gegen Schwarz wegrechnen
|
||||||
|
* würde. $quality gilt dann nicht (PNG ist verlustfrei). Der Default bleibt JPG,
|
||||||
|
* weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern und deren
|
||||||
|
* Pfade in den data/*.json auf .jpg zeigen.
|
||||||
*/
|
*/
|
||||||
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78): array
|
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78, bool $alpha = false): array
|
||||||
{
|
{
|
||||||
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
|
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
|
||||||
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
||||||
@@ -229,9 +236,25 @@ function resize_image_variants(string $src, string $destBase, array $widths, int
|
|||||||
$w = min((int) $width, $srcW); // nie hochskalieren
|
$w = min((int) $width, $srcW); // nie hochskalieren
|
||||||
$h = (int) round($srcH * $w / $srcW);
|
$h = (int) round($srcH * $w / $srcW);
|
||||||
$frame = imagecreatetruecolor($w, $h);
|
$frame = imagecreatetruecolor($w, $h);
|
||||||
|
|
||||||
|
if ($alpha) {
|
||||||
|
// Transparenz retten: ohne Blending kopieren (sonst rechnet GD den
|
||||||
|
// Alphakanal gegen Schwarz weg) und die Fläche vorher transparent füllen.
|
||||||
|
imagealphablending($frame, false);
|
||||||
|
imagesavealpha($frame, true);
|
||||||
|
$leer = (int) imagecolorallocatealpha($frame, 0, 0, 0, 127);
|
||||||
|
imagefilledrectangle($frame, 0, 0, $w, $h, $leer);
|
||||||
|
}
|
||||||
|
|
||||||
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
|
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
|
||||||
|
|
||||||
|
if ($alpha) {
|
||||||
|
$file = "{$destBase}-{$w}.png";
|
||||||
|
imagepng($frame, $file, 9);
|
||||||
|
} else {
|
||||||
$file = "{$destBase}-{$w}.jpg";
|
$file = "{$destBase}-{$w}.jpg";
|
||||||
imagejpeg($frame, $file, $quality);
|
imagejpeg($frame, $file, $quality);
|
||||||
|
}
|
||||||
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
|
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +568,27 @@ function veranstaltungen_load(): array
|
|||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Veranstaltung per Slug, UNGEFILTERT (auch Entwürfe) — für die
|
||||||
|
* Detailseite (app/pages/veranstaltung.php) und die Stadionzeitungs-Live-Seiten
|
||||||
|
* der Sportfest-Ausgabe (Programm, Turnierpläne, Gegnerseiten je Heimspiel).
|
||||||
|
* Ungefiltert aus demselben Grund wie die Detailseite: ein Entwurf muss sich
|
||||||
|
* gegenlesen lassen, und eine Entwurfs-Ausgabe der Stadionzeitung darf auf
|
||||||
|
* einen Entwurfs-Termin zeigen. null, wenn es den Slug nicht gibt.
|
||||||
|
*/
|
||||||
|
function veranstaltung_find(string $slug): ?array
|
||||||
|
{
|
||||||
|
if ($slug === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
|
||||||
|
if (($entry['slug'] ?? '') === $slug) {
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der
|
* Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der
|
||||||
* Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
|
* Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
|
||||||
@@ -932,6 +976,86 @@ function stadionzeitung_is_published(array $issue): bool
|
|||||||
return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf';
|
return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverzeile „Im Heft" einer Ausgabe — die EINE Quelle für das Cover
|
||||||
|
* (stadionzeitung-cover.php) und die Archivkarte (stadionzeitung-cards.php),
|
||||||
|
* damit beide nie Verschiedenes sagen. Eine Sonderausgabe (Sportfest) setzt
|
||||||
|
* `cover.teaser` chat-gepflegt; sonst trägt die Coverzeile des Interviews
|
||||||
|
* (`interview.teaser`). Leer, wenn es weder das eine noch das andere gibt.
|
||||||
|
*/
|
||||||
|
function stadionzeitung_teaser(array $issue): string
|
||||||
|
{
|
||||||
|
$teaser = trim((string) ($issue['cover']['teaser'] ?? ''));
|
||||||
|
if ($teaser !== '') {
|
||||||
|
return $teaser;
|
||||||
|
}
|
||||||
|
return trim((string) ($issue['interview']['teaser'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE
|
||||||
|
* Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND
|
||||||
|
* stadionzeitung_build_snapshot() (Daten), damit nie eine Seite ohne Daten
|
||||||
|
* oder Daten ohne Seite entstehen.
|
||||||
|
*
|
||||||
|
* Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`).
|
||||||
|
* Zusätzlich, wenn die Ausgabe eine `veranstaltung` (Slug) mit Zeitraum trägt
|
||||||
|
* (Sportfest-Ausgabe): jede weitere Mannschaft aus matchcenter.json mit einem
|
||||||
|
* HEIMspiel in diesem Zeitraum — drei Heimspiele am Fest, drei Gegnerseiten.
|
||||||
|
* Der Gegner ist dann der andere Verein der Begegnung. Reihenfolge wie in
|
||||||
|
* matchcenter.json (= config 'matchcenter.teams').
|
||||||
|
*
|
||||||
|
* Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']]
|
||||||
|
*/
|
||||||
|
function stadionzeitung_gegner_fixtures(array $issue): array
|
||||||
|
{
|
||||||
|
$date = (string) ($issue['date'] ?? '');
|
||||||
|
$issueTeam = (string) ($issue['team_key'] ?? '');
|
||||||
|
$issueOpponent = trim((string) ($issue['opponent'] ?? ''));
|
||||||
|
|
||||||
|
$von = '';
|
||||||
|
$bis = '';
|
||||||
|
$veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||||
|
if ($veranstaltung !== null) {
|
||||||
|
$von = (string) ($veranstaltung['date_start'] ?? '');
|
||||||
|
$bis = (string) ($veranstaltung['date_end'] ?? $von);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fixtures = [];
|
||||||
|
foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
|
||||||
|
$key = (string) ($team['key'] ?? '');
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($key === $issueTeam) {
|
||||||
|
if ($issueOpponent !== '') {
|
||||||
|
$fixtures[$key] = ['opponent' => $issueOpponent, 'date' => $date];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($von === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($team['next_matches'] ?? [] as $match) {
|
||||||
|
if (empty($match['is_home'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
|
||||||
|
if ($matchDate < $von || $matchDate > $bis) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$opponent = trim((string) ($match['away'] ?? ''));
|
||||||
|
if ($opponent === '' || $opponent === 'SPIELFREI') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fixtures[$key] = ['opponent' => $opponent, 'date' => $matchDate];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fixtures;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) — für die
|
* Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) — für die
|
||||||
* Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
|
* Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
|
||||||
@@ -969,6 +1093,11 @@ function stadionzeitung_build_snapshot(array $issue): array
|
|||||||
$tabellen = [];
|
$tabellen = [];
|
||||||
$ergebnisse = [];
|
$ergebnisse = [];
|
||||||
$vorschau = [];
|
$vorschau = [];
|
||||||
|
// Gegnerseiten als Map je Team-Key (wie `tabellen`): welche Mannschaften
|
||||||
|
// eine bekommen, entscheidet stadionzeitung_gegner_fixtures() — dieselbe
|
||||||
|
// Funktion, mit der bin/stadionzeitung-add.php die Seiten einplant.
|
||||||
|
$gegnerFixtures = stadionzeitung_gegner_fixtures($issue);
|
||||||
|
$gegner = [];
|
||||||
foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
|
foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
|
||||||
$key = (string) ($team['key'] ?? '');
|
$key = (string) ($team['key'] ?? '');
|
||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
@@ -981,6 +1110,47 @@ function stadionzeitung_build_snapshot(array $issue): array
|
|||||||
'table' => $team['table'] ?? [],
|
'table' => $team['table'] ?? [],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Gegnerseite(n): ausschließlich aus demselben Matchcenter-Datensatz
|
||||||
|
// wie die Tabelle ableiten. So gibt es keinen zweiten BFV-Abruf und der
|
||||||
|
// Stand wird beim Veröffentlichen zusammen mit den übrigen Live-Daten
|
||||||
|
// eingefroren. Normal nur die tragende Mannschaft; in der Sportfest-
|
||||||
|
// Ausgabe jede Mannschaft mit Heimspiel am Fest (s. gegner_fixtures).
|
||||||
|
if (isset($gegnerFixtures[$key])) {
|
||||||
|
$gegnerName = $gegnerFixtures[$key]['opponent'];
|
||||||
|
$gegnerDate = $gegnerFixtures[$key]['date'];
|
||||||
|
$table = is_array($team['table'] ?? null) ? $team['table'] : [];
|
||||||
|
$ownRow = null;
|
||||||
|
$opponentRow = null;
|
||||||
|
foreach ($table as $row) {
|
||||||
|
if (!empty($row['is_own_club'])) {
|
||||||
|
$ownRow = $row;
|
||||||
|
}
|
||||||
|
if (strcasecmp(trim((string) ($row['club'] ?? '')), $gegnerName) === 0) {
|
||||||
|
$opponentRow = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fixture = null;
|
||||||
|
foreach ($team['next_matches'] ?? [] as $match) {
|
||||||
|
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
|
||||||
|
$clubs = [(string) ($match['home'] ?? ''), (string) ($match['away'] ?? '')];
|
||||||
|
if ($matchDate === $gegnerDate && in_array($gegnerName, $clubs, true)) {
|
||||||
|
$fixture = $match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$gegner[$key] = [
|
||||||
|
'team_name' => (string) ($team['name'] ?? ''),
|
||||||
|
'opponent' => $gegnerName,
|
||||||
|
'league' => (string) ($team['league'] ?? ''),
|
||||||
|
'season' => (string) ($team['season'] ?? ''),
|
||||||
|
'fixture' => $fixture,
|
||||||
|
'own' => $ownRow,
|
||||||
|
'opponent_stats' => $opponentRow,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist
|
// Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist
|
||||||
// kein Rückblick (dieselbe Regel wie im News-Feed).
|
// kein Rückblick (dieselbe Regel wie im News-Feed).
|
||||||
$withScore = array_values(array_filter(
|
$withScore = array_values(array_filter(
|
||||||
@@ -1001,7 +1171,11 @@ function stadionzeitung_build_snapshot(array $issue): array
|
|||||||
|
|
||||||
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
|
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
|
||||||
// Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es
|
// Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es
|
||||||
// schon); ein Spiel einer ANDEREN Mannschaft am selben Tag bleibt drin.
|
// schon), und ebenso jedes Spiel, das eine eigene Gegnerseite hat
|
||||||
|
// (Sportfest-Ausgabe: Zweite und Frauen stünden sonst doppelt im Heft).
|
||||||
|
// Ein Spiel einer ANDEREN Mannschaft am selben Tag ohne Gegnerseite
|
||||||
|
// bleibt drin.
|
||||||
|
$gegnerDate = $gegnerFixtures[$key]['date'] ?? null;
|
||||||
foreach ($team['next_matches'] ?? [] as $match) {
|
foreach ($team['next_matches'] ?? [] as $match) {
|
||||||
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
|
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
|
||||||
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
|
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
|
||||||
@@ -1015,6 +1189,9 @@ function stadionzeitung_build_snapshot(array $issue): array
|
|||||||
if ($matchDate === $date && $key === $issueTeam) {
|
if ($matchDate === $date && $key === $issueTeam) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($gegnerDate !== null && $matchDate === $gegnerDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$vorschau[] = [
|
$vorschau[] = [
|
||||||
'team_key' => $key,
|
'team_key' => $key,
|
||||||
'team_name' => (string) ($team['name'] ?? ''),
|
'team_name' => (string) ($team['name'] ?? ''),
|
||||||
@@ -1035,6 +1212,7 @@ function stadionzeitung_build_snapshot(array $issue): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'tabellen' => $tabellen,
|
'tabellen' => $tabellen,
|
||||||
|
'gegner' => $gegner,
|
||||||
'ergebnisse' => $ergebnisse,
|
'ergebnisse' => $ergebnisse,
|
||||||
'vorschau' => $vorschau,
|
'vorschau' => $vorschau,
|
||||||
// Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht
|
// Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht
|
||||||
@@ -1271,6 +1449,14 @@ function send_mail(array $opts): bool
|
|||||||
$mail->Body = (string) $opts['body'];
|
$mail->Body = (string) $opts['body'];
|
||||||
|
|
||||||
$mail->send();
|
$mail->send();
|
||||||
|
// Erfolg als INFO mitschreiben. Vorher loggte nur der Fehlerfall, damit war ein
|
||||||
|
// funktionierender Versand von einem stummen nicht zu unterscheiden — beim
|
||||||
|
// Deploy am 31.07.2026 stand im Log gar kein mail-Kanal, was „nichts
|
||||||
|
// abgeschickt" und „einwandfrei versandt" gleichzeitig bedeuten konnte.
|
||||||
|
// Nur der Kontext (welches Formular), NIE Empfänger, Absender oder Inhalt:
|
||||||
|
// das verbietet die Logging-Regel, und für die Frage „läuft der Versand?"
|
||||||
|
// braucht es davon auch nichts.
|
||||||
|
log_write('mail', 'INFO', "{$context} versandt.");
|
||||||
return true;
|
return true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
log_write('mail', 'ERROR', "{$context} fehlgeschlagen: " . $e->getMessage());
|
log_write('mail', 'ERROR', "{$context} fehlgeschlagen: " . $e->getMessage());
|
||||||
@@ -1339,6 +1525,7 @@ function contact_variant(string $department): array
|
|||||||
'interests' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
|
'interests' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
|
||||||
'interest_required' => false,
|
'interest_required' => false,
|
||||||
'ask_subject' => true,
|
'ask_subject' => true,
|
||||||
|
'ask_birthdate' => false,
|
||||||
'message_required' => true,
|
'message_required' => true,
|
||||||
'to' => '',
|
'to' => '',
|
||||||
'context' => 'Kontaktanfrage',
|
'context' => 'Kontaktanfrage',
|
||||||
@@ -1364,6 +1551,13 @@ function contact_variant(string $department): array
|
|||||||
// Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
|
// Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
|
||||||
'interest_required' => true,
|
'interest_required' => true,
|
||||||
'ask_subject' => false,
|
'ask_subject' => false,
|
||||||
|
// Geburtsdatum nur hier: die Turnabteilung braucht bei Anfragen für Kinder
|
||||||
|
// die Altersgruppe, um die passende Gruppe zuzuordnen (Mäuse, Tiger,
|
||||||
|
// Hühner …). Gab es auf der alten Seite als optionales Feld
|
||||||
|
// „Geburtsdatum des Kindes" und ist beim Neubau untergegangen.
|
||||||
|
// In der allgemeinen Variante wäre es sinnlos und würde unnötig
|
||||||
|
// personenbezogene Daten von Minderjährigen erheben.
|
||||||
|
'ask_birthdate' => true,
|
||||||
// Bewusst optional (wie auf der alten Seite): „Interessiere mich für
|
// Bewusst optional (wie auf der alten Seite): „Interessiere mich für
|
||||||
// Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
|
// Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
|
||||||
// Disziplin stehen ja schon da.
|
// Disziplin stehen ja schon da.
|
||||||
|
|||||||
@@ -168,7 +168,12 @@ $meta = [
|
|||||||
Das <strong>Anfrageformular der Turnabteilung</strong> auf
|
Das <strong>Anfrageformular der Turnabteilung</strong> auf
|
||||||
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
|
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
|
||||||
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
|
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
|
||||||
interessierst; eine Nachricht ist dort freiwillig. Diese Anfragen gehen an das
|
interessierst; eine Nachricht ist dort freiwillig. Zusätzlich kannst du
|
||||||
|
<strong>freiwillig das Geburtsdatum des Kindes</strong> angeben, um das es bei der
|
||||||
|
Anfrage geht. Wir brauchen es nur, um die passende Turngruppe zuzuordnen, weil die
|
||||||
|
Gruppen nach Alter eingeteilt sind. Das Feld ist keine Pflichtangabe: ohne Datum
|
||||||
|
bearbeiten wir die Anfrage genauso und fragen gegebenenfalls nach dem Alter nach.
|
||||||
|
Diese Anfragen gehen an das
|
||||||
Postfach der Turnabteilung
|
Postfach der Turnabteilung
|
||||||
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
|
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
|
||||||
weil die Abteilung sie selbst beantwortet.
|
weil die Abteilung sie selbst beantwortet.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ component('hero', ['hero' => $page['hero']]);
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="intro-bento">
|
<div class="intro-bento">
|
||||||
<div class="bento__tile intro-bento__image">
|
<div class="bento__tile intro-bento__image">
|
||||||
<?php component('img', ['image' => $page['hero']['image'], 'sizes' => '(max-width: 900px) 100vw, 40vw', 'class' => 'intro-bento__img']); ?>
|
<?php component('img', ['image' => $page['team_photo'] ?? $page['hero']['image'], 'sizes' => '(max-width: 900px) 100vw, 40vw', 'class' => 'intro-bento__img']); ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="bento__tile intro-bento__about">
|
<article class="bento__tile intro-bento__about">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
|
|||||||
$meta = [
|
$meta = [
|
||||||
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
|
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
|
||||||
'description' => $d['hero']['text'],
|
'description' => $d['hero']['text'],
|
||||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
'og_image' => 'img/pages/turnen-eltern-kind-1536.jpg',
|
||||||
'scripts' => ['group-tabs.js'],
|
'scripts' => ['group-tabs.js'],
|
||||||
'schema' => page_schema([
|
'schema' => page_schema([
|
||||||
['name' => 'Startseite', 'slug' => ''],
|
['name' => 'Startseite', 'slug' => ''],
|
||||||
@@ -28,6 +28,9 @@ component('group-schedule', [
|
|||||||
'location' => $d['location'] ?? [],
|
'location' => $d['location'] ?? [],
|
||||||
'lead' => $d['lead'] ?? '',
|
'lead' => $d['lead'] ?? '',
|
||||||
'notice' => $d['notice'] ?? '',
|
'notice' => $d['notice'] ?? '',
|
||||||
|
'noticeTitle' => $d['notice_title'] ?? '',
|
||||||
|
'noticeTone' => $d['notice_tone'] ?? 'info',
|
||||||
|
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
|
||||||
'groups' => $d['groups'] ?? [],
|
'groups' => $d['groups'] ?? [],
|
||||||
'footnote' => $data['footnote'] ?? '',
|
'footnote' => $data['footnote'] ?? '',
|
||||||
]);
|
]);
|
||||||
@@ -12,7 +12,7 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
|
|||||||
$meta = [
|
$meta = [
|
||||||
'title' => 'Erwachsenenturnen in Kulmbach',
|
'title' => 'Erwachsenenturnen in Kulmbach',
|
||||||
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
|
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
|
||||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
'og_image' => 'img/pages/turnen-erwachsenenturnen-1536.jpg',
|
||||||
'schema' => page_schema([
|
'schema' => page_schema([
|
||||||
['name' => 'Startseite', 'slug' => ''],
|
['name' => 'Startseite', 'slug' => ''],
|
||||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||||
|
|||||||
38
app/pages/turnen-fitness-pilates.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turnen → Fitness & Pilates. Inhalt: data/turnen.json
|
||||||
|
* (disciplines.fitness-pilates). Struktur: Hero → Kurse/Zeiten → CTA.
|
||||||
|
*
|
||||||
|
* Hieß bis 31.07.2026 „Step Aerobic, Fitness & Pilates" unter dem Slug
|
||||||
|
* turnen/step-aerobic-fitness-pilates. Step Aerobic findet nicht mehr statt
|
||||||
|
* (Rückmeldung der Turnabteilung), die Gruppe ist aus data/turnen.json entfernt.
|
||||||
|
* Seit 19.08.2026 laufen hier Pilates, Functional Fitness I (Mittwoch) und der neue
|
||||||
|
* Functional Fitness II (Donnerstag). Der alte Slug leitet per 301 hierher
|
||||||
|
* (public/.htaccess).
|
||||||
|
*/
|
||||||
|
$slug = 'turnen/fitness-pilates';
|
||||||
|
$d = json_load('turnen')['disciplines']['fitness-pilates'];
|
||||||
|
|
||||||
|
$meta = [
|
||||||
|
'title' => 'Fitness & Pilates in Kulmbach',
|
||||||
|
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Fitness und Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
|
||||||
|
'og_image' => 'img/pages/turnen-fitness-pilates-1536.jpg',
|
||||||
|
'schema' => page_schema([
|
||||||
|
['name' => 'Startseite', 'slug' => ''],
|
||||||
|
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||||
|
['name' => 'Fitness & Pilates', 'slug' => $slug],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
component('hero', ['hero' => $d['hero']]);
|
||||||
|
component('group-schedule', [
|
||||||
|
'groupsTitle' => 'Unsere Kurse',
|
||||||
|
'location' => $d['location'] ?? [],
|
||||||
|
'lead' => $d['lead'] ?? '',
|
||||||
|
'groups' => $d['groups'] ?? [],
|
||||||
|
// Wenige, beschreibende Kurse → offen untereinander statt Tabs (siehe group-schedule.php).
|
||||||
|
'layout' => 'stacked',
|
||||||
|
]);
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turnen → Step Aerobic, Fitness & Pilates. Inhalt: data/turnen.json
|
|
||||||
* (disciplines.step-aerobic-fitness-pilates). Struktur: Hero → Kurse/Zeiten → CTA.
|
|
||||||
*/
|
|
||||||
$slug = 'turnen/step-aerobic-fitness-pilates';
|
|
||||||
$d = json_load('turnen')['disciplines']['step-aerobic-fitness-pilates'];
|
|
||||||
|
|
||||||
$meta = [
|
|
||||||
'title' => 'Step Aerobic, Fitness & Pilates in Kulmbach',
|
|
||||||
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
|
|
||||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
|
||||||
'schema' => page_schema([
|
|
||||||
['name' => 'Startseite', 'slug' => ''],
|
|
||||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
|
||||||
['name' => 'Step Aerobic, Fitness & Pilates', 'slug' => $slug],
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
component('hero', ['hero' => $d['hero']]);
|
|
||||||
component('group-schedule', [
|
|
||||||
'groupsTitle' => 'Unsere Kurse',
|
|
||||||
'location' => $d['location'] ?? [],
|
|
||||||
'lead' => $d['lead'] ?? '',
|
|
||||||
'groups' => $d['groups'] ?? [],
|
|
||||||
]);
|
|
||||||
@@ -24,5 +24,13 @@ component('hero', ['hero' => $d['hero']]);
|
|||||||
component('group-schedule', [
|
component('group-schedule', [
|
||||||
'location' => $d['location'] ?? [],
|
'location' => $d['location'] ?? [],
|
||||||
'lead' => $d['lead'] ?? '',
|
'lead' => $d['lead'] ?? '',
|
||||||
|
'contactEmail' => $d['contact_email'] ?? '',
|
||||||
|
'contactPhone' => $d['contact_phone'] ?? '',
|
||||||
|
'notice' => $d['notice'] ?? '',
|
||||||
|
'noticeTitle' => $d['notice_title'] ?? '',
|
||||||
|
'noticeTone' => $d['notice_tone'] ?? 'info',
|
||||||
|
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
|
||||||
'groups' => $d['groups'] ?? [],
|
'groups' => $d['groups'] ?? [],
|
||||||
|
'groupsInDash' => true,
|
||||||
|
'footnote' => $d['footnote'] ?? '',
|
||||||
]);
|
]);
|
||||||
@@ -4,10 +4,16 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Turnen → Wettkampfturnen. Inhalt: data/turnen.json (disciplines.wettkampfturnen).
|
* Turnen → Wettkampfturnen. Inhalt: data/turnen.json (disciplines.wettkampfturnen).
|
||||||
* Struktur: Hero → Gruppen/Trainingszeiten → CTA.
|
* Struktur: Hero → Gruppen/Trainingszeiten.
|
||||||
|
*
|
||||||
|
* Zwei Gruppen: die Flamingos, die am 31.07.2026 vom Kinderturnen hierher gezogen sind,
|
||||||
|
* und das Leistungsturnen. Dadurch läuft die Seite nicht mehr im Einzelgruppen-Layout
|
||||||
|
* (Trainingszeiten als Kachel im Dashboard), sondern zeigt die Gruppen darunter als
|
||||||
|
* offene Kartenreihe (layout => 'stacked') — deshalb die Sternchen-Fußnote.
|
||||||
*/
|
*/
|
||||||
$slug = 'turnen/wettkampfturnen';
|
$slug = 'turnen/wettkampfturnen';
|
||||||
$d = json_load('turnen')['disciplines']['wettkampfturnen'];
|
$data = json_load('turnen');
|
||||||
|
$d = $data['disciplines']['wettkampfturnen'];
|
||||||
|
|
||||||
$meta = [
|
$meta = [
|
||||||
'title' => 'Wettkampfturnen in Kulmbach',
|
'title' => 'Wettkampfturnen in Kulmbach',
|
||||||
@@ -25,4 +31,16 @@ component('group-schedule', [
|
|||||||
'location' => $d['location'] ?? [],
|
'location' => $d['location'] ?? [],
|
||||||
'lead' => $d['lead'] ?? '',
|
'lead' => $d['lead'] ?? '',
|
||||||
'groups' => $d['groups'] ?? [],
|
'groups' => $d['groups'] ?? [],
|
||||||
|
// Zwei Gruppen, beide offen untereinander statt hinter Tabs: bei so wenigen
|
||||||
|
// Gruppen verstecken Tabs mehr Inhalt als sie ordnen, und die offene Kartenreihe
|
||||||
|
// hält den Bento-Eindruck der Seite. Damit braucht die Seite auch kein
|
||||||
|
// group-tabs.js mehr. Das Kinderturnen bleibt bei Tabs, dort sind es drei
|
||||||
|
// textreiche Gruppen.
|
||||||
|
'layout' => 'stacked',
|
||||||
|
// Überschrift „Unsere Gruppen" nur für Screenreader: die Karten erklären sich
|
||||||
|
// selbst, und ohne die Zeile bleibt der Bento-Fluss ruhiger.
|
||||||
|
'groupsTitleHidden' => true,
|
||||||
|
// Drei Flamingos-Trainerinnen tragen ein Sternchen. Ohne die Fußnote stünden
|
||||||
|
// hier drei unerklärte Sterne auf der Seite.
|
||||||
|
'footnote' => $data['footnote'] ?? '',
|
||||||
]);
|
]);
|
||||||
@@ -3,9 +3,25 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn-Übersichtsseite (Hub + Abteilungskontakt). Inhalt: data/turnen.json.
|
* Turn-Übersichtsseite (Hub + Beiträge + Abteilungskontakt). Inhalt: data/turnen.json.
|
||||||
* Struktur: Hero → Disziplin-Karten → Anfrageformular. Die Detailinhalte
|
* Struktur: Hero → Disziplin-Karten → Beiträge → FAQ → Anfrageformular.
|
||||||
* (Beiträge, Trainingszeiten) leben auf den Disziplin-Unterseiten.
|
*
|
||||||
|
* Hier stand vorher „Die Detailinhalte (Beiträge, Trainingszeiten) leben auf den
|
||||||
|
* Disziplin-Unterseiten" — das war falsch, und zwar folgenreich: die Seite gab
|
||||||
|
* fees/austritt/faq NIE aus, die Unterseiten auch nicht, und damit standen die
|
||||||
|
* Turn-Beiträge NIRGENDS auf der Website (nachgeprüft 31.07.2026: 0 Treffer auf
|
||||||
|
* /turnen und allen fünf Unterseiten; /mitglied-werden führt nur die Fußball-
|
||||||
|
* Beiträge). Die Turnabteilung verlinkt diese Angaben in Einladungen für neue
|
||||||
|
* Familien, deshalb ist der Beitragsblock hier verankert (#beitraege).
|
||||||
|
* Die Daten selbst stimmen 1:1 mit der alten Seite überein (gegengeprüft an
|
||||||
|
* 2024.tsv08kulmbach.de): Zusatzbeitrag 24/48 €, Neumitglieder 84/94/184 €,
|
||||||
|
* Förderstufen 120/180/240 €, Austritt zum 30. September.
|
||||||
|
*
|
||||||
|
* Der Anmelde-Vorgang der Abteilung (erst Platz anfragen, dann Aufnahmeantrag) steht als
|
||||||
|
* eigene FAQ-Frage samt den zwei Knöpfen, nicht als eigene Sektion. Das war eine bewusste
|
||||||
|
* Entscheidung von Felix (31.07.2026): ein Band unter den Beiträgen war gebaut und wurde
|
||||||
|
* wieder entfernt, weil dieselbe Sache dann zweimal auf der Seite stand. Deshalb gibt es
|
||||||
|
* `overview.cta` nicht mehr — wer die Knöpfe sucht, findet sie in `overview.faq`.
|
||||||
*
|
*
|
||||||
* Das Formular ist die allgemeine contact-form-Komponente mit
|
* Das Formular ist die allgemeine contact-form-Komponente mit
|
||||||
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
|
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
|
||||||
@@ -15,28 +31,45 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
$page = json_load('turnen')['overview'];
|
$page = json_load('turnen')['overview'];
|
||||||
|
|
||||||
// Heros aller Turn-Seiten zeigen VORÜBERGEHEND das Drohnen-Luftbild des
|
// Die Kündigung darf formlos per Mail kommen (Turnabteilung, 31.07.2026). Die Adresse
|
||||||
// Vereinsgeländes (img/pages/vereinsgelaende, Quelle: data/turnen.json) statt des
|
// steht ausschließlich in club.json — der Austritt-Text in turnen.json markiert ihre
|
||||||
// Fußballplatz-Bildes. Ehrlicher wäre ein Bild aus der Turnhalle — liegt eines
|
// Stelle mit {mail}, dasselbe Muster wie der Material-Aufruf auf /historie. Ersetzt
|
||||||
// vor, tauschen die sechs Hero-Blöcke in data/turnen.json und das og_image der
|
// wird NACH inline_links_html(): so bleibt der Fließtext escaped und nur der Platzhalter
|
||||||
// sechs Seiten-Dateien. Die Übersichts-Kacheln zeigen weiter die (vorläufigen)
|
// wird zu HTML. Umgekehrt käme der fertige Link escaped als Text auf die Seite.
|
||||||
// themenbezogenen Platzhalter aus public/assets/img/pages/.
|
$mailTurnen = json_load('club')['email_turnen'];
|
||||||
|
$austrittText = strtr(
|
||||||
|
inline_links_html($page['austritt']['text'] ?? ''),
|
||||||
|
['{mail}' => '<a href="mailto:' . e($mailTurnen) . '">' . e($mailTurnen) . '</a>']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Eltern-Kind-, Erwachsenen- und Fitness/Pilates-Seite haben seit 19.08.2026 echte
|
||||||
|
// (Symbolbild-)Fotos aus der Turnabteilung, in Hero UND Kachel. Trampolin- und
|
||||||
|
// Wettkampf-Hero zeigen weiter VORÜBERGEHEND das Drohnen-Luftbild des Vereinsgeländes
|
||||||
|
// (img/pages/vereinsgelaende, Quelle: data/turnen.json); liegt dafür ein Hallenbild vor,
|
||||||
|
// den Hero-Block in data/turnen.json und das og_image der Seiten-Datei mittauschen.
|
||||||
$cards = [
|
$cards = [
|
||||||
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1600], 'alt' => 'Erwachsenenturnen beim TSV 08 Kulmbach']],
|
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1536], 'alt' => 'Turnerinnen üben Gleichgewicht auf dem Schwebebalken (Symbolbild)']],
|
||||||
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1600], 'alt' => 'Eltern-Kind- und Kinderturnen beim TSV 08 Kulmbach']],
|
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1536], 'alt' => 'Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)']],
|
||||||
['name' => 'Step Aerobic, Fitness & Pilates', 'slug' => 'turnen/step-aerobic-fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-step-aerobic', 'widths' => [640, 1000, 1600], 'alt' => 'Step Aerobic, Fitness und Pilates beim TSV 08 Kulmbach']],
|
['name' => 'Fitness & Pilates', 'slug' => 'turnen/fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-fitness-pilates', 'widths' => [640, 1000, 1536], 'alt' => 'Gruppe trainiert mit Kniebeugen auf Matten im Sportraum (Symbolbild)']],
|
||||||
['name' => 'Trampolinturnen', 'slug' => 'turnen/trampolin', 'category' => 'Carl-von-Linde-Realschule', 'image' => ['base' => 'img/pages/turnen-trampolin', 'widths' => [640, 1000, 1600], 'alt' => 'Trampolinturnen beim TSV 08 Kulmbach']],
|
// Einziges echtes Foto unter den Kacheln (31.07.2026). Nur 640 breit, weil die
|
||||||
|
// Vorlage über WhatsApp kam und dort auf 718 px eingedampft wurde;
|
||||||
|
// resize_image_variants() skaliert nie hoch. Liegt das Original vom Handy vor:
|
||||||
|
// php bin/img-resize.php <quelle> public/assets/img/pages/turnen-trampolin 640,1000,1600 82
|
||||||
|
// und hier wieder alle drei Breiten eintragen.
|
||||||
|
['name' => 'Trampolinturnen', 'slug' => 'turnen/trampolin', 'category' => 'Carl-von-Linde-Realschule', 'image' => ['base' => 'img/pages/turnen-trampolin', 'widths' => [640], 'alt' => 'Aufgebaute Trampoline und Sicherheitsmatten in der Dreifachturnhalle, in der wir trainieren']],
|
||||||
['name' => 'Wettkampfturnen', 'slug' => 'turnen/wettkampfturnen', 'category' => 'Leistungssport', 'image' => ['base' => 'img/pages/turnen-wettkampf', 'widths' => [640, 1000, 1600], 'alt' => 'Wettkampfturnen beim TSV 08 Kulmbach']],
|
['name' => 'Wettkampfturnen', 'slug' => 'turnen/wettkampfturnen', 'category' => 'Leistungssport', 'image' => ['base' => 'img/pages/turnen-wettkampf', 'widths' => [640, 1000, 1600], 'alt' => 'Wettkampfturnen beim TSV 08 Kulmbach']],
|
||||||
];
|
];
|
||||||
|
|
||||||
$meta = [
|
$meta = [
|
||||||
'title' => 'Turnen in Kulmbach',
|
'title' => 'Turnen in Kulmbach',
|
||||||
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
|
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
|
||||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||||
|
// Zweiter Parameter = FAQ: sichtbare Fragen und FAQPage-Schema kommen aus
|
||||||
|
// derselben Quelle, damit sie nie auseinanderlaufen (CLAUDE.md).
|
||||||
'schema' => page_schema([
|
'schema' => page_schema([
|
||||||
['name' => 'Startseite', 'slug' => ''],
|
['name' => 'Startseite', 'slug' => ''],
|
||||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||||
]),
|
], $page['faq'] ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
component('hero', ['hero' => $page['hero']]);
|
component('hero', ['hero' => $page['hero']]);
|
||||||
@@ -44,10 +77,53 @@ component('team-cards', [
|
|||||||
'teams' => $cards,
|
'teams' => $cards,
|
||||||
'title' => 'Unsere Turngruppen',
|
'title' => 'Unsere Turngruppen',
|
||||||
'title_hidden' => true,
|
'title_hidden' => true,
|
||||||
|
'intro' => $page['cards_intro'] ?? null,
|
||||||
'anchor' => 'gruppen',
|
'anchor' => 'gruppen',
|
||||||
]);
|
]);
|
||||||
|
?>
|
||||||
|
<?php /* Beiträge. Der Anker #beitraege ist die Adresse, die die Turnabteilung in
|
||||||
|
Einladungen weitergibt — nicht umbenennen. Drei Tabellen über die
|
||||||
|
bestehende fee-table-Komponente (dieselbe wie auf /mitglied-werden),
|
||||||
|
darunter die Austrittsregel, weil sie zur Beitragsfrage gehört. */ ?>
|
||||||
|
<section class="section section--tinted fees" id="beitraege">
|
||||||
|
<div class="container">
|
||||||
|
<header class="fees__head">
|
||||||
|
<h2><?= e($page['fees_title']) ?></h2>
|
||||||
|
<?php if (!empty($page['fees_intro'])): ?>
|
||||||
|
<p class="fees__intro"><?= e($page['fees_intro']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</header>
|
||||||
|
<div class="fee-tables">
|
||||||
|
<?php foreach ($page['fees'] as $feeTable): ?>
|
||||||
|
<?php component('fee-table', ['fees' => $feeTable]); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php /* Bewusst NICHT .prose: dort rendert ein h3 mit 21px Abel, also exakt
|
||||||
|
Fließtextgröße — in Impressum und Artikeln richtig, hier ist „Austritt"
|
||||||
|
aber eine Auszeichnung und wäre nicht von Fließtext zu unterscheiden. */ ?>
|
||||||
|
<?php if (!empty($page['austritt']['title'])): ?>
|
||||||
|
<div class="fees__note">
|
||||||
|
<h3 class="fees__note-title"><?= e($page['austritt']['title']) ?></h3>
|
||||||
|
<p><?= $austrittText ?></p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php
|
||||||
|
component('faq', ['faq' => $page['faq'] ?? []]);
|
||||||
|
/* Der Wartelisten-Hinweis steht DIREKT über dem Formular, so wie auf der alten Seite.
|
||||||
|
Grund ist Arbeitsaufwand in der Abteilung, kein Design: fuer „Tiger" und „Huehner"
|
||||||
|
kommen laufend Anfragen nach einem Wartelistenplatz, den es nicht gibt, und jede muss
|
||||||
|
einzeln beantwortet werden. Wer hier schreibt, soll es vorher lesen. Der Text kommt aus
|
||||||
|
derselben Stelle wie die Kachel auf der Kinderturn-Unterseite
|
||||||
|
(disciplines.eltern-kind-turnen.notice), ein Hinweis und eine Quelle. */
|
||||||
|
$kinderturnen = json_load('turnen')['disciplines']['eltern-kind-turnen'] ?? [];
|
||||||
component('contact-form', [
|
component('contact-form', [
|
||||||
'intro' => $page['contact'],
|
'intro' => $page['contact'],
|
||||||
'return' => 'turnen',
|
'return' => 'turnen',
|
||||||
'department' => 'turnen',
|
'department' => 'turnen',
|
||||||
|
'notice' => $kinderturnen['notice'] ?? '',
|
||||||
|
'noticeTitle' => $kinderturnen['notice_title'] ?? '',
|
||||||
|
'noticeTone' => $kinderturnen['notice_tone'] ?? 'info',
|
||||||
|
'noticeIcon' => $kinderturnen['notice_icon'] ?? 'info-circle',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ declare(strict_types=1);
|
|||||||
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
|
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
|
||||||
* schreibt bin/veranstaltung-bilder.php.
|
* schreibt bin/veranstaltung-bilder.php.
|
||||||
*
|
*
|
||||||
* Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()):
|
* Geladen wird hier bewusst UNGEFILTERT (veranstaltung_find(), nicht
|
||||||
* ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er
|
* veranstaltungen_load()): ein Entwurf muss unter seiner direkten URL lesbar
|
||||||
* gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
|
* bleiben, damit er gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
|
||||||
* trägt noindex.
|
* trägt noindex.
|
||||||
*
|
*
|
||||||
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
|
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
|
||||||
@@ -23,13 +23,7 @@ declare(strict_types=1);
|
|||||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||||
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
|
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
|
||||||
|
|
||||||
$v = null;
|
$v = veranstaltung_find($veranstaltungSlug);
|
||||||
foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
|
|
||||||
if (($entry['slug'] ?? '') === $veranstaltungSlug) {
|
|
||||||
$v = $entry;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($v === null) {
|
if ($v === null) {
|
||||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ return [
|
|||||||
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
|
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
|
||||||
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
|
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
|
||||||
'turnen/eltern-kind-turnen' => ['file' => 'turnen-eltern-kind.php', 'sitemap' => true],
|
'turnen/eltern-kind-turnen' => ['file' => 'turnen-eltern-kind.php', 'sitemap' => true],
|
||||||
'turnen/step-aerobic-fitness-pilates' => ['file' => 'turnen-step-aerobic.php', 'sitemap' => true],
|
'turnen/fitness-pilates' => ['file' => 'turnen-fitness-pilates.php', 'sitemap' => true],
|
||||||
'turnen/trampolin' => ['file' => 'turnen-trampolin.php', 'sitemap' => true],
|
'turnen/trampolin' => ['file' => 'turnen-trampolin.php', 'sitemap' => true],
|
||||||
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
|
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
|
||||||
'mitmachen' => ['file' => 'mitmachen.php', 'sitemap' => true],
|
'mitmachen' => ['file' => 'mitmachen.php', 'sitemap' => true],
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ foreach ($sources as $src) {
|
|||||||
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
|
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
|
||||||
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
|
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
|
||||||
// nicht mehr passen.
|
// nicht mehr passen.
|
||||||
foreach (glob("{$destDir}/{$slug}-*.jpg") ?: [] as $alt) {
|
// Muster bewusst auf die BREITEN-Endung eingeschränkt (`-<zahl>.jpg`): mit
|
||||||
|
// `{$slug}-*.jpg` löscht ein Slug die Motive jedes Slugs, der ihn als Präfix
|
||||||
|
// hat — `emons` räumte so die Dateien von `emons-partnerdank` mit weg
|
||||||
|
// (03.09.2026 beobachtet, dort zufällig gewollt, generell Datenverlust).
|
||||||
|
foreach (glob("{$destDir}/{$slug}-[0-9]*.jpg") ?: [] as $alt) {
|
||||||
unlink($alt);
|
unlink($alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
|
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
|
||||||
*/
|
*/
|
||||||
if (PHP_SAPI !== 'cli') {
|
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
|
||||||
|
// definiert. Nötig, weil die KAS-Cronjobs ausschließlich URLs aufrufen können und
|
||||||
|
// dieses Skript sonst nie automatisch liefe (Stand 31.07.2026).
|
||||||
|
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
|
||||||
|
// definiert Konstanten, ein zweiter Durchlauf wäre ein Fatal.
|
||||||
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
$maxMb = 2.0;
|
$maxMb = 2.0;
|
||||||
$keepDays = 90;
|
$keepDays = 90;
|
||||||
|
|||||||
@@ -296,6 +296,114 @@ if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Veröffentlichte Stadionzeitungen: Bilder wirklich eingefroren? ---
|
||||||
|
// Der Snapshot friert die Daten ein. Zeigt er noch auf img/crests oder img/instagram,
|
||||||
|
// hängt die Ausgabe weiter am Sync: der räumt Wappen weg, die der BFV nicht mehr führt
|
||||||
|
// (eine Saison), und Instagram-Bilder älterer Posts (Wochen). Am 31.07.2026 löschte ein
|
||||||
|
// Lauf 18 Wappen und 3 Bilder. Danach zeigte eine gedruckte Ausgabe Lücken.
|
||||||
|
// bin/stadionzeitung-publish.php kopiert sie deshalb in den Ausgaben-Ordner.
|
||||||
|
// Reparatur einer Alt-Ausgabe: `--entwurf` und neu veröffentlichen.
|
||||||
|
$szFile = DATA_PATH . '/stadionzeitung.json';
|
||||||
|
if (is_file($szFile)) {
|
||||||
|
$szData = json_decode((string) file_get_contents($szFile), true) ?: [];
|
||||||
|
foreach ($szData['issues'] ?? [] as $ausgabe) {
|
||||||
|
if (($ausgabe['status'] ?? 'veroeffentlicht') !== 'veroeffentlicht' || !isset($ausgabe['snapshot'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rohSnap = (string) json_encode($ausgabe['snapshot']);
|
||||||
|
$fluechtig = [];
|
||||||
|
foreach (['img/crests/', 'img/instagram/'] as $ordner) {
|
||||||
|
// JSON escapt Schrägstriche, deshalb beide Schreibweisen prüfen.
|
||||||
|
if (str_contains($rohSnap, $ordner) || str_contains($rohSnap, str_replace('/', '\\/', $ordner))) {
|
||||||
|
$fluechtig[] = rtrim($ordner, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($fluechtig !== []) {
|
||||||
|
$warnings[] = "Stadionzeitung '{$ausgabe['slug']}' ist veröffentlicht, ihr Snapshot zeigt aber noch auf "
|
||||||
|
. implode(' und ', $fluechtig) . ' — diese Ordner verwalten die Sync-Skripte, die Bilder verschwinden dort mit der Zeit.'
|
||||||
|
. ' Einfrieren: php bin/stadionzeitung-publish.php ' . $ausgabe['slug'] . ' --entwurf && php bin/stadionzeitung-publish.php ' . $ausgabe['slug'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) ---
|
||||||
|
// Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die
|
||||||
|
// Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug
|
||||||
|
// oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil
|
||||||
|
// die Komponenten bewusst still zurückkehren. Deshalb hier laut.
|
||||||
|
if (is_file($szFile)) {
|
||||||
|
$szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []);
|
||||||
|
$vBySlug = array_column($veranstaltungRows, null, 'slug');
|
||||||
|
foreach ($szData['issues'] ?? [] as $ausgabe) {
|
||||||
|
$vSlug = (string) ($ausgabe['veranstaltung'] ?? '');
|
||||||
|
if ($vSlug === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($vBySlug[$vSlug])) {
|
||||||
|
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': veranstaltung '{$vSlug}' fehlt in data/veranstaltungen.json.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$turniere = $vBySlug[$vSlug]['turniere'] ?? [];
|
||||||
|
foreach ($turniere as $i => $turnier) {
|
||||||
|
foreach (['key', 'title', 'date', 'teams', 'spiele'] as $feld) {
|
||||||
|
if (empty($turnier[$feld])) {
|
||||||
|
$warnings[] = "data/veranstaltungen.json, '{$vSlug}' → turniere[{$i}]: Feld '{$feld}' fehlt oder ist leer.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$keys = array_column($turniere, 'key');
|
||||||
|
foreach ($ausgabe['images'] ?? [] as $seite) {
|
||||||
|
if (($seite['type'] ?? '') === 'live' && ($seite['page'] ?? '') === 'turnier'
|
||||||
|
&& !in_array((string) ($seite['turnier'] ?? ''), $keys, true)) {
|
||||||
|
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Turnierseite '" . ($seite['turnier'] ?? '') . "' hat keinen Eintrag in turniere[] von '{$vSlug}'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
|
||||||
|
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
|
||||||
|
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
|
||||||
|
// echtem Apache greift die CSP aus public/.htaccess (default-src 'self', KEIN
|
||||||
|
// style-src), der Browser verwarf das Attribut, die Slides blieben schwarz. Lokal
|
||||||
|
// war das unsichtbar, weil `php -S` keine .htaccess liest — der Fehler konnte also
|
||||||
|
// erst in Produktion auffallen. Genau davor schützt diese Wache.
|
||||||
|
// Hinweis: per JavaScript gesetzte Styles (element.style.…) sind NICHT betroffen,
|
||||||
|
// die CSP prüft nur vom HTML-Parser gelesene style-Attribute und <style>-Blöcke.
|
||||||
|
/* Zeilenweise und ohne Tag-Kontext geprüft: ein Muster, das beim öffnenden Tag
|
||||||
|
ansetzt und mit [^>]* weitersucht, bricht sofort ab — PHP-Templates enthalten
|
||||||
|
über das schließende PHP-Tag ständig eine spitze Klammer. Ein style-Attribut in
|
||||||
|
einer PHP-Datei ist praktisch immer echtes Markup, diese Prüfung genügt also.
|
||||||
|
|
||||||
|
Dieser Kommentar ist bewusst ein Blockkommentar: die Zeichenfolge des schließenden
|
||||||
|
PHP-Tags beendet den PHP-Modus AUCH in einem //-Kommentar. Genau daran ist diese
|
||||||
|
Prüfung beim Bauen einmal gescheitert (preflight gab ab dort seinen eigenen
|
||||||
|
Quelltext aus, und php -l meldete nichts, weil das gültiges PHP mit HTML ist). */
|
||||||
|
$inlineStyles = [];
|
||||||
|
$phpDateien = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ROOT_PATH . '/app'));
|
||||||
|
foreach ($phpDateien as $datei) {
|
||||||
|
if (!$datei->isFile() || $datei->getExtension() !== 'php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (file($datei->getPathname()) ?: [] as $nr => $zeile) {
|
||||||
|
$roh = ltrim($zeile);
|
||||||
|
// Kommentarzeilen überspringen — dieser Abschnitt selbst nennt style=" ja auch.
|
||||||
|
if ($roh === '' || str_starts_with($roh, '*') || str_starts_with($roh, '//') || str_starts_with($roh, '/*')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/\sstyle\s*=\s*"/i', $zeile) === 1) {
|
||||||
|
$kurz = str_replace(ROOT_PATH . '/', '', $datei->getPathname());
|
||||||
|
$inlineStyles[] = $kurz . ':' . ($nr + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($inlineStyles !== []) {
|
||||||
|
$errors[] = 'Inline-Style im Markup — die CSP (kein style-src) verwirft ihn, die Regel greift NUR in Produktion: '
|
||||||
|
. implode(', ', $inlineStyles)
|
||||||
|
. '. Bilder als <img> ausgeben, Farben als Token in tokens.css plus Klasse in components.css.';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ausgabe ---
|
// --- Ausgabe ---
|
||||||
foreach ($warnings as $w) {
|
foreach ($warnings as $w) {
|
||||||
echo "WARNUNG {$w}\n";
|
echo "WARNUNG {$w}\n";
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ declare(strict_types=1);
|
|||||||
* NUR CLI — nie im Request-Pfad. Debug-Ausgabe bleibt aus, damit keine
|
* NUR CLI — nie im Request-Pfad. Debug-Ausgabe bleibt aus, damit keine
|
||||||
* Zugangsdaten (Base64 im AUTH-Dialog) im Terminal oder in Logs landen.
|
* Zugangsdaten (Base64 im AUTH-Dialog) im Terminal oder in Logs landen.
|
||||||
*
|
*
|
||||||
* Aufruf: php bin/smtp-test.php
|
* Aufruf: php bin/smtp-test.php (Passwort aus config/config.php)
|
||||||
|
* php bin/smtp-test.php --frage (Passwort verdeckt eingeben)
|
||||||
|
*
|
||||||
|
* --frage ist für den Fall, dass das Postfach-Passwort neu gesetzt wurde: erst
|
||||||
|
* prüfen, ob es überhaupt trägt, dann in die Config schreiben. Sonst schreibt man
|
||||||
|
* einen ungetesteten Wert in eine Datei, die auf zwei Rechnern liegt. Verdeckte
|
||||||
|
* Eingabe statt Argument, damit kein Klartext in der Shell-History landet.
|
||||||
*/
|
*/
|
||||||
if (PHP_SAPI !== 'cli') {
|
if (PHP_SAPI !== 'cli') {
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -20,12 +26,90 @@ require dirname(__DIR__) . '/app/bootstrap.php';
|
|||||||
use PHPMailer\PHPMailer\PHPMailer;
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
|
||||||
$smtp = config('smtp');
|
$smtp = config('smtp');
|
||||||
|
$frage = in_array('--frage', $argv, true);
|
||||||
|
|
||||||
|
if ($frage) {
|
||||||
|
echo "Postfach-Passwort für {$smtp['username']} (Eingabe unsichtbar): ";
|
||||||
|
system('stty -echo');
|
||||||
|
$eingabe = trim((string) fgets(STDIN));
|
||||||
|
system('stty echo');
|
||||||
|
echo "\n";
|
||||||
|
if ($eingabe === '') {
|
||||||
|
fwrite(STDERR, "Abbruch: kein Passwort eingegeben.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$smtp['password'] = $eingabe;
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array($smtp['password'] ?? '', ['', 'POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
|
if (in_array($smtp['password'] ?? '', ['', 'POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
|
||||||
fwrite(STDERR, "Abbruch: In config/config.php steht noch kein echtes SMTP-Passwort.\n");
|
fwrite(STDERR, "Abbruch: In config/config.php steht noch kein echtes SMTP-Passwort.\n");
|
||||||
|
fwrite(STDERR, "Zum Ausprobieren ohne Config-Änderung: php bin/smtp-test.php --frage\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- --probe: mehrere Zugangswege durchprobieren ---
|
||||||
|
// Hintergrund (31.07.2026): dieselben Zugangsdaten wurden vom Entwicklungsrechner
|
||||||
|
// akzeptiert, vom Webspace aber mit „Could not authenticate" abgelehnt.
|
||||||
|
// w01ca75d.kasserver.com und der Webserver zeigen auf dieselbe IP — vom Server aus
|
||||||
|
// verbindet sich PHP also mit der eigenen Maschine, und die kann Anmeldungen anders
|
||||||
|
// behandeln als von außen. Diese Sonde findet heraus, welcher Weg trägt, statt zu raten.
|
||||||
|
// Bewusst nur vier Kombinationen: jeder Fehlversuch ist ein fehlgeschlagener Login,
|
||||||
|
// und zu viele davon können ein Postfach vorübergehend sperren.
|
||||||
|
if (in_array('--probe', $argv, true)) {
|
||||||
|
$wege = [
|
||||||
|
['label' => 'Konfiguriert: Host, Port ' . (int) $smtp['port'] . ', STARTTLS, mit Anmeldung',
|
||||||
|
'host' => $smtp['host'], 'port' => (int) $smtp['port'], 'sec' => PHPMailer::ENCRYPTION_STARTTLS, 'auth' => true],
|
||||||
|
['label' => 'Host, Port 465, SSL, mit Anmeldung',
|
||||||
|
'host' => $smtp['host'], 'port' => 465, 'sec' => PHPMailer::ENCRYPTION_SMTPS, 'auth' => true],
|
||||||
|
['label' => 'localhost, Port 25, ohne Verschlüsselung, mit Anmeldung',
|
||||||
|
'host' => 'localhost', 'port' => 25, 'sec' => '', 'auth' => true],
|
||||||
|
['label' => 'localhost, Port 25, ohne Verschlüsselung, OHNE Anmeldung (lokaler Relay)',
|
||||||
|
'host' => 'localhost', 'port' => 25, 'sec' => '', 'auth' => false],
|
||||||
|
];
|
||||||
|
|
||||||
|
echo "Sonde: welcher Weg nimmt uns an? Postfach {$smtp['username']}\n\n";
|
||||||
|
$erfolg = [];
|
||||||
|
foreach ($wege as $i => $weg) {
|
||||||
|
$m = new PHPMailer(true);
|
||||||
|
$m->isSMTP();
|
||||||
|
$m->Host = $weg['host'];
|
||||||
|
$m->Port = $weg['port'];
|
||||||
|
$m->SMTPAuth = $weg['auth'];
|
||||||
|
$m->SMTPSecure = $weg['sec'];
|
||||||
|
if (!$weg['auth']) {
|
||||||
|
$m->SMTPAutoTLS = false;
|
||||||
|
}
|
||||||
|
$m->Username = $smtp['username'];
|
||||||
|
$m->Password = $smtp['password'];
|
||||||
|
$m->Timeout = 12;
|
||||||
|
try {
|
||||||
|
$ok = $m->smtpConnect();
|
||||||
|
$m->smtpClose();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$ok = false;
|
||||||
|
$m->ErrorInfo = $e->getMessage();
|
||||||
|
}
|
||||||
|
printf(" %d. %-68s %s\n", $i + 1, $weg['label'], $ok ? 'GEHT' : 'nein');
|
||||||
|
if (!$ok && $m->ErrorInfo !== '') {
|
||||||
|
echo ' ' . trim(preg_replace('/\s+/', ' ', $m->ErrorInfo) ?? '') . "\n";
|
||||||
|
}
|
||||||
|
if ($ok) {
|
||||||
|
$erfolg[] = $weg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
if ($erfolg === []) {
|
||||||
|
echo "Kein Weg trägt. Dann liegt es am Postfach selbst: Passwort im KAS neu setzen\n";
|
||||||
|
echo "und mit php bin/smtp-test.php --frage gegenprüfen.\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$w = $erfolg[0];
|
||||||
|
echo "Nimm den ersten Treffer in config/config.php: host {$w['host']}, port {$w['port']}\n";
|
||||||
|
echo 'Verschlüsselung ' . ($w['sec'] === '' ? 'keine' : $w['sec']) . ', Anmeldung ' . ($w['auth'] ? 'ja' : 'nein') . "\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
echo "Teste {$smtp['host']}:{$smtp['port']} (STARTTLS) als {$smtp['username']} …\n";
|
echo "Teste {$smtp['host']}:{$smtp['port']} (STARTTLS) als {$smtp['username']} …\n";
|
||||||
|
|
||||||
$mail = new PHPMailer(true);
|
$mail = new PHPMailer(true);
|
||||||
@@ -42,9 +126,24 @@ try {
|
|||||||
if ($mail->smtpConnect()) {
|
if ($mail->smtpConnect()) {
|
||||||
$mail->smtpClose();
|
$mail->smtpClose();
|
||||||
echo "OK: Verbindung, STARTTLS und Login erfolgreich — keine Mail versendet.\n";
|
echo "OK: Verbindung, STARTTLS und Login erfolgreich — keine Mail versendet.\n";
|
||||||
|
if ($frage) {
|
||||||
|
echo "Dieses Passwort trägt. Jetzt in config/config.php unter smtp.password eintragen.\n";
|
||||||
|
}
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
fwrite(STDERR, 'Fehler: ' . ($mail->ErrorInfo !== '' ? $mail->ErrorInfo : 'Verbindung fehlgeschlagen.') . "\n");
|
$info = $mail->ErrorInfo !== '' ? $mail->ErrorInfo : 'Verbindung fehlgeschlagen.';
|
||||||
|
fwrite(STDERR, 'Fehler: ' . $info . "\n");
|
||||||
|
// „Could not authenticate" heißt: Host, Port, Firewall und STARTTLS sind in
|
||||||
|
// Ordnung, nur die Zugangsdaten wurden abgelehnt. Das ist die Meldung, die nach
|
||||||
|
// einem All-Inkl-Tarifwechsel kam (31.07.2026) — dort mussten die
|
||||||
|
// Postfach-Passwörter im KAS neu gesetzt werden.
|
||||||
|
if (str_contains($info, 'Could not authenticate')) {
|
||||||
|
fwrite(STDERR, "\nDie Verbindung stand, abgelehnt wurde nur die Anmeldung. Prüfe:\n");
|
||||||
|
fwrite(STDERR, " 1. Postfach-Passwort im KAS neu setzen (nach einem Tarifwechsel nötig).\n");
|
||||||
|
fwrite(STDERR, " 2. Stimmt der Postfachname exakt? Aktuell: {$smtp['username']}\n");
|
||||||
|
fwrite(STDERR, " 3. Stimmt der Mailserver? Aktuell: {$smtp['host']} (im KAS unter E-Mail nachsehen).\n");
|
||||||
|
fwrite(STDERR, " Neues Passwort ohne Config-Änderung ausprobieren: php bin/smtp-test.php --frage\n");
|
||||||
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
fwrite(STDERR, 'Fehler: ' . $e->getMessage() . "\n");
|
fwrite(STDERR, 'Fehler: ' . $e->getMessage() . "\n");
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ declare(strict_types=1);
|
|||||||
* die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
|
* die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
|
||||||
* Fallback-Bilds `vorwort.*`.
|
* 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`.
|
||||||
|
*
|
||||||
|
* Sportfest-Ausgabe (03.09.2026): trägt die Ausgabe ein `veranstaltung`-Feld
|
||||||
|
* (Slug aus data/veranstaltungen.json, chat-gepflegt), kommen dazu: die
|
||||||
|
* Programm-Seite im Aufschlag (aus programm[] der Veranstaltung, dieselbe Liste
|
||||||
|
* wie die Website), ein Block Turnierplan-Seiten (je Eintrag in turniere[] eine
|
||||||
|
* Seite) direkt danach, und eine Gegnerseite je Mannschaft mit HEIMspiel im
|
||||||
|
* Zeitraum der Veranstaltung statt nur für die tragende Mannschaft — welche das
|
||||||
|
* sind, sagt stadionzeitung_gegner_fixtures() (helpers.php), dieselbe Funktion,
|
||||||
|
* aus der der Snapshot die Daten baut. `cover` ({edition, teaser}) kennzeichnet
|
||||||
|
* das Cover als Sonderausgabe. Beide Felder überleben erneute Läufe wie `vorwort`.
|
||||||
|
*
|
||||||
* Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
|
* Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
|
||||||
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
|
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
|
||||||
*/
|
*/
|
||||||
@@ -179,6 +196,21 @@ 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;
|
||||||
|
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
|
||||||
|
$oldAufruf = $oldIssue['aufruf'] ?? null;
|
||||||
|
// Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm,
|
||||||
|
// Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt.
|
||||||
|
$oldVeranstaltung = $oldIssue['veranstaltung'] ?? null;
|
||||||
|
$oldCover = $oldIssue['cover'] ?? null;
|
||||||
|
$veranstaltung = null;
|
||||||
|
if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') {
|
||||||
|
$veranstaltung = veranstaltung_find($oldVeranstaltung);
|
||||||
|
if ($veranstaltung === null) {
|
||||||
|
$log("veranstaltung '{$oldVeranstaltung}' steht nicht in data/veranstaltungen.json — Programm-, Turnier- und Zusatz-Gegnerseiten entfallen.", 'WARN');
|
||||||
|
} else {
|
||||||
|
$log("Sportfest-Ausgabe: Veranstaltung '{$oldVeranstaltung}' (" . count($veranstaltung['programm'] ?? []) . ' Programmtage, ' . count($veranstaltung['turniere'] ?? []) . ' Turniere).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
|
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
|
||||||
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
|
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
|
||||||
@@ -206,6 +238,28 @@ $widths = [480, 960, 1440];
|
|||||||
// erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und
|
// erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und
|
||||||
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
|
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
|
||||||
// Bilder liegen schon. ---
|
// Bilder liegen schon. ---
|
||||||
|
// Anzeigen mit `platzierung` haben einen FESTEN Platz auf einer Live-Seite
|
||||||
|
// (derzeit `danke`: die Emons-Anzeige über der Partner-Logo-Wand) und laufen
|
||||||
|
// deshalb nicht im Streu-Pool mit — sonst stünden sie zweimal im Heft.
|
||||||
|
$platzierteAnzeigen = [];
|
||||||
|
foreach (json_load('anzeigen')['anzeigen'] ?? [] as $a) {
|
||||||
|
$platz = trim((string) ($a['platzierung'] ?? ''));
|
||||||
|
if ($platz === '' || ($a['aktiv'] ?? true) === false || empty($a['base'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!empty($a['bis']) && (string) $a['bis'] < $date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($platzierteAnzeigen[$platz])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$platzierteAnzeigen[$platz] = [
|
||||||
|
'base' => (string) $a['base'],
|
||||||
|
'widths' => $a['widths'] ?? [],
|
||||||
|
'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . trim((string) ($a['sponsor'] ?? '')))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$anzeigenSeiten = array_values(array_map(
|
$anzeigenSeiten = array_values(array_map(
|
||||||
static function (array $a): array {
|
static function (array $a): array {
|
||||||
$sponsor = trim((string) ($a['sponsor'] ?? ''));
|
$sponsor = trim((string) ($a['sponsor'] ?? ''));
|
||||||
@@ -228,6 +282,7 @@ $anzeigenSeiten = array_values(array_map(
|
|||||||
// nächsten Handgriff im Heft zu bleiben.
|
// nächsten Handgriff im Heft zu bleiben.
|
||||||
static fn (array $a): bool => ($a['aktiv'] ?? true)
|
static fn (array $a): bool => ($a['aktiv'] ?? true)
|
||||||
&& !empty($a['base'])
|
&& !empty($a['base'])
|
||||||
|
&& empty($a['platzierung'])
|
||||||
&& (empty($a['bis']) || (string) $a['bis'] >= $date)
|
&& (empty($a['bis']) || (string) $a['bis'] >= $date)
|
||||||
)
|
)
|
||||||
));
|
));
|
||||||
@@ -236,12 +291,8 @@ $anzeigenSeiten = array_values(array_map(
|
|||||||
// Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE
|
// Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE
|
||||||
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
|
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
|
||||||
// bei jeder Umverteilung. ---
|
// bei jeder Umverteilung. ---
|
||||||
$live = static function (string $page, ?string $team = null): array {
|
$live = static function (string $page, array $extra = []): array {
|
||||||
$entry = ['type' => 'live', 'page' => $page];
|
return ['type' => 'live', 'page' => $page] + $extra;
|
||||||
if ($team !== null) {
|
|
||||||
$entry['team'] = $team;
|
|
||||||
}
|
|
||||||
return $entry;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
|
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
|
||||||
@@ -284,32 +335,89 @@ try {
|
|||||||
} elseif ($vorwort !== null) {
|
} elseif ($vorwort !== null) {
|
||||||
$aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung');
|
$aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung');
|
||||||
}
|
}
|
||||||
|
// Programm-Seite der Sportfest-Ausgabe direkt hinter dem Vorwort: aufgeschlagen
|
||||||
|
// liegen Vorwort (links) und Programm (rechts) als Doppelseite 2/3 nebeneinander.
|
||||||
|
if ($veranstaltung !== null && !empty($veranstaltung['programm'])) {
|
||||||
|
$aufschlag[] = $live('programm');
|
||||||
|
}
|
||||||
// Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes
|
// Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes
|
||||||
// Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
|
// Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
|
||||||
if ($oldInterview !== null) {
|
if ($oldInterview !== null) {
|
||||||
$aufschlag[] = $live('interview');
|
$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');
|
||||||
|
}
|
||||||
if ($aufschlag !== []) {
|
if ($aufschlag !== []) {
|
||||||
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
|
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Turnierplan-Seiten der Sportfest-Ausgabe: je Turnier eine Seite, EIN Block
|
||||||
|
// (zusammenhängender Sportfest-Teil, keine Anzeige zwischen zwei Turnieren),
|
||||||
|
// gleich nach dem Aufschlag und vor dem Fußball-Teil.
|
||||||
|
$turniere = [];
|
||||||
|
foreach ($veranstaltung['turniere'] ?? [] as $turnier) {
|
||||||
|
if (is_array($turnier) && !empty($turnier['key'])) {
|
||||||
|
$turniere[] = $live('turnier', ['turnier' => (string) $turnier['key']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($turniere !== []) {
|
||||||
|
$bloecke[] = ['name' => 'turniere', 'seiten' => $turniere, 'anzeigen_danach' => true];
|
||||||
|
}
|
||||||
|
|
||||||
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
|
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
|
||||||
|
|
||||||
|
// Gegnerseite(n): normal nur die tragende Mannschaft; in der Sportfest-Ausgabe
|
||||||
|
// je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als
|
||||||
|
// EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() —
|
||||||
|
// dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut,
|
||||||
|
// damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen.
|
||||||
|
$gegnerSeiten = [];
|
||||||
|
foreach (stadionzeitung_gegner_fixtures([
|
||||||
|
'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent,
|
||||||
|
'veranstaltung' => $oldVeranstaltung,
|
||||||
|
]) as $gegnerTeam => $fixture) {
|
||||||
|
$gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]);
|
||||||
|
$log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']}).");
|
||||||
|
}
|
||||||
|
if ($gegnerSeiten === []) {
|
||||||
|
// Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft —
|
||||||
|
// sie meldet dann selbst, dass keine Vergleichsdaten vorliegen.
|
||||||
|
$gegnerSeiten[] = $live('gegner', ['team' => $teamKey]);
|
||||||
|
}
|
||||||
|
$bloecke[] = ['name' => 'gegner', 'seiten' => $gegnerSeiten, 'anzeigen_danach' => true];
|
||||||
|
|
||||||
// Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
|
// Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
|
||||||
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
|
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
|
||||||
// SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
|
// SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
|
||||||
$tabellen = [];
|
$tabellen = [];
|
||||||
foreach ($mc['teams'] ?? [] as $team) {
|
foreach ($mc['teams'] ?? [] as $team) {
|
||||||
if (!empty($team['key'])) {
|
if (!empty($team['key'])) {
|
||||||
$tabellen[] = $live('tabelle', (string) $team['key']);
|
$tabellen[] = $live('tabelle', ['team' => (string) $team['key']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($tabellen !== []) {
|
if ($tabellen !== []) {
|
||||||
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
|
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) {
|
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'danke', 'momente'] as $seite) {
|
||||||
$bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true];
|
// Die Danke-Seite trägt ihr Anzeigenmotiv im Seiteneintrag (siehe oben),
|
||||||
|
// direkt hinter der Partner-werden-Seite: aufgeschlagen liegen damit
|
||||||
|
// Akquise und Dank als Partner-Doppelseite nebeneinander.
|
||||||
|
$extra = ($seite === 'danke' && isset($platzierteAnzeigen['danke']))
|
||||||
|
? ['anzeige' => $platzierteAnzeigen['danke']]
|
||||||
|
: [];
|
||||||
|
// Hinter der Partner-werden-Seite bleibt die Lücke leer, damit die
|
||||||
|
// Danke-Seite direkt folgt: Akquise und Dank gehören zusammen und
|
||||||
|
// liegen aufgeschlagen im günstigen Fall als Doppelseite nebeneinander.
|
||||||
|
$bloecke[] = [
|
||||||
|
'name' => $seite,
|
||||||
|
'seiten' => [$live($seite, $extra)],
|
||||||
|
'anzeigen_danach' => $seite !== 'partner',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];
|
$bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];
|
||||||
@@ -481,23 +589,49 @@ 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');
|
$log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Spielerfoto fürs Cover: unverändert kopieren (kein resize_image_variants(),
|
// --- Spielerfoto fürs Cover: zuschneiden UND verkleinern. ---
|
||||||
// das würde über imagejpeg() die Transparenz zerstören — Felix liefert bereits
|
// Hier stand früher „unverändert kopieren, Felix liefert bereits ein web-taugliches
|
||||||
// ein web-taugliches Foto). Optional, kein Fehlschlag bei Fehlen. ---
|
// 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.
|
||||||
$playerImage = null;
|
$playerImage = null;
|
||||||
if ($spieler !== null) {
|
if ($spieler !== null) {
|
||||||
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
|
||||||
$playerDest = "{$destDir}/spieler.{$ext}";
|
$istPng = $ext === 'png';
|
||||||
// PNG: auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
|
// PNG: erst auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
|
||||||
// leerem Rand aus dem Export-Tool). Andere Formate (kein Alpha) unverändert.
|
// leerem Rand aus dem Export-Tool), dann verkleinern. Der Zuschnitt geht in eine
|
||||||
$copied = $ext === 'png'
|
// Zwischendatei, damit das riesige Original nicht im Ausgaben-Ordner landet.
|
||||||
? crop_transparent_png_to_content($spieler, $playerDest)
|
$quelle = $spieler;
|
||||||
: copy($spieler, $playerDest);
|
$tmpCrop = "{$destDir}/spieler-zuschnitt.png";
|
||||||
if ($copied) {
|
if ($istPng && crop_transparent_png_to_content($spieler, $tmpCrop)) {
|
||||||
$playerImage = "img/stadionzeitung/{$slug}/spieler.{$ext}";
|
$quelle = $tmpCrop;
|
||||||
$log('Spielerfoto übernommen' . ($ext === 'png' ? ' (auf Inhalt zugeschnitten).' : ' (unverändert kopiert).'));
|
}
|
||||||
} else {
|
try {
|
||||||
$log("Spielerfoto konnte nicht kopiert werden: {$spieler}", 'WARN');
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +661,20 @@ foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'his
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
|
// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
|
||||||
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
|
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
|
||||||
// keine Einbindung, Regel 1 bleibt unberührt).
|
// keine Einbindung, Regel 1 bleibt unberührt).
|
||||||
@@ -594,6 +742,9 @@ $issues[] = [
|
|||||||
'qr_image' => $qrImage,
|
'qr_image' => $qrImage,
|
||||||
'vorwort' => $oldVorwort,
|
'vorwort' => $oldVorwort,
|
||||||
'interview' => $oldInterview,
|
'interview' => $oldInterview,
|
||||||
|
'aufruf' => $oldAufruf,
|
||||||
|
'veranstaltung' => $oldVeranstaltung,
|
||||||
|
'cover' => $oldCover,
|
||||||
'verfasser_photo' => $verfasserPhoto,
|
'verfasser_photo' => $verfasserPhoto,
|
||||||
'termine_snapshot' => $termineSnapshot,
|
'termine_snapshot' => $termineSnapshot,
|
||||||
'images' => $images,
|
'images' => $images,
|
||||||
|
|||||||
@@ -38,6 +38,112 @@ $log = static function (string $msg, string $level = 'INFO'): void {
|
|||||||
echo '[' . date('c') . "] {$level} {$msg}\n";
|
echo '[' . date('c') . "] {$level} {$msg}\n";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bilder, auf die der Snapshot zeigt, in den Ausgaben-Ordner kopieren und die Pfade
|
||||||
|
* darin umschreiben. Damit ist eine veröffentlichte Ausgabe wirklich eingefroren.
|
||||||
|
*
|
||||||
|
* Warum das nötig ist: der Snapshot friert die DATEN ein, verwies aber weiter auf
|
||||||
|
* Ordner, die die Sync-Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg,
|
||||||
|
* die der BFV nicht mehr führt, bin/instagram-sync.php ältere Posts — am 31.07.2026
|
||||||
|
* nachgewiesen: ein Lauf löschte 18 Wappen und 3 Instagram-Bilder. Bei den Wappen
|
||||||
|
* dauert das eine Saison, bei Instagram Wochen. Danach zeigte eine archivierte
|
||||||
|
* Ausgabe Lücken, und „gedruckt = fixiert" wäre nur die halbe Wahrheit.
|
||||||
|
*
|
||||||
|
* img/news wird mit eingefroren, obwohl News-Bilder nicht maschinenverwaltet sind:
|
||||||
|
* ein Artikelbild kann im Chat ersetzt werden, und dann änderte sich nachträglich
|
||||||
|
* eine gedruckte Ausgabe. Dieselbe Begründung, derselbe Handgriff.
|
||||||
|
*
|
||||||
|
* Fehlt eine Quelldatei (schon weggeräumt), bleibt der Pfad stehen und es gibt eine
|
||||||
|
* WARN — die crest-Komponente fällt auf Initialen zurück, ein fehlendes Bild darf
|
||||||
|
* das Veröffentlichen nicht verhindern.
|
||||||
|
*/
|
||||||
|
$freezeAssets = static function (array $snapshot, string $slug, callable $log): array {
|
||||||
|
$quellen = ['img/crests/', 'img/instagram/', 'img/news/'];
|
||||||
|
// Präfix je Quellordner, damit zwei gleichnamige Dateien sich nicht überschreiben
|
||||||
|
// und man im Ausgaben-Ordner sofort sieht, woher eine Datei kommt.
|
||||||
|
$praefix = ['img/crests/' => 'fix-wappen-', 'img/instagram/' => 'fix-insta-', 'img/news/' => 'fix-news-'];
|
||||||
|
$zielDir = PUBLIC_PATH . '/assets/img/stadionzeitung/' . $slug;
|
||||||
|
if (!is_dir($zielDir)) {
|
||||||
|
mkdir($zielDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$kopiert = [];
|
||||||
|
$fehlend = [];
|
||||||
|
|
||||||
|
$walk = static function (&$node) use (&$walk, $quellen, $praefix, $zielDir, $slug, &$kopiert, &$fehlend): void {
|
||||||
|
if (is_array($node)) {
|
||||||
|
// Varianten-Set {base, widths, ext?} zuerst: `base` ist KEIN Dateipfad,
|
||||||
|
// sondern ein Stamm ohne Endung — jede Breite ist eine eigene Datei.
|
||||||
|
// Ohne diesen Zweig lief img/news/neue-website als „Quelldatei fehlt" auf.
|
||||||
|
if (isset($node['base'], $node['widths']) && is_string($node['base']) && is_array($node['widths'])) {
|
||||||
|
foreach ($quellen as $ordner) {
|
||||||
|
if (!str_starts_with($node['base'], $ordner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = strtolower((string) ($node['ext'] ?? 'jpg'));
|
||||||
|
$neuStamm = $praefix[$ordner] . basename($node['base']);
|
||||||
|
$alleDa = true;
|
||||||
|
foreach ($node['widths'] as $w) {
|
||||||
|
$von = PUBLIC_PATH . "/assets/{$node['base']}-{$w}.{$ext}";
|
||||||
|
if (!is_file($von)) {
|
||||||
|
$fehlend["{$node['base']}-{$w}.{$ext}"] = true;
|
||||||
|
$alleDa = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
copy($von, "{$zielDir}/{$neuStamm}-{$w}.{$ext}");
|
||||||
|
$kopiert["{$node['base']}-{$w}.{$ext}"] = true;
|
||||||
|
}
|
||||||
|
// Nur umschreiben, wenn ALLE Breiten kopiert sind — ein halbes
|
||||||
|
// srcset wäre schlimmer als der alte Pfad.
|
||||||
|
if ($alleDa) {
|
||||||
|
$node['base'] = "img/stadionzeitung/{$slug}/{$neuStamm}";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($node as &$wert) {
|
||||||
|
$walk($wert);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!is_string($node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($quellen as $ordner) {
|
||||||
|
if (!str_starts_with($node, $ordner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$datei = basename($node);
|
||||||
|
$neu = $praefix[$ordner] . $datei;
|
||||||
|
$ziel = "{$zielDir}/{$neu}";
|
||||||
|
if (!isset($kopiert[$node])) {
|
||||||
|
$von = PUBLIC_PATH . '/assets/' . $node;
|
||||||
|
if (!is_file($von)) {
|
||||||
|
$fehlend[$node] = true;
|
||||||
|
return; // Pfad unverändert lassen — besser ein Fallback als ein toter Link
|
||||||
|
}
|
||||||
|
copy($von, $ziel);
|
||||||
|
$kopiert[$node] = true;
|
||||||
|
}
|
||||||
|
$node = "img/stadionzeitung/{$slug}/{$neu}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$walk($snapshot);
|
||||||
|
|
||||||
|
$log(sprintf(
|
||||||
|
'Bilder eingefroren: %d Dateien in img/stadionzeitung/%s/ kopiert%s.',
|
||||||
|
count($kopiert),
|
||||||
|
$slug,
|
||||||
|
$fehlend === [] ? '' : ', ' . count($fehlend) . ' Quelldatei(en) fehlten schon'
|
||||||
|
));
|
||||||
|
foreach (array_keys($fehlend) as $f) {
|
||||||
|
$log("Quelldatei für den Snapshot fehlt, Pfad unverändert: {$f}", 'WARN');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
$cacheFile = DATA_PATH . '/stadionzeitung.json';
|
$cacheFile = DATA_PATH . '/stadionzeitung.json';
|
||||||
$data = is_file($cacheFile)
|
$data = is_file($cacheFile)
|
||||||
? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
|
? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
|
||||||
@@ -53,7 +159,16 @@ foreach ($data['issues'] as &$issue) {
|
|||||||
if ($unpublish) {
|
if ($unpublish) {
|
||||||
$issue['status'] = 'entwurf';
|
$issue['status'] = 'entwurf';
|
||||||
unset($issue['snapshot']);
|
unset($issue['snapshot']);
|
||||||
$log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen (Live-Seiten rechnen wieder frisch).");
|
// Die eingefrorenen Bilder gehören zum Snapshot und gehen mit ihm. Sonst
|
||||||
|
// lägen sie als Waisen im Ausgaben-Ordner und ein erneutes Veröffentlichen
|
||||||
|
// legte daneben eine zweite Fassung ab.
|
||||||
|
$weg = 0;
|
||||||
|
foreach (glob(PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}/fix-*") ?: [] as $f) {
|
||||||
|
if (is_file($f) && unlink($f)) {
|
||||||
|
$weg++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen, {$weg} eingefrorene Bilder entfernt (Live-Seiten rechnen wieder frisch).");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +178,9 @@ foreach ($data['issues'] as &$issue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$snapshot = stadionzeitung_build_snapshot($issue);
|
$snapshot = stadionzeitung_build_snapshot($issue);
|
||||||
|
// Erst die Bilder sichern, dann den Snapshot ablegen: die Pfade darin zeigen
|
||||||
|
// danach in den Ausgaben-Ordner und sind vom Sync unabhängig.
|
||||||
|
$snapshot = $freezeAssets($snapshot, $slug, $log);
|
||||||
$issue['snapshot'] = $snapshot;
|
$issue['snapshot'] = $snapshot;
|
||||||
$issue['status'] = 'veroeffentlicht';
|
$issue['status'] = 'veroeffentlicht';
|
||||||
$issue['published_at'] = date('c');
|
$issue['published_at'] = date('c');
|
||||||
|
|||||||
@@ -7,46 +7,49 @@
|
|||||||
* config.php ist gitignored und liegt außerhalb des Webroots (zusätzlich per .htaccess gesperrt).
|
* config.php ist gitignored und liegt außerhalb des Webroots (zusätzlich per .htaccess gesperrt).
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* TAKT DER SYNC-SKRIPTE — zwei Wege, je nach Tarif
|
* TAKT DER SYNC-SKRIPTE — über public/cron.php, aufgerufen vom KAS-Cron
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* A) Der Tarif hat Cronjobs → Shell-Cronjobs unten verwenden. Sauberster Weg,
|
* Geprüft im KAS am 31.07.2026: der Tarif hat SSH und Cronjobs, aber die Cronjobs
|
||||||
* kein Endpoint, kein Dritter im Spiel.
|
* können ausschließlich **URLs** aufrufen. Ein `php bin/…` ist dort nicht eintragbar
|
||||||
* B) Der Tarif hat KEINE Cronjobs (Stand 27.07.2026 bei uns der Fall, KAS meldet
|
* (das Formular hat nur ein Feld „Protokoll / Pfad" mit https://). Also läuft der Takt
|
||||||
* „in deinem Tarif nicht verfügbar“) → public/cron.php + externer Cron-Dienst.
|
* über public/cron.php, und der Aufrufer ist der Hoster selbst — kein Dritter, und der
|
||||||
* Dann `cron.key` unten setzen und beim Dienst diese URLs im gewünschten Takt
|
* Schlüssel verlässt den Webspace nie.
|
||||||
* aufrufen lassen (Schlüssel besser als Header X-Cron-Key als in der URL):
|
|
||||||
*
|
*
|
||||||
* https://www.tsv08kulmbach.de/cron.php?job=matchcenter
|
* URLs (Schlüssel = cron.key unten):
|
||||||
* https://www.tsv08kulmbach.de/cron.php?job=instagram
|
* https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key>
|
||||||
|
* https://www.tsv08kulmbach.de/cron.php?job=instagram&key=<cron.key>
|
||||||
|
* https://www.tsv08kulmbach.de/cron.php?job=logrotate&key=<cron.key>
|
||||||
*
|
*
|
||||||
* Takt wie unten bei den Cronjobs. Der Dienst muss keine Antwort auswerten:
|
* Antwort ist immer leer: 204 = gelaufen oder wegen Mindestabstand übersprungen,
|
||||||
* 204 = angenommen oder wegen Mindestabstand übersprungen, 403 = Schlüssel oder
|
* 403 = Schlüssel oder Job falsch. Der KAS-Cron muss nichts auswerten. Das
|
||||||
* Job falsch (dann ist der Dienst falsch eingerichtet und es syncte nichts).
|
* E-Mail-Feld im KAS kann leer bleiben; ob die Syncs laufen, sagt `php bin/logs.php`,
|
||||||
* Erwarteter Fehlerfall: Instagram sperrt Rechenzentrums-IPs. Einmal von Hand
|
* und `php bin/preflight.php` warnt, wenn ein Datenstand zu alt wird.
|
||||||
* prüfen, ob der Scraper auf dem Server überhaupt durchkommt.
|
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* CRONJOBS (im KAS anlegen, Typ „eigener Cronjob“ / Shell) — nur Weg A
|
* DIE DREI CRONJOBS IM KAS (Zeitraster des KAS, nicht Crontab-Syntax)
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* SITE=/www/htdocs/<KAS-User>/tsv08kulmbach (echten Pfad einsetzen: pwd auf dem Server)
|
* 1. Matchcenter — „stündlich", Minute 37
|
||||||
* PHP=/usr/bin/php (All-Inkl ggf. /usr/local/bin/php8.2 — `which php8.2` prüft es)
|
* Das KAS kann keine Wochentage mit Halbstunden-Takt kombinieren, wie es die
|
||||||
|
* ursprüngliche Crontab vorsah (Sa+So 13-21 Uhr halbstündlich). Stündlich rund um
|
||||||
|
* die Uhr ist der brauchbare Ersatz: am Spieltag stehen Ergebnis und Tabelle
|
||||||
|
* spätestens eine Stunde später. Kostet 24 Läufe/Tag statt 2 plus Wochenende.
|
||||||
|
* Wer den Verkehr zum BFV klein halten will, nimmt stattdessen zwei „täglich"-Jobs
|
||||||
|
* (07:37 und 19:37) und lebt damit, dass Samstagsergebnisse erst abends stehen.
|
||||||
*
|
*
|
||||||
* # Instagram-Feed: 2×/Tag (versetzte Minuten, damit die Läufe sich nicht überlappen)
|
* 2. Instagram — ZWEI Jobs „täglich", 06:17 und 18:17
|
||||||
* 17 6,18 * * * cd $SITE && $PHP bin/instagram-sync.php >> storage/logs/instagram.log 2>&1
|
* Zwei Einträge, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht stündlich:
|
||||||
|
* Instagram sperrt auffällige Zugriffsmuster deutlich schneller als der BFV.
|
||||||
*
|
*
|
||||||
* # Matchcenter: 2×/Tag als Grundtakt
|
* 3. Logpflege — „täglich", 03:23 (`?job=logrotate`)
|
||||||
* 37 7,19 * * * cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
|
* Rotiert erst über 2 MB und löscht Einträge älter als 90 Tage, ein täglicher Lauf
|
||||||
|
* ist deshalb meistens ein No-Op. Wöchentlich genügt auch.
|
||||||
*
|
*
|
||||||
* # Matchcenter am Spieltag-Nachmittag/Abend ½-stündlich (Sa+So 13–21 Uhr),
|
* Die Minuten sind versetzt, damit sich zwei Läufe nicht überlappen (die Syncs sperren
|
||||||
* # damit Ergebnisse und Tabelle zeitnah stehen.
|
* sich per Lock, sonst fiele einer aus).
|
||||||
* 7,37 13-21 * * 6,0 cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
|
|
||||||
*
|
*
|
||||||
* # Logpflege: sonntags nachts. Rotiert Dateien über 2 MB und löscht Einträge älter als 90 Tage.
|
* Alle Skripte sind CLI-only bzw. laufen über den authentifizierten Endpoint; die Syncs
|
||||||
* 23 3 * * 0 cd $SITE && $PHP bin/log-rotate.php > /dev/null 2>&1
|
* lassen bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf
|
||||||
*
|
* schadet nie. Erstlauf nach dem Deploy einmal per SSH anstoßen (füllt Cache + Bilder).
|
||||||
* Alle Skripte sind CLI-only; die Syncs sperren sich per Lock gegen Parallelläufe und lassen
|
|
||||||
* bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie.
|
|
||||||
* Erstlauf nach dem Deploy einmal von Hand anstoßen (füllt Cache + Bilder/Wappen).
|
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* NACH DEM DEPLOY
|
* NACH DEM DEPLOY
|
||||||
@@ -80,6 +83,20 @@ return [
|
|||||||
|
|
||||||
// All-Inkl SMTP (Postfach im KAS anlegen; Host = <KAS-Login>.kasserver.com).
|
// All-Inkl SMTP (Postfach im KAS anlegen; Host = <KAS-Login>.kasserver.com).
|
||||||
// Verbindung testen ohne Mailversand: php bin/smtp-test.php
|
// Verbindung testen ohne Mailversand: php bin/smtp-test.php
|
||||||
|
//
|
||||||
|
// „SMTP Error: Could not authenticate" auf dem Server, obwohl dieselben Daten vom
|
||||||
|
// Entwicklungsrechner angenommen werden? Am 31.07.2026 war das VORÜBERGEHEND:
|
||||||
|
// derselbe Weg lief Minuten später fehlerfrei, ohne Änderung an der Config
|
||||||
|
// (vermutlich der Brute-Force-Schutz des Mailservers). Erst `php bin/smtp-test.php --probe`
|
||||||
|
// laufen lassen, bevor irgendetwas hier geändert wird — und nicht in Serie
|
||||||
|
// wiederholen, jeder Fehlversuch ist ein fehlgeschlagener Login.
|
||||||
|
//
|
||||||
|
// Bekannter RÜCKFALLWEG auf diesem Webspace, falls die Anmeldung öfter kippt:
|
||||||
|
// host 'localhost', port 25, ohne Verschlüsselung und OHNE Anmeldung — der lokale
|
||||||
|
// Relay des Hosters nimmt uns an (am 31.07.2026 nachgewiesen). Dann kann eine
|
||||||
|
// Anmeldung gar nicht mehr fehlschlagen. Bewusst NICHT der Standard: mit
|
||||||
|
// 'localhost' liefen Dev und Produktion auseinander (lokal gibt es keinen
|
||||||
|
// Mailserver), und ein Codepfad ist leichter zu pflegen als zwei.
|
||||||
'smtp' => [
|
'smtp' => [
|
||||||
'host' => 'w01ca75d.kasserver.com',
|
'host' => 'w01ca75d.kasserver.com',
|
||||||
'port' => 587, // STARTTLS
|
'port' => 587, // STARTTLS
|
||||||
@@ -92,12 +109,12 @@ return [
|
|||||||
'to' => 'info@tsv08kulmbach.de',
|
'to' => 'info@tsv08kulmbach.de',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Cron-Endpoint public/cron.php — nur nötig, wenn der Tarif keine Cronjobs hat
|
// Cron-Endpoint public/cron.php — der Takt-Geber (siehe oben). Leer oder
|
||||||
// (Weg B oben). Leer oder 'CHANGE_ME' lassen heißt: der Endpoint ist zu und
|
// 'CHANGE_ME' heißt: der Endpoint ist zu und antwortet auf jeden Aufruf mit 403,
|
||||||
// antwortet auf jeden Aufruf mit 403.
|
// dann läuft KEIN Sync mehr.
|
||||||
'cron' => [
|
'cron' => [
|
||||||
// Langes Zufallsgeheimnis, NICHT app_secret wiederverwenden — der Schlüssel
|
// Langes Zufallsgeheimnis, NICHT app_secret wiederverwenden: dieser Schlüssel
|
||||||
// reist bei einem externen Dienst mit und darf dort nichts anderes aufschließen.
|
// steht im KAS in einer Cronjob-URL und darf nichts anderes aufschließen.
|
||||||
// Erzeugen: php -r 'echo bin2hex(random_bytes(24)), "\n";'
|
// Erzeugen: php -r 'echo bin2hex(random_bytes(24)), "\n";'
|
||||||
'key' => 'CHANGE_ME',
|
'key' => 'CHANGE_ME',
|
||||||
// Mindestabstand zwischen zwei Läufen desselben Jobs in Sekunden. Schützt bei
|
// Mindestabstand zwischen zwei Läufen desselben Jobs in Sekunden. Schützt bei
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"name": "Kerstin Tauber",
|
"name": "Kerstin Tauber",
|
||||||
"phone": "+49 176 38957526",
|
"phone": "+49 176 38957526",
|
||||||
"whatsapp": true,
|
"whatsapp": true,
|
||||||
"image": null
|
"image": "img/persons/kerstin-tauber.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Turnen",
|
"group": "Turnen",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Turnen",
|
"group": "Turnen",
|
||||||
"department": "Step Aerobic, Fitness & Pilates",
|
"department": "Fitness & Pilates",
|
||||||
"name": "Karola Lorz",
|
"name": "Karola Lorz",
|
||||||
"email": "turnen@tsv08kulmbach.de",
|
"email": "turnen@tsv08kulmbach.de",
|
||||||
"image": null
|
"image": null
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
{
|
{
|
||||||
"slug": "emons-stuebinger",
|
"slug": "emons-stuebinger",
|
||||||
"sponsor": "Emons Spedition und AST Stübinger Hoch-, Tief- und Straßenbau, Kulmbach",
|
"sponsor": "Emons Spedition und AST Stübinger Hoch-, Tief- und Straßenbau, Kulmbach",
|
||||||
"aktiv": true,
|
"aktiv": false,
|
||||||
"base": "img/anzeigen/emons-stuebinger",
|
"base": "img/anzeigen/emons-stuebinger",
|
||||||
"widths": [
|
"widths": [
|
||||||
480,
|
480,
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
{
|
{
|
||||||
"slug": "sportfest-2026-plakat",
|
"slug": "sportfest-2026-plakat",
|
||||||
"sponsor": "Sportfest 2026",
|
"sponsor": "Sportfest 2026",
|
||||||
"aktiv": true,
|
"aktiv": false,
|
||||||
"bis": "2026-09-06",
|
"bis": "2026-09-06",
|
||||||
"alt": "Eigenanzeige: Sportfest 2026 vom 4. bis 6. September auf unserer Sportanlage",
|
"alt": "Eigenanzeige: Sportfest 2026 vom 4. bis 6. September auf unserer Sportanlage",
|
||||||
"base": "img/anzeigen/sportfest-2026-plakat",
|
"base": "img/anzeigen/sportfest-2026-plakat",
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
"slug": "sportfest-2026-programm",
|
"slug": "sportfest-2026-programm",
|
||||||
"sponsor": "Programm Sportfest 2026",
|
"sponsor": "Programm Sportfest 2026",
|
||||||
"aktiv": true,
|
"aktiv": true,
|
||||||
"bis": "2026-09-06",
|
"bis": "2026-09-03",
|
||||||
"alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade",
|
"alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade",
|
||||||
"base": "img/anzeigen/sportfest-2026-programm",
|
"base": "img/anzeigen/sportfest-2026-programm",
|
||||||
"widths": [
|
"widths": [
|
||||||
@@ -247,6 +247,54 @@
|
|||||||
960,
|
960,
|
||||||
1440
|
1440
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "gagel-pickelmann",
|
||||||
|
"sponsor": "Gagel Bäder und Mineralölhandel Pickelmann",
|
||||||
|
"aktiv": true,
|
||||||
|
"base": "img/anzeigen/gagel-pickelmann",
|
||||||
|
"widths": [
|
||||||
|
480,
|
||||||
|
960,
|
||||||
|
1440
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "schramm",
|
||||||
|
"sponsor": "Landgasthof und Metzgerei Schramm, Forstlahm",
|
||||||
|
"aktiv": true,
|
||||||
|
"base": "img/anzeigen/schramm",
|
||||||
|
"widths": [
|
||||||
|
480,
|
||||||
|
960,
|
||||||
|
1440
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tagesskifahrt-2027",
|
||||||
|
"sponsor": "Unsere Tagesskifahrt ins Hochzillertal 2027",
|
||||||
|
"aktiv": true,
|
||||||
|
"bis": "2027-02-20",
|
||||||
|
"alt": "Eigenanzeige: Unsere Tagesskifahrt ins Hochzillertal am 20. Februar 2027",
|
||||||
|
"base": "img/anzeigen/tagesskifahrt-2027",
|
||||||
|
"widths": [
|
||||||
|
480,
|
||||||
|
960,
|
||||||
|
1440
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "emons",
|
||||||
|
"sponsor": "Emons Spedition",
|
||||||
|
"aktiv": true,
|
||||||
|
"base": "img/anzeigen/emons",
|
||||||
|
"widths": [
|
||||||
|
480,
|
||||||
|
960,
|
||||||
|
1141
|
||||||
|
],
|
||||||
|
"alt": "Anzeige: Emons Spedition Himmelkron, Ihr Partner für nationale und internationale Transporte",
|
||||||
|
"platzierung": "danke"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,19 @@
|
|||||||
"title": "Hier könnte Ihre Werbung stehen",
|
"title": "Hier könnte Ihre Werbung stehen",
|
||||||
"text": "Werden Sie Werbepartner des TSV 08 Kulmbach und zeigen Sie Ihr Unternehmen genau hier: sichtbar für tausende sportbegeisterte Menschen aus Kulmbach und dem Katzbachtal.",
|
"text": "Werden Sie Werbepartner des TSV 08 Kulmbach und zeigen Sie Ihr Unternehmen genau hier: sichtbar für tausende sportbegeisterte Menschen aus Kulmbach und dem Katzbachtal.",
|
||||||
"logo": "img/logo.svg",
|
"logo": "img/logo.svg",
|
||||||
"background": "img/pages/fussball-hero-1600.jpg",
|
"background": {
|
||||||
"cta": { "label": "Werbepartner werden", "url": "/partner-werden", "internal": true }
|
"base": "img/pages/fussball-hero",
|
||||||
|
"widths": [
|
||||||
|
640,
|
||||||
|
1000,
|
||||||
|
1600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"label": "Werbepartner werden",
|
||||||
|
"url": "/partner-werden",
|
||||||
|
"internal": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "Breadcrumb Solutions GmbH",
|
"company": "Breadcrumb Solutions GmbH",
|
||||||
@@ -18,9 +29,19 @@
|
|||||||
"text": "Beratung, Software & Implementierung aus einer Hand. Wir entwickeln Intranets, Kundenportale, Apps und Individualsoftware, inklusive Einführung, sodass sie im Alltag tatsächlich ankommt.",
|
"text": "Beratung, Software & Implementierung aus einer Hand. Wir entwickeln Intranets, Kundenportale, Apps und Individualsoftware, inklusive Einführung, sodass sie im Alltag tatsächlich ankommt.",
|
||||||
"logo": "img/banners/breadcrumb-solutions.svg",
|
"logo": "img/banners/breadcrumb-solutions.svg",
|
||||||
"logo_light": true,
|
"logo_light": true,
|
||||||
"accent": "#6cbbbc",
|
"accent": "breadcrumb",
|
||||||
"background": "img/banners/breadcrumb-hero-1600.jpg",
|
"background": {
|
||||||
"cta": { "label": "Neue Website entdecken", "url": "https://www.breadcrumb-solutions.de/" }
|
"base": "img/banners/breadcrumb-hero",
|
||||||
|
"widths": [
|
||||||
|
640,
|
||||||
|
1000,
|
||||||
|
1600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"label": "Neue Website entdecken",
|
||||||
|
"url": "https://www.breadcrumb-solutions.de/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,17 @@
|
|||||||
},
|
},
|
||||||
"ctas": []
|
"ctas": []
|
||||||
},
|
},
|
||||||
|
"team_photo": { "base": "img/pages/jugend-team", "widths": [640, 1000], "alt": "Unsere Jugendabteilung des TSV 08 Kulmbach" },
|
||||||
"about": {
|
"about": {
|
||||||
"title": "Mitmachen leicht gemacht",
|
"title": "Mitmachen leicht gemacht",
|
||||||
"text": "Wenn deine Kinder Lust haben, bei uns Fußball auszuprobieren oder mitzuspielen, meldet euch gerne ganz unkompliziert. Wir freuen uns über jedes neue Gesicht und bieten Fußball in familiärer Atmosphäre, vom ersten Ballkontakt bis zum echten Mannschaftsspiel."
|
"text": "Wenn deine Kinder Lust haben, bei uns Fußball auszuprobieren oder mitzuspielen, meldet euch gerne ganz unkompliziert. Wir freuen uns über jedes neue Gesicht und bieten Fußball in familiärer Atmosphäre, vom ersten Ballkontakt bis zum echten Mannschaftsspiel."
|
||||||
},
|
},
|
||||||
"groups": [
|
"groups": [
|
||||||
{ "code": "G", "name": "G-Jugend (Bambini)", "training": ["Freitag 16:30–17:30 Uhr"], "photo": { "base": "img/teams/jugend-g", "widths": [400, 800], "alt": "G-Jugend des TSV 08 Kulmbach" } },
|
{ "code": "G", "name": "G-Jugend (Bambini)", "training": ["Freitag 16:00–17:00 Uhr"], "photo": { "base": "img/teams/jugend-g", "widths": [400, 800], "alt": "G-Jugend des TSV 08 Kulmbach" } },
|
||||||
{ "code": "F", "name": "F-Jugend", "training": ["Montag 17:00–18:30 Uhr", "Freitag 16:30–17:30 Uhr"], "photo": { "base": "img/teams/jugend-f", "widths": [400, 800], "alt": "F-Jugend des TSV 08 Kulmbach" } },
|
{ "code": "F", "name": "F-Jugend", "training": ["Dienstag 16:30–17:40 Uhr", "Freitag 16:30–17:40 Uhr"], "photo": { "base": "img/teams/jugend-f", "widths": [400, 800], "alt": "F-Jugend des TSV 08 Kulmbach" } },
|
||||||
{ "code": "E", "name": "E-Jugend", "training": ["Montag 17:30–18:30 Uhr", "Mittwoch 17:30–19:00 Uhr"], "photo": { "base": "img/teams/jugend-e", "widths": [400, 800], "alt": "E-Jugend des TSV 08 Kulmbach" } },
|
{ "code": "E", "name": "E-Jugend", "training": ["Montag 18:15–19:30 Uhr", "Mittwoch 17:45–19:00 Uhr"], "photo": { "base": "img/teams/jugend-e", "widths": [400, 800], "alt": "E-Jugend des TSV 08 Kulmbach" } },
|
||||||
{ "code": "D", "name": "D-Jugend", "training": ["Dienstag 17:30–18:30 Uhr", "Freitag 17:30–18:30 Uhr"], "photo": { "base": "img/teams/jugend-d", "widths": [400, 800], "alt": "D-Jugend des TSV 08 Kulmbach" } },
|
{ "code": "D", "name": "D-Jugend", "training": ["Dienstag 17:40–19:00 Uhr", "Freitag 17:40–19:00 Uhr"], "photo": { "base": "img/teams/jugend-d", "widths": [400, 800], "alt": "D-Jugend des TSV 08 Kulmbach" } },
|
||||||
{ "code": "C", "name": "C-Jugend", "training": ["Montag 18:30–19:30 Uhr", "Freitag 17:30–19:00 Uhr"], "photo": { "base": "img/teams/jugend-c", "widths": [400, 800], "alt": "C-Jugend des TSV 08 Kulmbach" } },
|
{ "code": "C/B", "name": "C/B-Jugend", "training": ["Montag 17:00–18:00 Uhr", "Donnerstag 17:00–18:00 Uhr"], "photo": { "base": "img/teams/jugend-c", "widths": [400, 800], "alt": "C/B-Jugend des TSV 08 Kulmbach" } },
|
||||||
{ "code": "B", "name": "B-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } },
|
|
||||||
{ "code": "A", "name": "A-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } }
|
{ "code": "A", "name": "A-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } }
|
||||||
],
|
],
|
||||||
"faq": [
|
"faq": [
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
"children": [
|
"children": [
|
||||||
{ "label": "Übersicht", "slug": "turnen" },
|
{ "label": "Übersicht", "slug": "turnen" },
|
||||||
{ "label": "Erwachsenenturnen", "slug": "turnen/erwachsenenturnen" },
|
{ "label": "Erwachsenenturnen", "slug": "turnen/erwachsenenturnen" },
|
||||||
{ "label": "Eltern-Kind-Turnen", "slug": "turnen/eltern-kind-turnen" },
|
{ "label": "Eltern-Kind- & Kinderturnen", "slug": "turnen/eltern-kind-turnen" },
|
||||||
{ "label": "Step Aerobic, Fitness und Pilates", "slug": "turnen/step-aerobic-fitness-pilates" },
|
{ "label": "Fitness & Pilates", "slug": "turnen/fitness-pilates" },
|
||||||
{ "label": "Trampolin", "slug": "turnen/trampolin" },
|
{ "label": "Trampolinturnen", "slug": "turnen/trampolin" },
|
||||||
{ "label": "Wettkampfturnen", "slug": "turnen/wettkampfturnen" }
|
{ "label": "Wettkampfturnen", "slug": "turnen/wettkampfturnen" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
{
|
{
|
||||||
"typ": "text",
|
"typ": "text",
|
||||||
"absaetze": [
|
"absaetze": [
|
||||||
"Jede Turngruppe hat jetzt ihre eigene Seite: [Erwachsenenturnen](turnen/erwachsenenturnen), [Eltern-Kind-Turnen](turnen/eltern-kind-turnen), [Step Aerobic, Fitness und Pilates](turnen/step-aerobic-fitness-pilates), [Trampolin](turnen/trampolin) und [Wettkampfturnen](turnen/wettkampfturnen). Auf jeder stehen die Trainingszeiten, die Halle samt Weg dorthin und wer die Gruppe leitet.",
|
"Jede Turngruppe hat jetzt ihre eigene Seite: [Erwachsenenturnen](turnen/erwachsenenturnen), [Eltern-Kind-Turnen](turnen/eltern-kind-turnen), [Fitness und Pilates](turnen/fitness-pilates), [Trampolin](turnen/trampolin) und [Wettkampfturnen](turnen/wettkampfturnen). Auf jeder stehen die Trainingszeiten, die Halle samt Weg dorthin und wer die Gruppe leitet.",
|
||||||
"Fragen zum Turnen stellst du unten auf der [Turnseite](turnen): du sagst, welche Gruppe dich interessiert, und die Anfrage geht direkt an die Turnabteilung."
|
"Fragen zum Turnen stellst du unten auf der [Turnseite](turnen): du sagst, welche Gruppe dich interessiert, und die Anfrage geht direkt an die Turnabteilung."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
134
data/turnen.json
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"ctas": []
|
"ctas": []
|
||||||
},
|
},
|
||||||
"cards_intro": "Die Turnabteilung des TSV 08 Kulmbach bietet neben dem klassischen Gerätturnen sowohl Trampolinturnen als auch Step Aerobic, Fitness und Pilates an. Weitere Informationen zu den Gruppen findest du in den einzelnen Rubriken.",
|
"cards_intro": "Die Turnabteilung des TSV 08 Kulmbach bietet neben dem klassischen Gerätturnen sowohl Trampolinturnen als auch Fitness und Pilates an. Weitere Informationen zu den Gruppen findest du in den einzelnen Rubriken.",
|
||||||
"fees_title": "Mitgliedsbeiträge",
|
"fees_title": "Mitgliedsbeiträge",
|
||||||
"fees_intro": "Der Beitrag der Turnabteilung kommt zum allgemeinen TSV-Mitgliedsbeitrag hinzu. Trainiert dein Kind besonders viel, greift zusätzlich eine Förderstufe.",
|
"fees_intro": "Der Beitrag der Turnabteilung kommt zum allgemeinen TSV-Mitgliedsbeitrag hinzu. Trainiert dein Kind besonders viel, greift zusätzlich eine Förderstufe.",
|
||||||
"fees": [
|
"fees": [
|
||||||
@@ -45,29 +45,29 @@
|
|||||||
],
|
],
|
||||||
"austritt": {
|
"austritt": {
|
||||||
"title": "Austritt",
|
"title": "Austritt",
|
||||||
"text": "Der Austritt aus dem Verein ist nur zum Ende eines Kalenderjahres möglich und muss bis spätestens 30. September schriftlich erklärt werden. Der Beitrag ist in jedem Fall bis zum Jahresende zu entrichten."
|
"text": "Der Austritt aus dem Verein ist nur zum Ende eines Kalenderjahres möglich und muss bis spätestens 30. September schriftlich erklärt werden. Die Kündigung kannst du formlos per E-Mail an {mail} schicken. Der Beitrag ist in jedem Fall bis zum Jahresende zu entrichten."
|
||||||
},
|
},
|
||||||
"faq": [
|
"faq": [
|
||||||
{ "q": "Welche Turngruppen gibt es beim TSV 08 Kulmbach?", "a": "Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolinturnen sowie Wettkampfturnen, von den Jüngsten ab 2 Jahren bis zu Erwachsenen." },
|
{ "q": "Welche Turngruppen gibt es beim TSV 08 Kulmbach?", "a": "Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Fitness und Pilates, Trampolinturnen sowie Wettkampfturnen, von den Jüngsten ab 2 Jahren bis zu Erwachsenen." },
|
||||||
{ "q": "Ab welchem Alter kann mein Kind mitturnen?", "a": "Beim Eltern-Kind-Turnen starten die Jüngsten bereits ab etwa 2 Jahren gemeinsam mit ihren Eltern. Für ältere Kinder gibt es nach Alter gestaffelte Breitensportgruppen." },
|
{ "q": "Ab welchem Alter kann mein Kind mitturnen?", "a": "Beim Eltern-Kind-Turnen starten die Jüngsten bereits ab etwa 2 Jahren gemeinsam mit ihren Eltern. Für ältere Kinder gibt es nach Alter gestaffelte Breitensportgruppen." },
|
||||||
{ "q": "Wo finden die Turnstunden statt?", "a": "Die meisten Gruppen trainieren im Mäximum Gym (Thurnauer Str. 51, 95326 Kulmbach). Step Aerobic, Fitness und Pilates laufen in der Max-Hundt-Schule, das Trampolinturnen in der Dreifachturnhalle der Carl-von-Linde-Realschule." },
|
{ "q": "Wo finden die Turnstunden statt?", "a": "Die meisten Gruppen trainieren im Mäximum Gym (Thurnauer Str. 51, 95326 Kulmbach). Fitness und Pilates laufen in der Max-Hundt-Schule, das Trampolinturnen in der Dreifachturnhalle der Carl-von-Linde-Realschule." },
|
||||||
{ "q": "Was kostet die Turnabteilung?", "a": "Bestehende TSV-Mitglieder zahlen einen Zusatzbeitrag von 24,00 € (Einzelperson) bzw. 48,00 € pro Jahr (Familie). Neumitglieder zahlen 84,00 € (Kinder/Jugendliche), 94,00 € (Erwachsene) oder 184,00 € (Familie ab 3 Personen) im Jahr, inklusive Grundbeitrag, Verbandsabgaben und Sportunfall-Versicherung. Bei hohem Trainingsumfang kommt eine Förderstufe hinzu." },
|
{ "q": "Was kostet die Turnabteilung?", "a": "Bestehende TSV-Mitglieder zahlen einen Zusatzbeitrag von 24,00 € (Einzelperson) bzw. 48,00 € pro Jahr (Familie). Neumitglieder zahlen 84,00 € (Kinder/Jugendliche), 94,00 € (Erwachsene) oder 184,00 € (Familie ab 3 Personen) im Jahr, inklusive Grundbeitrag, Verbandsabgaben und Sportunfall-Versicherung. Bei hohem Trainingsumfang kommt eine Förderstufe hinzu." },
|
||||||
{ "q": "Kann ich vorher zum Probetraining kommen?", "a": "Ja. Schreib uns vorher über unser [Anfrageformular](turnen#kontakt), damit wir die aktuelle Platzverfügbarkeit prüfen können." }
|
{ "q": "Kann ich vorher zum Probetraining kommen?", "a": "Ja. Schreib uns vorher über unser [Anfrageformular](turnen#kontakt), damit wir die aktuelle Platzverfügbarkeit prüfen können." },
|
||||||
|
{
|
||||||
|
"q": "Wie melde ich mich oder mein Kind an?",
|
||||||
|
"a": "In zwei Schritten. Bei Interesse an einer aktiven Teilnahme schreib uns bitte zunächst über unser Anfrageformular, so prüfen wir die aktuelle Platzverfügbarkeit. Ist ein Platz frei, läuft die Anmeldung über den Aufnahmeantrag als PDF: herunterladen, ausfüllen und beim Übungsleiter abgeben.",
|
||||||
|
"ctas": [
|
||||||
|
{ "label": "Platz anfragen", "slug": "turnen", "hash": "kontakt", "style": "outline" },
|
||||||
|
{ "label": "Anmeldung Turnabteilung", "slug": "mitglied-werden", "hash": "anmeldung-turnen", "style": "primary" }
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Fragen zum Turnen?",
|
"title": "Fragen zum Turnen?",
|
||||||
"text": "Schreib direkt der Turnabteilung. Sag uns, welche Gruppe dich interessiert, dann prüfen wir die Platzverfügbarkeit und melden uns bei dir."
|
"text": "Schreib direkt der Turnabteilung. Sag uns, welche Gruppe dich interessiert, dann prüfen wir die Platzverfügbarkeit und melden uns bei dir."
|
||||||
},
|
|
||||||
"cta": {
|
|
||||||
"title": "Lust aufs Turnen bekommen?",
|
|
||||||
"text": "Bei Interesse an einer aktiven Teilnahme schreib uns bitte zunächst über unser Anfrageformular, so prüfen wir die aktuelle Platzverfügbarkeit.",
|
|
||||||
"ctas": [
|
|
||||||
{ "label": "Mitglied werden", "slug": "mitglied-werden#anmeldung-turnen", "style": "primary" },
|
|
||||||
{ "label": "Zur Turn-Übersicht", "slug": "turnen", "style": "outline" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footnote": "* So im Trainingsplan der Abteilung gekennzeichnet.",
|
"footnote": "* Übungsleiterinnen und Übungsleiter.",
|
||||||
"disciplines": {
|
"disciplines": {
|
||||||
"erwachsenenturnen": {
|
"erwachsenenturnen": {
|
||||||
"hero": {
|
"hero": {
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
"compact": true,
|
"compact": true,
|
||||||
"title": "Erwachsenenturnen",
|
"title": "Erwachsenenturnen",
|
||||||
"text": "Beweglich bleiben, Kraft und Koordination trainieren: Turnen für Erwachsene im Mäximum Gym.",
|
"text": "Beweglich bleiben, Kraft und Koordination trainieren: Turnen für Erwachsene im Mäximum Gym.",
|
||||||
"image": { "base": "img/pages/vereinsgelaende", "widths": [640, 1000, 1600], "alt": "Luftaufnahme unserer Hans-Rausch-Sportanlage im Katzbachtal" },
|
"image": { "base": "img/pages/turnen-erwachsenenturnen", "widths": [640, 1000, 1536], "alt": "Turnerinnen üben Gleichgewicht auf dem Schwebebalken (Symbolbild)" },
|
||||||
"ctas": []
|
"ctas": []
|
||||||
},
|
},
|
||||||
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
||||||
@@ -98,13 +98,14 @@
|
|||||||
"compact": true,
|
"compact": true,
|
||||||
"title": "Eltern-Kind- & Kinderturnen",
|
"title": "Eltern-Kind- & Kinderturnen",
|
||||||
"text": "Bei uns starten die Jüngsten bereits ab 2 Jahren gemeinsam mit ihren Eltern und turnen sich über die Breitensportgruppen Schritt für Schritt nach oben.",
|
"text": "Bei uns starten die Jüngsten bereits ab 2 Jahren gemeinsam mit ihren Eltern und turnen sich über die Breitensportgruppen Schritt für Schritt nach oben.",
|
||||||
"image": { "base": "img/pages/vereinsgelaende", "widths": [640, 1000, 1600], "alt": "Luftaufnahme unserer Hans-Rausch-Sportanlage im Katzbachtal" },
|
"image": { "base": "img/pages/turnen-eltern-kind", "widths": [640, 1000, 1536], "alt": "Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)" },
|
||||||
"ctas": []
|
"ctas": []
|
||||||
},
|
},
|
||||||
"layout": "blocks",
|
|
||||||
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
||||||
"lead": "Annelie Frisque",
|
"lead": "Annelie Frisque",
|
||||||
"notice": "Aktuell sind alle Kinderturngruppen belegt. Bei Interesse setzen wir sie gerne auf die Warteliste.",
|
"notice_tone": "stop",
|
||||||
|
"notice_title": "Hinweis zur Warteliste",
|
||||||
|
"notice": "Für die Gruppen „Tiger“ und „Hühner“ (ab 8 Jahre) können wir derzeit leider keine neuen Mitglieder aufnehmen und führen deshalb aktuell keine Warteliste. Die übrigen Kinderturngruppen sind ebenfalls belegt, dort nehmen wir dein Kind aber gerne auf die Warteliste.",
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"name": "Eltern-Kind-Turnen",
|
"name": "Eltern-Kind-Turnen",
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
"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": "Kängurus", "day": "Dienstag", "time": "16:30–17:15 Uhr", "trainers": ["Carina Schleicher*"] },
|
{ "label": "Kängurus", "day": "Dienstag", "time": "16:30–17:15 Uhr", "trainers": ["Carina Schleicher*"] },
|
||||||
{ "label": "Grashüpfer", "day": "Donnerstag", "time": "15:30–16:15 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch"] }
|
{ "label": "Grashüpfer", "day": "Donnerstag", "time": "15:30–16:15 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch*"] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,31 +150,16 @@
|
|||||||
{ "label": "Tiger · 8–10 Jahre", "day": "Dienstag", "time": "18:30–19:30 Uhr", "note": "in geraden Kalenderwochen" },
|
{ "label": "Tiger · 8–10 Jahre", "day": "Dienstag", "time": "18:30–19:30 Uhr", "note": "in geraden Kalenderwochen" },
|
||||||
{ "label": "Hühner · ab 11 Jahre", "day": "Dienstag", "time": "19:30–20:30 Uhr", "note": "in geraden Kalenderwochen" }
|
{ "label": "Hühner · ab 11 Jahre", "day": "Dienstag", "time": "19:30–20:30 Uhr", "note": "in geraden Kalenderwochen" }
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Flamingos",
|
|
||||||
"level": "Turnen · fortgeschritten · Alter nach Absprache",
|
|
||||||
"intro": "In unserer Gruppe für fortgeschrittenes Turnen bauen wir auf bereits erlernten turnerischen Fähigkeiten auf. Das Training orientiert sich an den Anforderungen der LK3 (Kür modifiziert, Leistungsklasse 3).",
|
|
||||||
"details": [
|
|
||||||
{ "heading": "Trainingsinhalte", "text": "Je nach Voraussetzung erlernen wir Elemente wie Radwende, Flick-Flack und Salto (Boden), Kippen und Felge vorlings (Barren), Überschläge (Sprung) sowie Räder und Bogengänge (Balken)." },
|
|
||||||
{ "heading": "Wettkämpfe & Teamgeist", "text": "Wir nehmen vorzugsweise an Mannschaftswettkämpfen teil, um den Teamgeist zu fördern und die individuelle Leistung als Teil eines gemeinsamen Erfolgs zu erleben." }
|
|
||||||
],
|
|
||||||
"recommendation": "Eine Teilnahme erfolgt ausschließlich auf Empfehlung und Einladung durch unser Trainerteam. Wir gleichen das Leistungsniveau regelmäßig ab, um für jedes Kind die optimale Gruppe zu finden.",
|
|
||||||
"trainers": ["Christa Knarr*", "Maxi Roßberg*", "Annelie Frisque*", "Theresa Diener", "Lea Schramm", "Charlyn Simon", "Lia Buchwald"],
|
|
||||||
"sessions": [
|
|
||||||
{ "day": "Montag", "time": "18:00–19:00 Uhr" },
|
|
||||||
{ "day": "Mittwoch", "time": "18:00–20:00 Uhr" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"step-aerobic-fitness-pilates": {
|
"fitness-pilates": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"align": "center",
|
"align": "center",
|
||||||
"compact": true,
|
"compact": true,
|
||||||
"title": "Step Aerobic, Fitness & Pilates",
|
"title": "Fitness & Pilates",
|
||||||
"text": "Ausdauer, Kräftigung und Beweglichkeit für Erwachsene, in der Max-Hundt-Schule.",
|
"text": "Ausdauer, Kräftigung und Beweglichkeit für Erwachsene, in der Max-Hundt-Schule.",
|
||||||
"image": { "base": "img/pages/vereinsgelaende", "widths": [640, 1000, 1600], "alt": "Luftaufnahme unserer Hans-Rausch-Sportanlage im Katzbachtal" },
|
"image": { "base": "img/pages/turnen-fitness-pilates", "widths": [640, 1000, 1536], "alt": "Gruppe trainiert mit Kniebeugen auf Matten im Sportraum (Symbolbild)" },
|
||||||
"ctas": []
|
"ctas": []
|
||||||
},
|
},
|
||||||
"location": { "venue": "Max-Hundt-Schule", "city": "Kulmbach" },
|
"location": { "venue": "Max-Hundt-Schule", "city": "Kulmbach" },
|
||||||
@@ -181,18 +167,33 @@
|
|||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"name": "Pilates",
|
"name": "Pilates",
|
||||||
|
"intro": "Pilates stärkt den Körper von innen heraus und verbindet Kraft, Beweglichkeit und Körperbewusstsein. Im Mittelpunkt stehen die klassischen Pilates-Prinzipien: bewusste Atmung, Kontrolle, Konzentration, Präzision, Bewegungsfluss und Zentrierung.",
|
||||||
|
"details": [
|
||||||
|
{ "heading": "Was dich erwartet", "text": "Durch kontrollierte, fließende Bewegungen kräftigen wir vor allem die tiefliegende Rumpfmuskulatur, verbessern Stabilität, Koordination und Haltung und fördern eine bewusste Körperwahrnehmung. Klassische Übungen werden dabei gezielt durch moderne und abwechslungsreiche Varianten ergänzt." },
|
||||||
|
{ "heading": "Dein Ziel", "text": "Mehr Kraft aus der Körpermitte, bessere Bewegungsqualität und ein wohltuender Ausgleich zum Alltag." }
|
||||||
|
],
|
||||||
"sessions": [ { "day": "Mittwoch", "time": "17:30–18:30 Uhr" } ],
|
"sessions": [ { "day": "Mittwoch", "time": "17:30–18:30 Uhr" } ],
|
||||||
"trainers": ["Karola Lorz"]
|
"trainers": ["Karola Lorz"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Fitness & Stretching",
|
"name": "Functional Fitness I",
|
||||||
|
"intro": "Wir starten mit einem lockeren Warm-up aus einfachen Schrittkombinationen, das Muskeln und Gelenke mobilisiert und den Körper auf das Training vorbereitet.",
|
||||||
|
"details": [
|
||||||
|
{ "heading": "Was dich erwartet", "text": "Danach erwartet dich ein vielseitiges Ganzkörper-Workout mit abwechslungsreichen Übungen, Kleingeräten und instabilen Unterlagen. Dabei trainieren wir nicht nur Kraft, sondern auch Tiefenmuskulatur, Stabilität, Koordination und Balance." },
|
||||||
|
{ "heading": "Dein Ziel", "text": "Den Körper rundum stärken, beweglich bleiben und langfristig fit und belastbar durchs Leben gehen. Die Übungen lassen sich an unterschiedliche Trainingslevel anpassen." }
|
||||||
|
],
|
||||||
"sessions": [ { "day": "Mittwoch", "time": "18:45–19:45 Uhr" } ],
|
"sessions": [ { "day": "Mittwoch", "time": "18:45–19:45 Uhr" } ],
|
||||||
"trainers": ["Karola Lorz"]
|
"trainers": ["Karola Lorz"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Step-Aerobic",
|
"name": "Functional Fitness II",
|
||||||
"sessions": [ { "day": "Donnerstag", "time": "19:30–20:30 Uhr" } ],
|
"intro": "Jede Stunde ist anders, aber das Ziel bleibt gleich: ein starker, beweglicher und belastbarer Körper für deinen Alltag.",
|
||||||
"trainers": ["Heike Wahler"]
|
"details": [
|
||||||
|
{ "heading": "Was dich erwartet", "text": "Dich erwartet ein abwechslungsreicher Mix aus Pilates, Mobility, funktionellem Krafttraining und Bodyweight-Übungen. Je nach Schwerpunkt kommen auch Kleingeräte wie Kurzhanteln oder Pilates-Bälle zum Einsatz." },
|
||||||
|
{ "heading": "Im Mittelpunkt", "text": "Stabilität, Balance und absolute Körperkontrolle. Alle Übungen lassen sich flexibel an dein individuelles Trainingslevel anpassen, für mehr Kraft, Beweglichkeit und ein rundum gutes Körpergefühl." }
|
||||||
|
],
|
||||||
|
"sessions": [ { "day": "Donnerstag", "time": "abends", "note": "Die genaue Uhrzeit geben wir in Kürze bekannt." } ],
|
||||||
|
"trainers": ["Karola Lorz", "Eva Wünsch"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -207,15 +208,30 @@
|
|||||||
},
|
},
|
||||||
"location": { "venue": "Dreifachturnhalle der Carl-von-Linde-Realschule", "city": "Kulmbach" },
|
"location": { "venue": "Dreifachturnhalle der Carl-von-Linde-Realschule", "city": "Kulmbach" },
|
||||||
"lead": "Tina Kornatz",
|
"lead": "Tina Kornatz",
|
||||||
|
"contact_email": "tina.kornatz@gmx.de",
|
||||||
|
"contact_phone": "0163 6032410",
|
||||||
|
"footnote": "In den Schulferien findet kein Training statt.",
|
||||||
|
"notice_icon": "calendar-event",
|
||||||
|
"notice_title": "Schnuppern nach Absprache",
|
||||||
|
"notice": "Schnuppertraining ist bei uns jederzeit nach vorheriger Absprache möglich. Melde dich davor einfach kurz bei Tina, dann finden wir einen passenden Termin.",
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"name": "Trampolinturnen",
|
"name": "Kinder und Erwachsene",
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{ "day": "Montag", "time": "17:30 / 18:00–20:30 Uhr" },
|
{ "day": "Montag", "time": "17:30–19:30 / 20:30 Uhr", "trainers": ["Sabine Hofmann"] }
|
||||||
{ "day": "Donnerstag", "time": "17:30 / 18:00–20:30 Uhr" }
|
]
|
||||||
],
|
},
|
||||||
"note": "Der Einstieg erfolgt gestaffelt um 17:30 bzw. 18:00 Uhr.",
|
{
|
||||||
"trainers": []
|
"name": "Kinder ab 5 Jahren",
|
||||||
|
"sessions": [
|
||||||
|
{ "day": "Donnerstag", "time": "17:30–19:00 Uhr", "trainers": ["Michaela Bollingerfehr"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kinder ab 10 Jahren und Fortgeschrittene",
|
||||||
|
"sessions": [
|
||||||
|
{ "day": "Donnerstag", "time": "18:00–20:30 Uhr", "trainers": ["Tina Kornatz", "Laura Ramming", "Christa Ramming"] }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -229,18 +245,34 @@
|
|||||||
"ctas": []
|
"ctas": []
|
||||||
},
|
},
|
||||||
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
||||||
"lead": "Franziska Trotzke",
|
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"name": "Wettkampfturnen",
|
"name": "Flamingos",
|
||||||
|
"level": "Turnen · fortgeschritten",
|
||||||
|
"contact": "Theresa Diener",
|
||||||
|
"intro": "In unserer Gruppe für fortgeschrittenes Turnen bauen wir auf bereits erlernten turnerischen Fähigkeiten auf und entwickeln diese gezielt weiter. Das Training orientiert sich an den Anforderungen der LK3 (Kür modifiziert, Leistungsklasse 3). Trotz des höheren Leistungsniveaus stehen der Spaß am Sport und persönliche Erfolgserlebnisse durch das Erlernen neuer Elemente stets im Vordergrund.",
|
||||||
|
"details": [
|
||||||
|
{ "heading": "Trainingsinhalte", "text": "Je nach individuellen Voraussetzungen erlernen und trainieren wir Elemente wie Radwende, Flick-Flack und Salto (Boden), Kippen und Felge vorlings (Barren), Überschläge (Sprung) sowie Räder, Bogengänge und verschiedene Sprünge am Balken." },
|
||||||
|
{ "heading": "Wettkämpfe & Teamgeist", "text": "Gelegentlich nehmen wir mit einem Teil unserer Turnerinnen an Wettkämpfen teil. Dabei bevorzugen wir Mannschaftswettkämpfe, um den Teamgeist zu stärken und die individuelle Leistung als Teil eines gemeinsamen Erfolgs zu erleben." }
|
||||||
|
],
|
||||||
|
"recommendation": "Eine Teilnahme erfolgt ausschließlich auf Empfehlung und Einladung durch unser Trainerteam. Um allen Turnerinnen ein passendes Trainingsumfeld zu bieten, gleichen wir das Leistungsniveau regelmäßig ab und sprechen bei Bedarf Empfehlungen für eine andere Trainingsgruppe aus.",
|
||||||
|
"trainers": ["Theresa Diener", "Maxi Roßberg*", "Christa Knarr*", "Annelie Frisque*", "Lea Schramm", "Charlyn Simon", "Lia Buchwald"],
|
||||||
|
"sessions": [
|
||||||
|
{ "day": "Montag", "time": "18:00–19:00 Uhr" },
|
||||||
|
{ "day": "Montag", "time": "19:00–20:00 Uhr" },
|
||||||
|
{ "day": "Mittwoch", "time": "18:00–20:00 Uhr" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Leistungsturnen",
|
||||||
|
"contact": "Franziska Trotzke",
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{ "day": "Montag", "time": "15:00–18:00 Uhr" },
|
{ "day": "Montag", "time": "15:00–18:00 Uhr" },
|
||||||
{ "day": "Mittwoch", "time": "15:00–18:00 Uhr" },
|
{ "day": "Mittwoch", "time": "15:00–18:00 Uhr" },
|
||||||
{ "day": "Donnerstag", "time": "16:30–18:30 Uhr" },
|
{ "day": "Donnerstag", "time": "16:30–18:30 Uhr" },
|
||||||
{ "day": "Freitag", "time": "14:00–17:00 Uhr" },
|
{ "day": "Freitag", "time": "14:00–17:00 Uhr" },
|
||||||
{ "day": "Samstag", "time": "09:00–13:00 Uhr" }
|
{ "day": "Samstag", "time": "09:00–13:00 Uhr" }
|
||||||
],
|
]
|
||||||
"trainers": []
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
{
|
{
|
||||||
"veranstaltungen": [
|
"veranstaltungen": [
|
||||||
|
{
|
||||||
|
"slug": "tagesskifahrt-2027",
|
||||||
|
"title": "Tagesskifahrt 2027",
|
||||||
|
"date_start": "2027-02-20",
|
||||||
|
"date_end": null,
|
||||||
|
"time": "",
|
||||||
|
"location": "Skigebiet Hochzillertal",
|
||||||
|
"department": "verein",
|
||||||
|
"status": "veroeffentlicht",
|
||||||
|
"text": "Wir fahren für einen Tag zum Skifahren ins Skigebiet Hochzillertal. Die Plätze sind auf 40 Personen begrenzt, angemeldet wird bei Fabian Früh.",
|
||||||
|
"ankuendigung": [
|
||||||
|
"Einmal im Winter machen wir uns gemeinsam auf und fahren für einen Tag ins Skigebiet Hochzillertal bei Kaltenbach. Ob Erwachsene, Jugendliche oder Kinder: Auf den Pisten ist für alle etwas dabei. Die Fahrt ist auf 40 Personen begrenzt, wer dabei sein möchte, meldet sich am besten früh.",
|
||||||
|
"Erwachsene zahlen 115 Euro, Jugendliche 95 Euro und Kinder 70 Euro. Anmelden könnt ihr euch bei Fabian Früh unter 0160 97516797."
|
||||||
|
],
|
||||||
|
"rueckblick": null,
|
||||||
|
"karte": "",
|
||||||
|
"anfahrt": [],
|
||||||
|
"plakate": [
|
||||||
|
{
|
||||||
|
"base": "img/veranstaltungen/tagesskifahrt-2027/plakat-01",
|
||||||
|
"widths": [
|
||||||
|
640,
|
||||||
|
1000,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"alt": "Plakat zu unserer Tagesskifahrt am Samstag, 20. Februar 2027 ins Skigebiet Hochzillertal, mit den Preisen für Erwachsene, Jugendliche und Kinder und der Anmeldung bei Fabian Früh.",
|
||||||
|
"source": "1-ankuendigung.jpeg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"galerie": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "sportfest-2026",
|
"slug": "sportfest-2026",
|
||||||
"title": "Sportfest 2026",
|
"title": "Sportfest 2026",
|
||||||
@@ -14,6 +45,301 @@
|
|||||||
"rueckblick": null,
|
"rueckblick": null,
|
||||||
"karte": "club",
|
"karte": "club",
|
||||||
"anfahrt": [],
|
"anfahrt": [],
|
||||||
|
"programm": [
|
||||||
|
{
|
||||||
|
"tag": "2026-09-04",
|
||||||
|
"punkte": [
|
||||||
|
{
|
||||||
|
"zeit": "16:30",
|
||||||
|
"titel": "Spiel unserer E2-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "18:00",
|
||||||
|
"titel": "Unsere erste Mannschaft gegen ATS Wartenfels",
|
||||||
|
"text": "Kreisklasse"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "2026-09-05",
|
||||||
|
"punkte": [
|
||||||
|
{
|
||||||
|
"zeit": "09:00",
|
||||||
|
"titel": "Turnier der G-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "12:00",
|
||||||
|
"titel": "Turnier der F1-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "12:00",
|
||||||
|
"titel": "Turnier der F2-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "14:30",
|
||||||
|
"titel": "Kinderolympiade",
|
||||||
|
"text": "bis 16:30 Uhr in der Turnhalle Maximum Gym"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "15:00",
|
||||||
|
"titel": "Turnier der D1-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "18:00",
|
||||||
|
"titel": "Unsere zweite Mannschaft gegen SV Weidenberg III/SSV Warmensteinach II",
|
||||||
|
"text": "Reserve"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "2026-09-06",
|
||||||
|
"punkte": [
|
||||||
|
{
|
||||||
|
"zeit": "11:00",
|
||||||
|
"titel": "Unsere Frauenmannschaft gegen 1. FC 1928 Schwarzenbach/Saale",
|
||||||
|
"text": "Kreisliga Ost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "13:00",
|
||||||
|
"titel": "Turnier der E1-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "15:00",
|
||||||
|
"titel": "Spiel unserer D2-Jugend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zeit": "16:30",
|
||||||
|
"titel": "Spiel unserer C/B-Jugend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"turniere": [
|
||||||
|
{
|
||||||
|
"key": "f1",
|
||||||
|
"title": "F1-Jugend",
|
||||||
|
"date": "2026-09-05",
|
||||||
|
"start": "12:00",
|
||||||
|
"spieldauer": 10,
|
||||||
|
"modus": "Jeder gegen jeden mit Hin und Rückspiel",
|
||||||
|
"teams": [
|
||||||
|
"VfR Katschenreuth 1",
|
||||||
|
"SV Motschenbach 1",
|
||||||
|
"TSV Neudrossenfeld 1"
|
||||||
|
],
|
||||||
|
"spiele": [
|
||||||
|
{
|
||||||
|
"nr": 1,
|
||||||
|
"zeit": "12:00",
|
||||||
|
"heim": "SV Motschenbach 1",
|
||||||
|
"gast": "TSV Neudrossenfeld 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 2,
|
||||||
|
"zeit": "12:15",
|
||||||
|
"heim": "VfR Katschenreuth 1",
|
||||||
|
"gast": "SV Motschenbach 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 3,
|
||||||
|
"zeit": "12:30",
|
||||||
|
"heim": "TSV Neudrossenfeld 1",
|
||||||
|
"gast": "VfR Katschenreuth 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 4,
|
||||||
|
"zeit": "12:45",
|
||||||
|
"heim": "TSV Neudrossenfeld 1",
|
||||||
|
"gast": "SV Motschenbach 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 5,
|
||||||
|
"zeit": "13:00",
|
||||||
|
"heim": "SV Motschenbach 1",
|
||||||
|
"gast": "VfR Katschenreuth 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 6,
|
||||||
|
"zeit": "13:15",
|
||||||
|
"heim": "VfR Katschenreuth 1",
|
||||||
|
"gast": "TSV Neudrossenfeld 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "f2",
|
||||||
|
"title": "F2-Jugend",
|
||||||
|
"date": "2026-09-05",
|
||||||
|
"start": "12:00",
|
||||||
|
"spieldauer": 15,
|
||||||
|
"modus": "Jeder gegen jeden",
|
||||||
|
"teams": [
|
||||||
|
"TSV 08 Kulmbach",
|
||||||
|
"SG Neuenmarkt",
|
||||||
|
"VfR Katschenreuth 2",
|
||||||
|
"SV Motschenbach 2"
|
||||||
|
],
|
||||||
|
"spiele": [
|
||||||
|
{
|
||||||
|
"nr": 1,
|
||||||
|
"zeit": "12:00",
|
||||||
|
"heim": "SG Neuenmarkt",
|
||||||
|
"gast": "SV Motschenbach 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 2,
|
||||||
|
"zeit": "12:16",
|
||||||
|
"heim": "TSV 08 Kulmbach",
|
||||||
|
"gast": "VfR Katschenreuth 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 3,
|
||||||
|
"zeit": "12:32",
|
||||||
|
"heim": "SG Neuenmarkt",
|
||||||
|
"gast": "TSV 08 Kulmbach"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 4,
|
||||||
|
"zeit": "12:48",
|
||||||
|
"heim": "VfR Katschenreuth 2",
|
||||||
|
"gast": "SV Motschenbach 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 5,
|
||||||
|
"zeit": "13:04",
|
||||||
|
"heim": "SV Motschenbach 2",
|
||||||
|
"gast": "TSV 08 Kulmbach"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 6,
|
||||||
|
"zeit": "13:20",
|
||||||
|
"heim": "VfR Katschenreuth 2",
|
||||||
|
"gast": "SG Neuenmarkt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d1",
|
||||||
|
"title": "D1-Jugend",
|
||||||
|
"date": "2026-09-05",
|
||||||
|
"start": "15:00",
|
||||||
|
"spieldauer": 35,
|
||||||
|
"modus": "Jeder gegen jeden",
|
||||||
|
"teams": [
|
||||||
|
"TSV 08 Kulmbach",
|
||||||
|
"JFG Maintal/Friesenbachtal",
|
||||||
|
"VfB Kulmbach"
|
||||||
|
],
|
||||||
|
"spiele": [
|
||||||
|
{
|
||||||
|
"nr": 1,
|
||||||
|
"zeit": "15:00",
|
||||||
|
"heim": "TSV 08 Kulmbach",
|
||||||
|
"gast": "JFG Maintal/Friesenbachtal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 2,
|
||||||
|
"zeit": "15:45",
|
||||||
|
"heim": "JFG Maintal/Friesenbachtal",
|
||||||
|
"gast": "VfB Kulmbach"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 3,
|
||||||
|
"zeit": "16:30",
|
||||||
|
"heim": "VfB Kulmbach",
|
||||||
|
"gast": "TSV 08 Kulmbach"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "e1",
|
||||||
|
"title": "E1-Jugend",
|
||||||
|
"date": "2026-09-06",
|
||||||
|
"start": "13:00",
|
||||||
|
"spieldauer": 12,
|
||||||
|
"felder": 2,
|
||||||
|
"modus": "Jeder gegen jeden",
|
||||||
|
"teams": [
|
||||||
|
"TSV 08 Kulmbach",
|
||||||
|
"SV Motschenbach 2",
|
||||||
|
"VfB Kulmbach 2",
|
||||||
|
"VfR Katschenreuth 2",
|
||||||
|
"SG Wirsberg"
|
||||||
|
],
|
||||||
|
"spiele": [
|
||||||
|
{
|
||||||
|
"nr": 1,
|
||||||
|
"zeit": "13:00",
|
||||||
|
"feld": 1,
|
||||||
|
"heim": "TSV 08 Kulmbach",
|
||||||
|
"gast": "SG Wirsberg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 2,
|
||||||
|
"zeit": "13:00",
|
||||||
|
"feld": 2,
|
||||||
|
"heim": "SV Motschenbach 2",
|
||||||
|
"gast": "VfB Kulmbach 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 3,
|
||||||
|
"zeit": "13:16",
|
||||||
|
"feld": 1,
|
||||||
|
"heim": "VfR Katschenreuth 2",
|
||||||
|
"gast": "TSV 08 Kulmbach"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 4,
|
||||||
|
"zeit": "13:16",
|
||||||
|
"feld": 2,
|
||||||
|
"heim": "VfB Kulmbach 2",
|
||||||
|
"gast": "SG Wirsberg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 5,
|
||||||
|
"zeit": "13:32",
|
||||||
|
"feld": 1,
|
||||||
|
"heim": "VfR Katschenreuth 2",
|
||||||
|
"gast": "SV Motschenbach 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 6,
|
||||||
|
"zeit": "13:32",
|
||||||
|
"feld": 2,
|
||||||
|
"heim": "VfB Kulmbach 2",
|
||||||
|
"gast": "TSV 08 Kulmbach"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 7,
|
||||||
|
"zeit": "13:48",
|
||||||
|
"feld": 1,
|
||||||
|
"heim": "SG Wirsberg",
|
||||||
|
"gast": "VfR Katschenreuth 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 8,
|
||||||
|
"zeit": "13:48",
|
||||||
|
"feld": 2,
|
||||||
|
"heim": "TSV 08 Kulmbach",
|
||||||
|
"gast": "SV Motschenbach 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 9,
|
||||||
|
"zeit": "14:04",
|
||||||
|
"feld": 1,
|
||||||
|
"heim": "VfR Katschenreuth 2",
|
||||||
|
"gast": "VfB Kulmbach 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nr": 10,
|
||||||
|
"zeit": "14:04",
|
||||||
|
"feld": 2,
|
||||||
|
"heim": "SV Motschenbach 2",
|
||||||
|
"gast": "SG Wirsberg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"plakate": [
|
"plakate": [
|
||||||
{
|
{
|
||||||
"base": "img/veranstaltungen/sportfest-2026/plakat-01",
|
"base": "img/veranstaltungen/sportfest-2026/plakat-01",
|
||||||
|
|||||||
388
docs/deploy.md
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# Deploy auf All-Inkl (Shared Hosting, SSH + Cronjobs)
|
||||||
|
|
||||||
|
Schritt für Schritt vom leeren Webspace zur Live-Seite. Die Reihenfolge ist Absicht:
|
||||||
|
**die alte CMS-Seite bleibt online, bis die neue auf einer geschützten Test-Subdomain
|
||||||
|
nachweislich läuft.** Umgeschaltet wird erst am Ende, und das Umschalten ist in einer
|
||||||
|
Minute rückgängig.
|
||||||
|
|
||||||
|
Der Kern des Plans: die Test-Subdomain läuft mit **derselben** `config.php`, die
|
||||||
|
später live geht. Es gibt keinen Config-Wechsel beim Umschalten, also auch keinen
|
||||||
|
Fehler, der sich dabei einschleichen kann. Möglich ist das, weil die
|
||||||
|
Host-Weiterleitung in `public/.htaccess` nur beim exakten Host `tsv08kulmbach.de`
|
||||||
|
greift; eine Subdomain läuft unbehelligt durch. Einzige Abweichung: Canonical, OG
|
||||||
|
und Sitemap nennen auf der Test-Subdomain schon die echte Domain. Genau richtig für
|
||||||
|
den Umschaltmoment, und dank Verzeichnisschutz sieht es ohnehin keine Suchmaschine.
|
||||||
|
|
||||||
|
Legende: `[lokal]` auf deinem Mac, `[SSH]` in der Server-Shell, `[KAS]` im
|
||||||
|
All-Inkl-Kundenmenü.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A — Vorbereitung lokal
|
||||||
|
|
||||||
|
### A1 [lokal] Die zwei offenen Werte in `config/config.production.php` setzen
|
||||||
|
|
||||||
|
`php bin/preflight.php` bricht sonst auf dem Server ab. Beides betrifft nur die
|
||||||
|
Dateiablage `/dateien`, nicht die Website selbst.
|
||||||
|
|
||||||
|
**Passwörter für die zwei Zugänge.** Als Hash, nicht als Klartext:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -r 'echo password_hash("DEIN-PASSWORT", PASSWORD_DEFAULT), "\n";'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgabe (beginnt mit `$2y$…`) in `dateien.users.verein.password_hash` bzw.
|
||||||
|
`dateien.users.admin.password_hash` eintragen. Zwei verschiedene Passwörter: `verein`
|
||||||
|
gibt der Verein weiter, `admin` behältst du.
|
||||||
|
|
||||||
|
**Lizenzschlüssel** der Files Gallery in `dateien.license_key`. Fehlt er, läuft die
|
||||||
|
Ablage trotzdem, zeigt aber den Hinweis der unlizenzierten Version (nur eine Warnung
|
||||||
|
in `preflight`, kein Blocker).
|
||||||
|
|
||||||
|
Bereits erledigt: `cron.key` ist auf `''` gesetzt und der Endpoint `public/cron.php`
|
||||||
|
damit zu. Der Takt kommt ab jetzt aus echten Cronjobs (Teil D), der Endpoint war nur
|
||||||
|
der Ersatz für den fehlenden Cron.
|
||||||
|
|
||||||
|
### A2 [lokal] Alles gepusht?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --porcelain # muss leer sein
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil B — Server einrichten
|
||||||
|
|
||||||
|
### B1 [SSH] Umgebung feststellen und notieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh ssh-w01ca75d@SERVER # SERVER = Hostname oder IP aus dem KAS
|
||||||
|
pwd # das Verzeichnis, in dem SSH startet
|
||||||
|
which php; php -v # PHP muss >= 8.1 sein
|
||||||
|
git --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ergebnis vom 31.07.2026 auf diesem Webspace:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Webspace-Wurzel | `/www/htdocs/w01ca75d` |
|
||||||
|
| SITE | `/www/htdocs/w01ca75d/tsv08kulmbach-website` |
|
||||||
|
| PHP (CLI, für Cron) | `/usr/bin/php` — 8.3.29 |
|
||||||
|
| Composer | 2.9.8, global vorhanden |
|
||||||
|
| Alte CMS-Seite (Rückweg) | `/www/htdocs/w01ca75d/tsv08kulmbach.de` |
|
||||||
|
|
||||||
|
**Falle: `~` ist hier `/`, nicht der Webspace.** Das SSH-Konto hat die Systemwurzel
|
||||||
|
als Home-Verzeichnis, `cd ~` führt also aus dem Webspace heraus und `~/…`-Pfade
|
||||||
|
existieren nicht. Alle Pfade in dieser Anleitung sind deshalb absolut — auch das
|
||||||
|
`scp`-Ziel und die Cron-Zeilen.
|
||||||
|
|
||||||
|
All-Inkl legt pro Domain einen Ordner unter `/www/htdocs/<user>/` an, dieses
|
||||||
|
Verzeichnis selbst ist also kein Docroot. Der Ordnername `tsv08kulmbach.de` ist von der alten Seite belegt, deshalb
|
||||||
|
heißt der neue `tsv08kulmbach-website`. Die anderen TSV-Ordner
|
||||||
|
(`2024.`, `hall-of-fame.`, `kabinendienst.`, `kreisliga-reise.`) sind eigene
|
||||||
|
Subdomains und vom Umschalten nicht betroffen.
|
||||||
|
|
||||||
|
Achtung: das ist die **CLI**-Version. Welches PHP der Webserver nimmt, stellt KAS pro
|
||||||
|
Domain getrennt ein (Schritt C1).
|
||||||
|
|
||||||
|
### B2 [SSH] Repo klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/htdocs/w01ca75d
|
||||||
|
git clone https://onion.breadcrumb-online.de/fs/tsv08kulmbach-website.git tsv08kulmbach-website
|
||||||
|
cd tsv08kulmbach-website && pwd # diesen Pfad notieren: das ist SITE
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Klon lädt gut 460 MB (Website plus das Vereinsmaterial der Dateiablage), das
|
||||||
|
dauert ein paar Minuten. Die alte CMS-Seite liegt in einem anderen Verzeichnis und
|
||||||
|
wird nicht angefasst.
|
||||||
|
|
||||||
|
### B3 [SSH] `config.php` platzieren
|
||||||
|
|
||||||
|
Die Datei ist gitignored, also nicht im Klon. Vom Mac hochladen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# [lokal], in einem zweiten Terminal.
|
||||||
|
# SERVER = derselbe Host wie beim ssh-Login (hier die Server-IP des Webspace).
|
||||||
|
scp config/config.production.php \
|
||||||
|
ssh-w01ca75d@SERVER:/www/htdocs/w01ca75d/tsv08kulmbach-website/config/config.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Platzhalter bewusst ohne spitze Klammern: `<host>` liest die zsh als
|
||||||
|
Eingabe-Umleitung und bricht mit „no such file or directory" ab.
|
||||||
|
|
||||||
|
Wichtig: Ziel heißt `config.php`, nicht `config.production.php`. Danach [SSH]:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 config/config.php # enthält SMTP-Passwort und app_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### B4 [SSH] Composer-Abhängigkeiten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
```
|
||||||
|
|
||||||
|
Kennt die Shell `composer` nicht, hol ihn dir lokal ins Verzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://getcomposer.org/installer | php
|
||||||
|
php composer.phar install --no-dev --optimize-autoloader
|
||||||
|
rm composer.phar
|
||||||
|
```
|
||||||
|
|
||||||
|
Es wird nur PHPMailer installiert.
|
||||||
|
|
||||||
|
### B4b [SSH] Git-Identität für den Server setzen
|
||||||
|
|
||||||
|
Ohne sie bricht **jeder** Commit auf dem Server ab („Author identity unknown"),
|
||||||
|
und ohne Commits funktioniert der Update-Ablauf aus F1 nicht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
|
||||||
|
git config --local user.name "TSV 08 Server"
|
||||||
|
git config --local user.email "fs@breadcrumb-solutions.de"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--local` gilt nur für dieses Repo — die anderen Projekte auf dem Webspace bleiben
|
||||||
|
unberührt. Der Name macht in `git log` auf einen Blick sichtbar, welche Commits vom
|
||||||
|
Server stammen (Uploads, Sync-Stände) und welche vom Mac (Code, Inhalte).
|
||||||
|
|
||||||
|
Wird ein Push mit `! [rejected] … (fetch first)` abgewiesen, hat der Mac inzwischen
|
||||||
|
gepusht: `git pull --rebase origin main` und nochmal pushen. Rebase statt Merge, damit
|
||||||
|
die Historie linear bleibt.
|
||||||
|
|
||||||
|
### B5 [SSH] Schreibrechte prüfen
|
||||||
|
|
||||||
|
PHP läuft unter deinem Benutzer, meist passt es also schon. `preflight` sagt es dir
|
||||||
|
in Teil C4 verbindlich. Falls dort etwas fehlt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod -R u+rwX storage
|
||||||
|
```
|
||||||
|
|
||||||
|
Schreiben muss die Seite in `storage/logs`, `storage/cache`, `storage/ratelimit`,
|
||||||
|
`storage/dateien/uploads` und `storage/dateien/system`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil C — Auf der Test-Subdomain prüfen
|
||||||
|
|
||||||
|
### C1 [KAS] Subdomain anlegen und schützen
|
||||||
|
|
||||||
|
1. **Domains** → Subdomain `neu.tsv08kulmbach.de` anlegen.
|
||||||
|
2. Als Verzeichnis **`/tsv08kulmbach-website/public`** eintragen, nicht das Repo-Root.
|
||||||
|
Damit liegen `app/`, `config/`, `data/`, `storage/` und `vendor/` außerhalb des
|
||||||
|
Docroots und sind grundsätzlich nicht per URL erreichbar. (Die Root-`.htaccess`
|
||||||
|
ist nur ein Notnagel für Hoster, die das nicht können.)
|
||||||
|
3. **PHP-Version** für die Subdomain auf 8.1 oder neuer stellen.
|
||||||
|
4. **SSL** aktivieren (Let's Encrypt). Ohne Zertifikat läuft die Seite in eine
|
||||||
|
Endlosweiterleitung, denn `public/.htaccess` erzwingt HTTPS.
|
||||||
|
5. **Verzeichnisschutz** (Basic Auth) — empfohlen, aber kein Blocker. Er hält die
|
||||||
|
Testseite komplett aus dem Netz. Ohne ihn ist das Risiko klein, weil jede Seite
|
||||||
|
ein Canonical auf `www.tsv08kulmbach.de` trägt: Google konsolidiert darauf,
|
||||||
|
doppelter Inhalt entsteht praktisch nicht. Es kann lediglich die Test-URL bis zum
|
||||||
|
Umschalten in einem Suchergebnis auftauchen. Am 31.07.2026 bewusst weggelassen.
|
||||||
|
Wer es ohne Basic Auth sauber will: `X-Robots-Tag: noindex` in
|
||||||
|
`public/.htaccess`, host-bedingt nur für Namen, die mit `neu.` beginnen.
|
||||||
|
|
||||||
|
### C2 Erreichbarkeit
|
||||||
|
|
||||||
|
`https://neu.tsv08kulmbach.de` aufrufen. Nach der Passwortabfrage muss die
|
||||||
|
Startseite kommen. Weißes Blatt oder 500er? Dann [SSH]
|
||||||
|
`tail -30 storage/logs/php-errors.log`.
|
||||||
|
|
||||||
|
### C3 [SSH] Datenstände erzeugen
|
||||||
|
|
||||||
|
`data/matchcenter.json` und `data/instagram.json` sind bewusst nicht im Repo, die
|
||||||
|
holen die Syncs. Vor dem ersten Lauf fehlen Tabellen, Spiele und der
|
||||||
|
Instagram-Block (die Seite bricht deswegen nicht, sie zeigt Ersatzinhalte).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
|
||||||
|
php bin/matchcenter-sync.php # Spiele, Ergebnisse, Tabellen, Wappen
|
||||||
|
php bin/instagram-sync.php # Posts und Bilder
|
||||||
|
```
|
||||||
|
|
||||||
|
**Der Instagram-Lauf ist der Wackelkandidat des ganzen Deploys.** Instagram sperrt
|
||||||
|
Rechenzentrums-IPs; bisher lief der Scraper nur über einen privaten Anschluss.
|
||||||
|
Schlägt er hier fehl, ist das kein Fehler im Deploy: dann bleibt der Weg, ihn von
|
||||||
|
zu Hause laufen zu lassen und `data/instagram.json` samt Bildern hochzuladen, oder
|
||||||
|
der Block wird redaktionell gepflegt. Die Website funktioniert in jedem Fall.
|
||||||
|
|
||||||
|
### C4 [SSH] `preflight` bis null Fehler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/preflight.php; echo "Exit: $?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ziel ist **Exit 0**. Warnungen darfst du bewerten, Fehler nicht. Erwartbare
|
||||||
|
Warnungen hier: der Hinweis, dass `cron.key` leer ist (richtig so, siehe A1) und
|
||||||
|
die Uploadgrenze, die erst ein echter Upload beweist (C6).
|
||||||
|
|
||||||
|
### C5 Durchklicken
|
||||||
|
|
||||||
|
Die Seiten, die eigene Datenquellen haben:
|
||||||
|
|
||||||
|
- [ ] Startseite: Banner-Slider, Termin-Karte im Hero, Instagram-Block
|
||||||
|
- [ ] `/matchcenter`: Tabellen und nächste Spiele mit Wappen
|
||||||
|
- [ ] `/termine`, `/news`, `/veranstaltungen` plus eine Detailseite
|
||||||
|
- [ ] `/stadionzeitung` und die Ausgabe durchswipen — **alle Wappen und
|
||||||
|
Instagram-Bilder da?** (Sie liegen im Repo, sollten also stimmen.)
|
||||||
|
- [ ] `/stadionzeitung/aktuell` landet auf der neuesten Ausgabe
|
||||||
|
- [ ] Die Druckfassung einer Ausgabe (`?druck=1`)
|
||||||
|
- [ ] `/impressum`: Webadresse zeigt `www.tsv08kulmbach.de`, nicht die Subdomain
|
||||||
|
|
||||||
|
### C6 Dateiablage und Mailversand
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/dateien-init.php # Jahr/Abteilung-Ordner, MUSS auf dem Server laufen
|
||||||
|
php bin/smtp-test.php # nur Verbindung und Login, kein Versand
|
||||||
|
```
|
||||||
|
|
||||||
|
`dateien-init.php` gehört auf den Server, weil Umlaute per FTP zu Doppelordnern
|
||||||
|
führen (macOS NFD gegen Linux NFC).
|
||||||
|
|
||||||
|
- [ ] `/dateien` mit beiden Zugängen anmelden
|
||||||
|
- [ ] Eine große Datei hochladen (>10 MB) — beweist die 250-MB-Grenze
|
||||||
|
- [ ] Ein Formular abschicken (z. B. `/kontakt`) und die Mail im Postfach prüfen
|
||||||
|
- [ ] `php bin/logs.php` zeigt keine Fehler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil D — Cronjobs
|
||||||
|
|
||||||
|
**Die KAS-Cronjobs können nur URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026:
|
||||||
|
das Formular hat ein Feld „Protokoll / Pfad" mit `https://`, kein Kommandofeld). Der
|
||||||
|
Takt läuft deshalb über `public/cron.php`. Aufrufer ist der Hoster selbst — der
|
||||||
|
Schlüssel verlässt den Webspace nie, kein Dritter ist beteiligt.
|
||||||
|
|
||||||
|
Voraussetzung: `cron.key` muss in `config/config.php` gesetzt sein (48 Zeichen,
|
||||||
|
nicht `app_secret`). Steht er leer, antwortet der Endpoint mit 403 und **nichts** synct.
|
||||||
|
`bin/preflight.php` prüft Länge und Verwechslung.
|
||||||
|
|
||||||
|
[KAS] → **Cronjobs** → Anlegen, drei bis vier Einträge. `KEY` durch den Wert aus
|
||||||
|
`config.php` ersetzen:
|
||||||
|
|
||||||
|
| # | Pfad | Zeitpunkt |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY` | stündlich, Minute 37 |
|
||||||
|
| 2 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 06:17 |
|
||||||
|
| 3 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 18:17 |
|
||||||
|
| 4 | `www.tsv08kulmbach.de/cron.php?job=logrotate&key=KEY` | täglich, 03:23 |
|
||||||
|
|
||||||
|
Vier Dinge dahinter sind Absicht:
|
||||||
|
|
||||||
|
- **Matchcenter stündlich statt halbstündlich am Spieltag.** Das KAS kann Wochentage
|
||||||
|
nicht mit einem Halbstunden-Takt kombinieren, wie es die ursprüngliche Crontab vorsah.
|
||||||
|
Stündlich rund um die Uhr ist der brauchbare Ersatz: am Spieltag steht das Ergebnis
|
||||||
|
spätestens eine Stunde später. Kostet 24 Läufe/Tag. Wer den Verkehr zum BFV klein
|
||||||
|
halten will, nimmt zwei „täglich"-Jobs (07:37 und 19:37) und lebt damit, dass
|
||||||
|
Samstagsergebnisse erst abends stehen.
|
||||||
|
- **Instagram zwei Einträge**, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht
|
||||||
|
stündlich: Instagram sperrt auffällige Zugriffsmuster schneller als der BFV.
|
||||||
|
- **Versetzte Minuten**, damit sich zwei Läufe nie überlappen (die Syncs sperren sich
|
||||||
|
per Lock, sonst fällt einer aus).
|
||||||
|
- **Das E-Mail-Feld im KAS bleibt leer.** Die Antwort ist immer leer (204 = gelaufen oder
|
||||||
|
wegen Mindestabstand übersprungen, 403 = Schlüssel oder Job falsch), es gäbe also
|
||||||
|
nichts zu mailen. Ob der Takt läuft, sagt `php bin/logs.php`; `php bin/preflight.php`
|
||||||
|
warnt, wenn ein Datenstand zu alt wird.
|
||||||
|
|
||||||
|
**Am Tag nach dem ersten Lauf** einmal `php bin/logs.php` ansehen. Läuft nichts, ist fast
|
||||||
|
immer der Schlüssel in der URL falsch oder abgeschnitten. Gegenprobe von Hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' \
|
||||||
|
'https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY'
|
||||||
|
```
|
||||||
|
|
||||||
|
204 = angenommen, 403 = Schlüssel oder Job falsch. Direkt danach ein zweites Mal
|
||||||
|
aufgerufen muss es ebenfalls 204 geben — dann hat der Mindestabstand gegriffen und der
|
||||||
|
Lauf wurde übersprungen, was im Log als WARN steht.
|
||||||
|
|
||||||
|
## Teil E — Umschalten auf die Hauptdomain
|
||||||
|
|
||||||
|
Ab hier ist die Seite öffentlich. Alles davor muss sitzen.
|
||||||
|
|
||||||
|
### E1 Sicherheitsnetz
|
||||||
|
|
||||||
|
Notiere, auf welches Verzeichnis `www.tsv08kulmbach.de` **jetzt** zeigt (die alte
|
||||||
|
CMS-Seite). Das ist dein Rückweg. Verzeichnis und Datenbank der alten Seite bleiben
|
||||||
|
unangetastet, nicht löschen.
|
||||||
|
|
||||||
|
### E2 [KAS] Domain umstellen
|
||||||
|
|
||||||
|
**Domains** → `tsv08kulmbach.de` und `www.tsv08kulmbach.de` auf
|
||||||
|
**`/tsv08kulmbach-website/public`** zeigen lassen. PHP-Version prüfen, SSL muss für beide
|
||||||
|
aktiv sein.
|
||||||
|
|
||||||
|
### E3 Sofort danach prüfen
|
||||||
|
|
||||||
|
- [ ] `https://www.tsv08kulmbach.de` lädt
|
||||||
|
- [ ] `http://tsv08kulmbach.de` (ohne www, ohne s) landet per 301 auf
|
||||||
|
`https://www.tsv08kulmbach.de`
|
||||||
|
- [ ] `/sitemap.xml` und `/robots.txt` erreichbar
|
||||||
|
- [ ] `curl -sI https://www.tsv08kulmbach.de | head` — Statuszeile 200, kein
|
||||||
|
Redirect-Loop
|
||||||
|
- [ ] Ein Formular live abschicken
|
||||||
|
- [ ] **Einen gedruckten QR-Code mit dem Handy scannen.** Der Code der
|
||||||
|
Stadionzeitung zeigt auf `www.tsv08kulmbach.de/stadionzeitung/aktuell`.
|
||||||
|
Scannen ist die einzige echte Prüfung: die QR-Bibliothek arbeitet nicht
|
||||||
|
reproduzierbar, ein Bildvergleich beweist nichts.
|
||||||
|
|
||||||
|
### E4 Verzeichnisschutz der Test-Subdomain lassen
|
||||||
|
|
||||||
|
Nicht entfernen. Sonst steht die Seite zweimal im Netz und Google sieht doppelten
|
||||||
|
Inhalt. Wenn du die Subdomain nicht mehr brauchst, in KAS löschen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil F — Danach
|
||||||
|
|
||||||
|
### F1 Der Update-Ablauf ab jetzt
|
||||||
|
|
||||||
|
**Die Reihenfolge ist nicht verhandelbar**, weil `storage/dateien/uploads/` im Repo
|
||||||
|
liegt und andere Menschen dort hochladen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. [SSH] auf dem Server: neue Uploads sichern
|
||||||
|
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
|
||||||
|
git add -A storage/dateien/uploads && git commit -m "Uploads vom Server" && git push
|
||||||
|
|
||||||
|
# 2. [lokal] holen, dann entwickeln
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 3. [lokal] fertig, gepusht → [SSH] ausrollen
|
||||||
|
cd /www/htdocs/w01ca75d/tsv08kulmbach-website && git pull && php bin/preflight.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Nie in der anderen Reihenfolge. Details und Begründung in CLAUDE.md, Abschnitt
|
||||||
|
Dateiablage.
|
||||||
|
|
||||||
|
Ändert sich `composer.json`, gehört ein `composer install --no-dev
|
||||||
|
--optimize-autoloader` dazu. Neue Formularfelder heißen: Datenschutzerklärung
|
||||||
|
mitziehen.
|
||||||
|
|
||||||
|
### F2 Rückweg, falls etwas Grundlegendes bricht
|
||||||
|
|
||||||
|
[KAS] Domain zurück auf das alte Verzeichnis aus E1. Nach ein paar Minuten ist die
|
||||||
|
alte Seite wieder da. Kein Datenverlust, weil die neue Seite in einem eigenen
|
||||||
|
Verzeichnis liegt und die alte Datenbank nicht anfasst.
|
||||||
|
|
||||||
|
### F3 Offen, nach dem Livegang
|
||||||
|
|
||||||
|
- ~~**301-Weiterleitungen der alten URLs.**~~ Erledigt am 31.07.2026: `public/.htaccess`,
|
||||||
|
Block „Alte CMS-Adressen". Abgedeckt sind der PDF-Aufnahmeantrag der Turnabteilung
|
||||||
|
(`/userdata/01_Basis/Beitrittserkl…pdf`, so in den Einladungen verschickt) und alle
|
||||||
|
Seiten der alten Navigation unter `/08/de/…` (Zuordnung aus `main_navigation` im
|
||||||
|
DB-Dump). **Nicht** dabei und bewusst nicht nachgezogen: die Bilder und das
|
||||||
|
Hero-Video unter `/userdata/04_Seiteninhalte/` — die liegen auf der neuen Seite in
|
||||||
|
anderer Form, eine Weiterleitung von Bild-URLs hilft niemandem.
|
||||||
|
- **Google Search Console:** Property anlegen, `sitemap.xml` einreichen.
|
||||||
|
- **Instagram-Sync** beobachten, siehe C3.
|
||||||
|
- Nach dem Sportfest (ab 07.09.2026) fallen zwei Anzeigen per `bis`-Datum aus dem
|
||||||
|
Bestand, die Stadionzeitung hat dann 38 Seiten statt 40 und ist nicht mehr durch
|
||||||
|
4 teilbar. `bin/stadionzeitung-add.php` warnt beim nächsten Lauf.
|
||||||
@@ -15,6 +15,40 @@ RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
|||||||
RewriteCond %{HTTP_HOST} ^tsv08kulmbach\.de$ [NC]
|
RewriteCond %{HTTP_HOST} ^tsv08kulmbach\.de$ [NC]
|
||||||
RewriteRule ^ https://www.tsv08kulmbach.de%{REQUEST_URI} [R=301,L]
|
RewriteRule ^ https://www.tsv08kulmbach.de%{REQUEST_URI} [R=301,L]
|
||||||
|
|
||||||
|
# --- Umbenannte Seiten (301) ---
|
||||||
|
# Step Aerobic findet nicht mehr statt (31.07.2026), die Seite heißt jetzt
|
||||||
|
# „Fitness & Pilates" und liegt unter turnen/fitness-pilates. Der alte Slug war
|
||||||
|
# in der Sitemap und ist von Google indexiert, deshalb 301 statt 404 — sonst
|
||||||
|
# verliert die Seite ihre Position und alte Links laufen ins Leere.
|
||||||
|
# Steht VOR dem Front Controller, sonst würde index.php den Pfad zuerst als
|
||||||
|
# unbekannten Slug abfangen und einen 404 rendern.
|
||||||
|
RewriteRule ^turnen/step-aerobic-fitness-pilates/?$ /turnen/fitness-pilates [R=301,L]
|
||||||
|
|
||||||
|
# --- Alte CMS-Adressen (301) ---
|
||||||
|
# Die alte Seite lief unter /08/de/<seite>/ und lieferte Dateien aus /userdata/.
|
||||||
|
# Beides ist seit dem Umzug tot, die Adressen stehen aber in Einladungen, Aushängen
|
||||||
|
# und im Google-Index. Steht wie die Regel darüber VOR dem Front Controller.
|
||||||
|
#
|
||||||
|
# Der Aufnahmeantrag der Turnabteilung ist der dringendste Fall: er ging so an die
|
||||||
|
# neuen Familien raus. Der Dateiname trägt ein „ä" und kommt je nach Mailprogramm in
|
||||||
|
# drei Fassungen an: NFC (%C3%A4), NFD (a%CC%88 — genau so stand der Link in der alten
|
||||||
|
# Datenbank) oder roh als UTF-8. mod_rewrite prüft den dekodierten Pfad, also matchen
|
||||||
|
# wir am Umlaut vorbei, statt eine der drei Fassungen zu raten.
|
||||||
|
RewriteRule ^userdata/01_Basis/Beitrittserkl[^/]*\.pdf$ /assets/downloads/beitrittserklaerung-turnen.pdf [R=301,L,NC]
|
||||||
|
|
||||||
|
# Seiten, die ihren Namen behalten haben. Das abschließende „?" wirft den alten
|
||||||
|
# Query-String weg (die Turn-Seite hatte ?group_filter_id=<n> als Disziplin-Filter,
|
||||||
|
# 14 war „Allgemeines") — ohne das hängt mod_rewrite ihn ungefragt ans neue Ziel.
|
||||||
|
RewriteRule ^08/de/(fussball|jugendfussball|matchcenter|turnen|mitglied-werden|kontakt|historie|impressum|datenschutz)/?$ /$1? [R=301,L]
|
||||||
|
|
||||||
|
# Seiten, die heute anders heißen
|
||||||
|
RewriteRule ^08/de/home/?$ / [R=301,L]
|
||||||
|
RewriteRule ^08/de/aktuelles/?$ /news [R=301,L]
|
||||||
|
RewriteRule ^08/de/der-verein/?$ /verein [R=301,L]
|
||||||
|
RewriteRule ^08/de/der-verein/historie/?$ /historie [R=301,L]
|
||||||
|
RewriteRule ^08/de/der-verein/sportheim/?$ /sportheimbuchung [R=301,L]
|
||||||
|
RewriteRule ^08/de/personen/?$ /ansprechpartner [R=301,L]
|
||||||
|
|
||||||
# --- Front Controller ---
|
# --- Front Controller ---
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
|||||||
@@ -176,7 +176,13 @@
|
|||||||
z-index: 25;
|
z-index: 25;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3xs);
|
gap: var(--space-3xs);
|
||||||
min-width: 15rem;
|
/* 15,5rem statt 15rem: der längste Punkt „Eltern-Kind- & Kinderturnen" ist 187,6px
|
||||||
|
breit, und im Hover bzw. auf der aktiven Seite wächst padding-left von --space-s
|
||||||
|
auf --space-m. Bei 15rem blieben dafür nur 186px, das Panel wuchs beim Hover um
|
||||||
|
zwei Pixel und war auf dieser Seite dauerhaft breiter — als Flackern sichtbar,
|
||||||
|
weil das Panel mittig sitzt und sich also nach beiden Seiten schiebt. Wer hier
|
||||||
|
einen längeren Menüpunkt einträgt, misst die Textbreite im Hover-Zustand nach. */
|
||||||
|
min-width: 15.5rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
/* Gleichmäßiges Padding rundum — Pillen stehen oben/unten/seitlich gleich dezent frei. */
|
/* Gleichmäßiges Padding rundum — Pillen stehen oben/unten/seitlich gleich dezent frei. */
|
||||||
padding: var(--space-2xs);
|
padding: var(--space-2xs);
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
--clr-accent-hsl: 356.7 94.8% 45.5%; /* dasselbe Rot als H S L */
|
--clr-accent-hsl: 356.7 94.8% 45.5%; /* dasselbe Rot als H S L */
|
||||||
--clr-accent-h: 356.7; /* nur der Farbton — die Dateiablage (/dateien)
|
--clr-accent-h: 356.7; /* nur der Farbton — die Dateiablage (/dateien)
|
||||||
leitet daraus ihre Flächen ab, siehe dateien.css */
|
leitet daraus ihre Flächen ab, siehe dateien.css */
|
||||||
|
/* Akzentfarben einzelner Werbe-Banner (Sponsor-CI statt Vereins-Rot).
|
||||||
|
Sie stehen hier und nicht in data/banners.json, weil ein Hex-Wert aus der JSON
|
||||||
|
nur per Inline-Style in die Seite käme — und den verwirft die CSP
|
||||||
|
(default-src 'self', kein style-src). Die JSON nennt nur den Namen
|
||||||
|
("accent": "breadcrumb"), components.css bildet ihn auf dieses Token ab.
|
||||||
|
Neuer Sponsor mit eigener CI = eine Zeile hier plus eine Klasse dort. */
|
||||||
|
--clr-banner-accent-breadcrumb: #6cbbbc;
|
||||||
|
|
||||||
--clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */
|
--clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */
|
||||||
--clr-bg-deep: #161616; /* abgesetzte Sektionen */
|
--clr-bg-deep: #161616; /* abgesetzte Sektionen */
|
||||||
--clr-bg-footer: #000000;
|
--clr-bg-footer: #000000;
|
||||||
@@ -64,6 +72,12 @@
|
|||||||
/* Sperrung. Coolvetica ist Condensed und hat nur einen Schnitt: unter 28px
|
/* Sperrung. Coolvetica ist Condensed und hat nur einen Schnitt: unter 28px
|
||||||
wird es in Uppercase unleserlich (siehe CLAUDE.md). Kleinere Auszeichnungen
|
wird es in Uppercase unleserlich (siehe CLAUDE.md). Kleinere Auszeichnungen
|
||||||
laufen deshalb über --ff-body + uppercase + --ls-caps statt --ff-heading. */
|
laufen deshalb über --ff-body + uppercase + --ls-caps statt --ff-heading. */
|
||||||
|
--ls-heading: 0.012em; /* Coolvetica-Überschriften unter ~40px: nur ein Hauch Luft
|
||||||
|
zwischen den Versalien (0,4px bei 34px). Der Schnitt ist
|
||||||
|
condensed, ohne Sperrung kleben die Buchstaben — mit
|
||||||
|
--ls-caps (0.08em) wirkt eine Überschrift dagegen
|
||||||
|
auseinandergezogen. Nicht für kleine Labels, dafür
|
||||||
|
--ls-caps/--ls-wide. */
|
||||||
--ls-tight: 0.03em; /* dezente Straffung in Fließtext-Größen */
|
--ls-tight: 0.03em; /* dezente Straffung in Fließtext-Größen */
|
||||||
--ls-caps: 0.08em; /* Standard für gesperrte Uppercase-Auszeichnungen */
|
--ls-caps: 0.08em; /* Standard für gesperrte Uppercase-Auszeichnungen */
|
||||||
--ls-wide: 0.12em; /* kleine Labels, Meta-Zeilen */
|
--ls-wide: 0.12em; /* kleine Labels, Meta-Zeilen */
|
||||||
@@ -88,6 +102,20 @@
|
|||||||
der meistgenutzte). Nach oben wird das Raster gröber, weil große Abstände
|
der meistgenutzte). Nach oben wird das Raster gröber, weil große Abstände
|
||||||
das vertragen. --space-4xs (2px) ist ausschließlich für optische
|
das vertragen. --space-4xs (2px) ist ausschließlich für optische
|
||||||
Korrekturen gedacht (Icon-/Baseline-Nudges), nicht für Layout. */
|
Korrekturen gedacht (Icon-/Baseline-Nudges), nicht für Layout. */
|
||||||
|
/* Karten-Innenabstände. Fluid statt fest, damit auf schmalen Handys nicht ein
|
||||||
|
Viertel der Bildschirmbreite in gestapelten Innenabständen verschwindet
|
||||||
|
(gemessen bei 309px: Container 11,5 + Karte 32 je Seite → nur 71% der Breite
|
||||||
|
blieben für das Eingabefeld). Ein harter Breakpoint bei ~310px wäre die
|
||||||
|
Alternative gewesen, sieht aber bei 311px wieder falsch aus.
|
||||||
|
Die OBERGRENZEN entsprechen genau den bisherigen festen Werten, am Desktop
|
||||||
|
ändert sich also nichts — es wird nur nach unten schmaler.
|
||||||
|
Vier Karten (fee-table, membership-card, legal__card, matchcenter-empty)
|
||||||
|
hatten diese clamp()-Formeln schon einzeln stehen; sie behalten ihre eigenen
|
||||||
|
Werte, damit ihr Desktop-Bild unangetastet bleibt. Sie hier einzusammeln ist
|
||||||
|
eine Aufräumung für später. */
|
||||||
|
--pad-card: clamp(var(--space-m), var(--space-s) + 1.5vw, var(--space-2xl)); /* 20…32px */
|
||||||
|
--pad-card-lg: clamp(var(--space-l), var(--space-s) + 2.5vw, var(--space-3xl)); /* 24…40px */
|
||||||
|
|
||||||
--space-4xs: 0.125rem; /* 2px */
|
--space-4xs: 0.125rem; /* 2px */
|
||||||
--space-3xs: 0.25rem; /* 4px */
|
--space-3xs: 0.25rem; /* 4px */
|
||||||
--space-2xs: 0.5rem; /* 8px */
|
--space-2xs: 0.5rem; /* 8px */
|
||||||
|
|||||||
BIN
public/assets/img/anzeigen/emons-1141.jpg
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/assets/img/anzeigen/emons-480.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/assets/img/anzeigen/emons-960.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
public/assets/img/anzeigen/gagel-pickelmann-1440.jpg
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
public/assets/img/anzeigen/gagel-pickelmann-480.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/assets/img/anzeigen/gagel-pickelmann-960.jpg
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/assets/img/anzeigen/schramm-1440.jpg
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
public/assets/img/anzeigen/schramm-480.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/assets/img/anzeigen/schramm-960.jpg
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
public/assets/img/anzeigen/tagesskifahrt-2027-1440.jpg
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
public/assets/img/anzeigen/tagesskifahrt-2027-480.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
public/assets/img/anzeigen/tagesskifahrt-2027-960.jpg
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
public/assets/img/banners/breadcrumb-hero-1000.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/assets/img/banners/breadcrumb-hero-640.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/assets/img/crests/00ES8GNK2800001AVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000002VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000009VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/assets/img/crests/00ES8GNK3G00000GVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/img/crests/00ES8GNK3G00000LVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/assets/img/crests/00ES8GNK3G00000NVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/img/crests/00ES8GNK3G00000RVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/img/crests/00ES8GNK3G00000SVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000010VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000013VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000015VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000017VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000018VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/img/crests/00ES8GNK3G000019VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/img/crests/00ES8GNK3G00001AVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/assets/img/crests/00ES8GNK4O000001VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/img/crests/00ES8GNK4O000002VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/img/crests/00ES8GNK4O00000NVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/assets/img/crests/00ES8GNK4O00000SVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/assets/img/crests/00ES8GNK4O00000TVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/assets/img/crests/00ES8GNK4O00000VVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/assets/img/crests/00ES8GNK4O000012VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/assets/img/crests/00ES8GNK4O000013VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/assets/img/crests/00ES8GNK4O000015VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/img/crests/00ES8GNK4O000016VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/img/crests/00ES8GNK4O00001DVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/assets/img/crests/00ES8GNK78000005VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/assets/img/crests/00ES8GNK8G00000IVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/assets/img/crests/00ES8GNK8G000019VV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/assets/img/crests/00ES8GNK9O00000BVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/img/crests/00ES8GNK9O00000PVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/assets/img/crests/00ES8GNK9O00000QVV0AG08LVUPGND5I.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |