Compare commits
20 Commits
06a18a2d19
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e761231442 | |||
| 0a01134583 | |||
| 5854e452f7 | |||
| f2f193b223 | |||
| b4d2cde149 | |||
| 0d80f69a76 | |||
| 6313b98f9e | |||
| f88b5f1dd1 | |||
| 736a588a88 | |||
| 550616aacf | |||
| c688d155cc | |||
| 1839632b66 | |||
| d410c0bedf | |||
| 133c3f926e | |||
| 146471bae8 | |||
| f3f004d13e | |||
| 7bbf783562 | |||
| f21224ea84 | |||
| 592acf02b0 | |||
| 63a045835e |
132
CLAUDE.md
@@ -54,10 +54,10 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
|
||||
| Partner/Sponsoren | `data/partners.json` | — |
|
||||
| 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`) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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`, `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) |
|
||||
| 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[]` |
|
||||
| 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 |
|
||||
@@ -72,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
|
||||
`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.
|
||||
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
|
||||
`[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
|
||||
@@ -509,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
|
||||
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
|
||||
Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
|
||||
News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
|
||||
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
|
||||
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
|
||||
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
|
||||
(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
|
||||
@@ -540,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.
|
||||
Ein `images[]`-Eintrag hat
|
||||
`"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;
|
||||
`tabelle` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der Team-Key
|
||||
der Ausgabe). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
|
||||
`vorwort` | `interview` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `impressum`, kein Bild;
|
||||
`tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der
|
||||
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
|
||||
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` 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
|
||||
@@ -558,6 +589,22 @@ sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync — **auf
|
||||
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
|
||||
@@ -627,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
|
||||
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 —
|
||||
`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
|
||||
|
||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 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')
|
||||
* $anchor string (optional, Default 'faq')
|
||||
* Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert
|
||||
@@ -27,7 +32,16 @@ if ($faq === []) {
|
||||
<?php foreach ($faq as $item): ?>
|
||||
<details class="faq__item">
|
||||
<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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,12 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props:
|
||||
* $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)
|
||||
* $noticeTitle string (optional) — Überschrift dazu
|
||||
* $noticeTone 'info' (Default) | 'stop' — Tonlage, siehe components/notice.php
|
||||
@@ -14,10 +19,29 @@ declare(strict_types=1);
|
||||
* 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:
|
||||
* { name, level?, intro?, details?:[{heading,text}], recommendation?, note?,
|
||||
* trainers?:[…], sessions:[{ label?, day, time, note?, trainers?:[…] }] }
|
||||
* { name, level?, contact?, intro?, details?:[{heading,text}], recommendation?,
|
||||
* 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)
|
||||
* $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')
|
||||
*
|
||||
* Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard.
|
||||
@@ -26,12 +50,17 @@ declare(strict_types=1);
|
||||
*/
|
||||
$anchor = $anchor ?? 'training';
|
||||
$groupsTitle = $groupsTitle ?? 'Unsere Gruppen';
|
||||
$groupsTitleHidden = $groupsTitleHidden ?? false;
|
||||
$layout = ($layout ?? 'tabs') === 'stacked' ? 'stacked' : 'tabs';
|
||||
$lead = $lead ?? '';
|
||||
$contactEmail = $contactEmail ?? '';
|
||||
$contactPhone = $contactPhone ?? '';
|
||||
$notice = $notice ?? '';
|
||||
$noticeTitle = $noticeTitle ?? '';
|
||||
$noticeTone = $noticeTone ?? 'info';
|
||||
$noticeIcon = $noticeIcon ?? 'info-circle';
|
||||
$footnote = $footnote ?? '';
|
||||
$groupsInDash = !empty($groupsInDash);
|
||||
$groups = $groups ?? [];
|
||||
$location = $location ?? [];
|
||||
|
||||
@@ -40,7 +69,8 @@ if ($groups === []) {
|
||||
}
|
||||
|
||||
$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;
|
||||
$rich = false;
|
||||
@@ -51,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']);
|
||||
$mapsQuery = $hasVenue ? rawurlencode(trim(
|
||||
($location['venue'] ?? '') . ', '
|
||||
@@ -59,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 {
|
||||
?>
|
||||
<li class="schedule-row">
|
||||
@@ -74,96 +114,20 @@ $renderSession = static function (array $s): void {
|
||||
<span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?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; ?>
|
||||
</li>
|
||||
<?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' : '' ?>">
|
||||
<?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; ?>
|
||||
</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"
|
||||
>
|
||||
/**
|
||||
* 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
|
||||
* (ohne). Vorher stand das Markup nur in der Tab-Fassung; eine zweite Kopie daneben wäre
|
||||
* die Stelle, an der beide auseinanderlaufen.
|
||||
*/
|
||||
$renderCardBody = static function (array $g) use ($renderSession): void {
|
||||
?>
|
||||
<div class="group-card__body">
|
||||
<div class="group-card__header">
|
||||
<h3 class="group-card__name"><?= e($g['name']) ?></h3>
|
||||
@@ -192,20 +156,27 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||
<ul class="schedule-list">
|
||||
<?php foreach ($g['sessions'] as $s): $renderSession($s); endforeach; ?>
|
||||
</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'])): ?>
|
||||
<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 if (!empty($g['note'])): ?>
|
||||
<p class="group-card__note text-muted"><?= e($g['note']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="group-list group-list--grid">
|
||||
<?php foreach ($groups as $g): ?>
|
||||
<?php
|
||||
};
|
||||
|
||||
/**
|
||||
* Schlichte Gruppen-Karte (Name + Trainingszeiten). Als Closure, weil sie an zwei Stellen
|
||||
* 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">
|
||||
<div class="group-card__body">
|
||||
<div class="group-card__header">
|
||||
@@ -223,7 +194,123 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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; ?>
|
||||
</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>
|
||||
<?php endif; ?>
|
||||
<?php if ($footnote !== ''): ?>
|
||||
|
||||
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>
|
||||
@@ -36,11 +36,12 @@ $issues = $issues ?? [];
|
||||
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
||||
· <?= (int) $issue['pages'] ?> Seiten
|
||||
</p>
|
||||
<?php /* Coverzeile des Interviews als Teaser — dieselbe Quelle, die auch auf dem
|
||||
Cover unter „Im Heft" steht. Sie füllt die breite Zeile mit echtem Inhalt
|
||||
statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte. Trägt eine
|
||||
Ausgabe kein Interview, entfällt die Zeile (wie bei .veranstaltung-card__text). */
|
||||
$kartenTeaser = trim((string) ($issue['interview']['teaser'] ?? '')); ?>
|
||||
<?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; ?>
|
||||
|
||||
@@ -69,18 +69,64 @@ if ($match !== null) {
|
||||
|
||||
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (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) —
|
||||
// erscheint automatisch, sobald die Ausgabe ein Interview trägt
|
||||
// (interview-Feld, chat-gepflegt wie das Vorwort).
|
||||
$teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
||||
// aus stadionzeitung_teaser(): `cover.teaser` einer Sonderausgabe, sonst die
|
||||
// Coverzeile des Interviews. Dieselbe Funktion speist die Archivkarte.
|
||||
$teaser = stadionzeitung_teaser($issue);
|
||||
?>
|
||||
<div class="stadionzeitung-cover">
|
||||
<div class="stadionzeitung-cover<?= $poster !== null ? ' stadionzeitung-cover--poster' : '' ?>">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($playerImage)): ?>
|
||||
<?php if ($poster !== null): ?>
|
||||
<?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,
|
||||
@@ -112,6 +158,17 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
||||
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
|
||||
</header>
|
||||
<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">
|
||||
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
|
||||
ruhiger) — die beiden Logos sprechen für sich. */ ?>
|
||||
@@ -124,6 +181,7 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
||||
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($teaser !== ''): ?>
|
||||
<p class="stadionzeitung-cover__teaser">
|
||||
<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:
|
||||
* $entry Eintrag aus issue.images[]:
|
||||
* type 'image' (base/widths/alt),
|
||||
* type 'live' (page: cover|vorwort|interview|tabelle|termine|
|
||||
* news|ergebnisse|vorschau|kontakt; tabelle optional
|
||||
* mit team: Team-Key aus matchcenter.json),
|
||||
* type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier|
|
||||
* tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt|
|
||||
* 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
|
||||
* Seitenzahl auf einem Vielfachen von 4)
|
||||
* $issue Eintrag aus data/stadionzeitung.json
|
||||
@@ -46,12 +49,30 @@ $type = $entry['type'] ?? 'image';
|
||||
<?php case 'interview': ?>
|
||||
<?php component('stadionzeitung-interview', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'aufruf': ?>
|
||||
<?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'termine': ?>
|
||||
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'ergebnisse': ?>
|
||||
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
|
||||
<?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 component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
@@ -70,6 +91,11 @@ $type = $entry['type'] ?? 'image';
|
||||
<?php case 'partner': ?>
|
||||
<?php component('stadionzeitung-partner', []); ?>
|
||||
<?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 component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
|
||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
||||
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
|
||||
* 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
|
||||
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
|
||||
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
|
||||
@@ -60,6 +60,8 @@ $termineLink = club_domain('termine');
|
||||
} elseif (!$isMatch && !empty($item['location'])) {
|
||||
$parts[] = (string) $item['location'];
|
||||
}
|
||||
$title = (string) ($item['title'] ?? '');
|
||||
$meta = implode(' · ', $parts);
|
||||
?>
|
||||
<li class="stadionzeitung-termine__item">
|
||||
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
|
||||
@@ -67,8 +69,8 @@ $termineLink = club_domain('termine');
|
||||
Blick unterscheidbar. */ ?>
|
||||
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
|
||||
<div class="stadionzeitung-termine__body">
|
||||
<p class="stadionzeitung-termine__title"><?= e((string) ($item['title'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-termine__meta"><?= e(implode(' · ', $parts)) ?></p>
|
||||
<p class="stadionzeitung-termine__title" title="<?= e($title) ?>"><?= e($title) ?></p>
|
||||
<p class="stadionzeitung-termine__meta" title="<?= e($meta) ?>"><?= e($meta) ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<?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:
|
||||
* $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) —
|
||||
* voll lesbar, sequentiell auch per Screenreader. Mit JS
|
||||
|
||||
157
app/helpers.php
@@ -568,6 +568,27 @@ function veranstaltungen_load(): array
|
||||
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
|
||||
* Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
|
||||
@@ -955,6 +976,86 @@ function stadionzeitung_is_published(array $issue): bool
|
||||
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
|
||||
* Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
|
||||
@@ -992,6 +1093,11 @@ function stadionzeitung_build_snapshot(array $issue): array
|
||||
$tabellen = [];
|
||||
$ergebnisse = [];
|
||||
$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) {
|
||||
$key = (string) ($team['key'] ?? '');
|
||||
if ($key === '') {
|
||||
@@ -1004,6 +1110,47 @@ function stadionzeitung_build_snapshot(array $issue): array
|
||||
'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
|
||||
// kein Rückblick (dieselbe Regel wie im News-Feed).
|
||||
$withScore = array_values(array_filter(
|
||||
@@ -1024,7 +1171,11 @@ function stadionzeitung_build_snapshot(array $issue): array
|
||||
|
||||
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
|
||||
// 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) {
|
||||
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
|
||||
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
|
||||
@@ -1038,6 +1189,9 @@ function stadionzeitung_build_snapshot(array $issue): array
|
||||
if ($matchDate === $date && $key === $issueTeam) {
|
||||
continue;
|
||||
}
|
||||
if ($gegnerDate !== null && $matchDate === $gegnerDate) {
|
||||
continue;
|
||||
}
|
||||
$vorschau[] = [
|
||||
'team_key' => $key,
|
||||
'team_name' => (string) ($team['name'] ?? ''),
|
||||
@@ -1058,6 +1212,7 @@ function stadionzeitung_build_snapshot(array $issue): array
|
||||
|
||||
return [
|
||||
'tabellen' => $tabellen,
|
||||
'gegner' => $gegner,
|
||||
'ergebnisse' => $ergebnisse,
|
||||
'vorschau' => $vorschau,
|
||||
// Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht
|
||||
|
||||
@@ -40,7 +40,7 @@ component('hero', ['hero' => $page['hero']]);
|
||||
<div class="container">
|
||||
<div class="intro-bento">
|
||||
<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>
|
||||
|
||||
<article class="bento__tile intro-bento__about">
|
||||
|
||||
@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
|
||||
$meta = [
|
||||
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
|
||||
'description' => $d['hero']['text'],
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'og_image' => 'img/pages/turnen-eltern-kind-1536.jpg',
|
||||
'scripts' => ['group-tabs.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
|
||||
@@ -12,7 +12,7 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
|
||||
$meta = [
|
||||
'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.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'og_image' => 'img/pages/turnen-erwachsenenturnen-1536.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
|
||||
@@ -8,9 +8,10 @@ declare(strict_types=1);
|
||||
*
|
||||
* 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 —
|
||||
* Pilates und Fitness & Stretching bei Karola Lorz laufen weiter. Der alte Slug
|
||||
* leitet per 301 hierher (public/.htaccess).
|
||||
* (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'];
|
||||
@@ -18,7 +19,7 @@ $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/vereinsgelaende-1600.jpg',
|
||||
'og_image' => 'img/pages/turnen-fitness-pilates-1536.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
@@ -32,4 +33,6 @@ component('group-schedule', [
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'groups' => $d['groups'] ?? [],
|
||||
// Wenige, beschreibende Kurse → offen untereinander statt Tabs (siehe group-schedule.php).
|
||||
'layout' => 'stacked',
|
||||
]);
|
||||
@@ -24,9 +24,13 @@ component('hero', ['hero' => $d['hero']]);
|
||||
component('group-schedule', [
|
||||
'location' => $d['location'] ?? [],
|
||||
'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'] ?? [],
|
||||
'groupsInDash' => true,
|
||||
'footnote' => $d['footnote'] ?? '',
|
||||
]);
|
||||
@@ -4,10 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 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';
|
||||
$d = json_load('turnen')['disciplines']['wettkampfturnen'];
|
||||
$data = json_load('turnen');
|
||||
$d = $data['disciplines']['wettkampfturnen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Wettkampfturnen in Kulmbach',
|
||||
@@ -25,4 +31,16 @@ component('group-schedule', [
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'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'] ?? '',
|
||||
]);
|
||||
@@ -17,9 +17,11 @@ declare(strict_types=1);
|
||||
* 2024.tsv08kulmbach.de): Zusatzbeitrag 24/48 €, Neumitglieder 84/94/184 €,
|
||||
* Förderstufen 120/180/240 €, Austritt zum 30. September.
|
||||
*
|
||||
* NOCH unbenutzt: overview.cta („Lust aufs Turnen bekommen?"). Der Block verlinkt
|
||||
* „Zur Turn-Übersicht" und gehört damit auf die Disziplin-Unterseiten, nicht hierher
|
||||
* — auf der Übersicht wäre das ein Selbstlink.
|
||||
* 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
|
||||
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
|
||||
@@ -29,17 +31,32 @@ declare(strict_types=1);
|
||||
*/
|
||||
$page = json_load('turnen')['overview'];
|
||||
|
||||
// Heros aller Turn-Seiten zeigen VORÜBERGEHEND das Drohnen-Luftbild des
|
||||
// Vereinsgeländes (img/pages/vereinsgelaende, Quelle: data/turnen.json) statt des
|
||||
// Fußballplatz-Bildes. Ehrlicher wäre ein Bild aus der Turnhalle — liegt eines
|
||||
// vor, tauschen die sechs Hero-Blöcke in data/turnen.json und das og_image der
|
||||
// sechs Seiten-Dateien. Die Übersichts-Kacheln zeigen weiter die (vorläufigen)
|
||||
// themenbezogenen Platzhalter aus public/assets/img/pages/.
|
||||
// Die Kündigung darf formlos per Mail kommen (Turnabteilung, 31.07.2026). Die Adresse
|
||||
// steht ausschließlich in club.json — der Austritt-Text in turnen.json markiert ihre
|
||||
// Stelle mit {mail}, dasselbe Muster wie der Material-Aufruf auf /historie. Ersetzt
|
||||
// wird NACH inline_links_html(): so bleibt der Fließtext escaped und nur der Platzhalter
|
||||
// wird zu HTML. Umgekehrt käme der fertige Link escaped als Text auf die Seite.
|
||||
$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 = [
|
||||
['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' => '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' => 'Fitness & Pilates', 'slug' => 'turnen/fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-fitness-pilates', 'widths' => [640, 1000, 1600], 'alt' => 'Fitness und Pilates beim TSV 08 Kulmbach']],
|
||||
['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']],
|
||||
['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, 1536], 'alt' => 'Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)']],
|
||||
['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)']],
|
||||
// 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']],
|
||||
];
|
||||
|
||||
@@ -87,7 +104,7 @@ component('team-cards', [
|
||||
<?php if (!empty($page['austritt']['title'])): ?>
|
||||
<div class="fees__note">
|
||||
<h3 class="fees__note-title"><?= e($page['austritt']['title']) ?></h3>
|
||||
<p><?= inline_links_html($page['austritt']['text']) ?></p>
|
||||
<p><?= $austrittText ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ declare(strict_types=1);
|
||||
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
|
||||
* schreibt bin/veranstaltung-bilder.php.
|
||||
*
|
||||
* Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()):
|
||||
* ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er
|
||||
* gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
|
||||
* Geladen wird hier bewusst UNGEFILTERT (veranstaltung_find(), nicht
|
||||
* veranstaltungen_load()): ein Entwurf muss unter seiner direkten URL lesbar
|
||||
* bleiben, damit er gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
|
||||
* trägt noindex.
|
||||
*
|
||||
* 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), '/');
|
||||
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
|
||||
|
||||
$v = null;
|
||||
foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
|
||||
if (($entry['slug'] ?? '') === $veranstaltungSlug) {
|
||||
$v = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$v = veranstaltung_find($veranstaltungSlug);
|
||||
|
||||
if ($v === null) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
|
||||
@@ -123,7 +123,11 @@ foreach ($sources as $src) {
|
||||
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
|
||||
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,41 @@ if (is_file($szFile)) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
|
||||
@@ -51,6 +51,23 @@ declare(strict_types=1);
|
||||
* die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
|
||||
* Fallback-Bilds `vorwort.*`.
|
||||
*
|
||||
* Interview (`interview`-Feld) und die generische Aufruf-Seite (`aufruf`-Feld,
|
||||
* z. B. Sportfest-Helferaufruf) sind wie das Vorwort chat-gepflegt: das Skript
|
||||
* trägt sie über erneute Läufe weiter und plant die Live-Seite nur ein, wenn
|
||||
* das Feld existiert. Reihenfolge im anzeigenfreien Aufschlag: Vorwort →
|
||||
* Interview → Aufruf. Für die Aufruf-Seite erzeugt das Skript zusätzlich den
|
||||
* QR (qr-aufruf.png im Ausgaben-Ordner) aus `aufruf.link`.
|
||||
*
|
||||
* 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
|
||||
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
|
||||
*/
|
||||
@@ -179,6 +196,21 @@ foreach ($existing['issues'] ?? [] as $entry) {
|
||||
$oldVorwort = $oldIssue['vorwort'] ?? null;
|
||||
// Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso.
|
||||
$oldInterview = $oldIssue['interview'] ?? null;
|
||||
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
|
||||
$oldAufruf = $oldIssue['aufruf'] ?? null;
|
||||
// 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 ---
|
||||
$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
|
||||
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
|
||||
// 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(
|
||||
static function (array $a): array {
|
||||
$sponsor = trim((string) ($a['sponsor'] ?? ''));
|
||||
@@ -228,6 +282,7 @@ $anzeigenSeiten = array_values(array_map(
|
||||
// nächsten Handgriff im Heft zu bleiben.
|
||||
static fn (array $a): bool => ($a['aktiv'] ?? true)
|
||||
&& !empty($a['base'])
|
||||
&& empty($a['platzierung'])
|
||||
&& (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
|
||||
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
|
||||
// bei jeder Umverteilung. ---
|
||||
$live = static function (string $page, ?string $team = null): array {
|
||||
$entry = ['type' => 'live', 'page' => $page];
|
||||
if ($team !== null) {
|
||||
$entry['team'] = $team;
|
||||
}
|
||||
return $entry;
|
||||
$live = static function (string $page, array $extra = []): array {
|
||||
return ['type' => 'live', 'page' => $page] + $extra;
|
||||
};
|
||||
|
||||
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
|
||||
@@ -284,32 +335,89 @@ try {
|
||||
} elseif ($vorwort !== null) {
|
||||
$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
|
||||
// Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
|
||||
if ($oldInterview !== null) {
|
||||
$aufschlag[] = $live('interview');
|
||||
}
|
||||
// Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien
|
||||
// Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller
|
||||
// Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld).
|
||||
if ($oldAufruf !== null) {
|
||||
$aufschlag[] = $live('aufruf');
|
||||
}
|
||||
if ($aufschlag !== []) {
|
||||
$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];
|
||||
|
||||
// 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
|
||||
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
|
||||
// SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
|
||||
$tabellen = [];
|
||||
foreach ($mc['teams'] ?? [] as $team) {
|
||||
if (!empty($team['key'])) {
|
||||
$tabellen[] = $live('tabelle', (string) $team['key']);
|
||||
$tabellen[] = $live('tabelle', ['team' => (string) $team['key']]);
|
||||
}
|
||||
}
|
||||
if ($tabellen !== []) {
|
||||
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
|
||||
}
|
||||
|
||||
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) {
|
||||
$bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true];
|
||||
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'danke', 'momente'] as $seite) {
|
||||
// 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];
|
||||
@@ -553,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
|
||||
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
|
||||
// keine Einbindung, Regel 1 bleibt unberührt).
|
||||
@@ -620,6 +742,9 @@ $issues[] = [
|
||||
'qr_image' => $qrImage,
|
||||
'vorwort' => $oldVorwort,
|
||||
'interview' => $oldInterview,
|
||||
'aufruf' => $oldAufruf,
|
||||
'veranstaltung' => $oldVeranstaltung,
|
||||
'cover' => $oldCover,
|
||||
'verfasser_photo' => $verfasserPhoto,
|
||||
'termine_snapshot' => $termineSnapshot,
|
||||
'images' => $images,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"name": "Kerstin Tauber",
|
||||
"phone": "+49 176 38957526",
|
||||
"whatsapp": true,
|
||||
"image": null
|
||||
"image": "img/persons/kerstin-tauber.jpg"
|
||||
},
|
||||
{
|
||||
"group": "Turnen",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
{
|
||||
"slug": "emons-stuebinger",
|
||||
"sponsor": "Emons Spedition und AST Stübinger Hoch-, Tief- und Straßenbau, Kulmbach",
|
||||
"aktiv": true,
|
||||
"aktiv": false,
|
||||
"base": "img/anzeigen/emons-stuebinger",
|
||||
"widths": [
|
||||
480,
|
||||
@@ -225,7 +225,7 @@
|
||||
{
|
||||
"slug": "sportfest-2026-plakat",
|
||||
"sponsor": "Sportfest 2026",
|
||||
"aktiv": true,
|
||||
"aktiv": false,
|
||||
"bis": "2026-09-06",
|
||||
"alt": "Eigenanzeige: Sportfest 2026 vom 4. bis 6. September auf unserer Sportanlage",
|
||||
"base": "img/anzeigen/sportfest-2026-plakat",
|
||||
@@ -239,7 +239,7 @@
|
||||
"slug": "sportfest-2026-programm",
|
||||
"sponsor": "Programm Sportfest 2026",
|
||||
"aktiv": true,
|
||||
"bis": "2026-09-06",
|
||||
"bis": "2026-09-03",
|
||||
"alt": "Eigenanzeige: Programm des Sportfests 2026 mit allen Spielen, Turnieren und der Kinderolympiade",
|
||||
"base": "img/anzeigen/sportfest-2026-programm",
|
||||
"widths": [
|
||||
@@ -247,6 +247,54 @@
|
||||
960,
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
},
|
||||
"ctas": []
|
||||
},
|
||||
"team_photo": { "base": "img/pages/jugend-team", "widths": [640, 1000], "alt": "Unsere Jugendabteilung des TSV 08 Kulmbach" },
|
||||
"about": {
|
||||
"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."
|
||||
},
|
||||
"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": "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": "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": "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": "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": "B", "name": "B-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } },
|
||||
{ "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": ["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 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: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/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": "A", "name": "A-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau. Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } }
|
||||
],
|
||||
"faq": [
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
"children": [
|
||||
{ "label": "Übersicht", "slug": "turnen" },
|
||||
{ "label": "Erwachsenenturnen", "slug": "turnen/erwachsenenturnen" },
|
||||
{ "label": "Eltern-Kind-Turnen", "slug": "turnen/eltern-kind-turnen" },
|
||||
{ "label": "Fitness und Pilates", "slug": "turnen/fitness-pilates" },
|
||||
{ "label": "Trampolin", "slug": "turnen/trampolin" },
|
||||
{ "label": "Eltern-Kind- & Kinderturnen", "slug": "turnen/eltern-kind-turnen" },
|
||||
{ "label": "Fitness & Pilates", "slug": "turnen/fitness-pilates" },
|
||||
{ "label": "Trampolinturnen", "slug": "turnen/trampolin" },
|
||||
{ "label": "Wettkampfturnen", "slug": "turnen/wettkampfturnen" }
|
||||
]
|
||||
},
|
||||
|
||||
120
data/turnen.json
@@ -45,29 +45,29 @@
|
||||
],
|
||||
"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": [
|
||||
{ "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": "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": "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": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"erwachsenenturnen": {
|
||||
"hero": {
|
||||
@@ -75,7 +75,7 @@
|
||||
"compact": true,
|
||||
"title": "Erwachsenenturnen",
|
||||
"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": []
|
||||
},
|
||||
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
||||
@@ -98,10 +98,9 @@
|
||||
"compact": true,
|
||||
"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.",
|
||||
"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": []
|
||||
},
|
||||
"layout": "blocks",
|
||||
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
||||
"lead": "Annelie Frisque",
|
||||
"notice_tone": "stop",
|
||||
@@ -120,7 +119,7 @@
|
||||
"recommendation": "Bei besonderem Potenzial für Wettkampf- oder Leistungsgruppen sprechen unsere Trainer eine Empfehlung aus.",
|
||||
"sessions": [
|
||||
{ "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*"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -151,21 +150,6 @@
|
||||
{ "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" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Flamingos",
|
||||
"level": "Turnen · fortgeschritten",
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -175,7 +159,7 @@
|
||||
"compact": true,
|
||||
"title": "Fitness & Pilates",
|
||||
"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": []
|
||||
},
|
||||
"location": { "venue": "Max-Hundt-Schule", "city": "Kulmbach" },
|
||||
@@ -183,13 +167,33 @@
|
||||
"groups": [
|
||||
{
|
||||
"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" } ],
|
||||
"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" } ],
|
||||
"trainers": ["Karola Lorz"]
|
||||
},
|
||||
{
|
||||
"name": "Functional Fitness II",
|
||||
"intro": "Jede Stunde ist anders, aber das Ziel bleibt gleich: ein starker, beweglicher und belastbarer Körper für deinen Alltag.",
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -204,18 +208,30 @@
|
||||
},
|
||||
"location": { "venue": "Dreifachturnhalle der Carl-von-Linde-Realschule", "city": "Kulmbach" },
|
||||
"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 ab September",
|
||||
"notice": "Mit dem Schulbeginn im September können neue Kinder bei uns gerne zum Schnuppern vorbeikommen. Schreib uns davor einfach kurz eine Nachricht, dann sagen wir dir, wann es am besten passt.",
|
||||
"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": [
|
||||
{
|
||||
"name": "Trampolinturnen",
|
||||
"name": "Kinder und Erwachsene",
|
||||
"sessions": [
|
||||
{ "day": "Montag", "time": "17:30 / 18:00–20:30 Uhr" },
|
||||
{ "day": "Donnerstag", "time": "17:30 / 18:00–20:30 Uhr" }
|
||||
],
|
||||
"note": "Der Einstieg erfolgt gestaffelt um 17:30 bzw. 18:00 Uhr.",
|
||||
"trainers": []
|
||||
{ "day": "Montag", "time": "17:30–19:30 / 20:30 Uhr", "trainers": ["Sabine Hofmann"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": []
|
||||
},
|
||||
"location": { "venue": "Mäximum Gym", "street": "Thurnauer Str. 51", "zip": "95326", "city": "Kulmbach" },
|
||||
"lead": "Franziska Trotzke",
|
||||
"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": [
|
||||
{ "day": "Montag", "time": "15:00–18:00 Uhr" },
|
||||
{ "day": "Mittwoch", "time": "15:00–18:00 Uhr" },
|
||||
{ "day": "Donnerstag", "time": "16:30–18:30 Uhr" },
|
||||
{ "day": "Freitag", "time": "14:00–17:00 Uhr" },
|
||||
{ "day": "Samstag", "time": "09:00–13:00 Uhr" }
|
||||
],
|
||||
"trainers": []
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
{
|
||||
"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",
|
||||
"title": "Sportfest 2026",
|
||||
@@ -14,6 +45,301 @@
|
||||
"rueckblick": null,
|
||||
"karte": "club",
|
||||
"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": [
|
||||
{
|
||||
"base": "img/veranstaltungen/sportfest-2026/plakat-01",
|
||||
|
||||
@@ -2676,6 +2676,24 @@
|
||||
/* Nur die Position im Bento-Raster — Fläche, Farbe und Innenaufbau kommen von .notice. */
|
||||
.turnen-dash__notice { grid-area: notice; }
|
||||
|
||||
/* Schlichte Gruppen als volle Bodenreihe MIT im Dashboard ($groupsInDash, z. B. Trampolin):
|
||||
ein zusammenhängender Block statt einer losgelösten Kartenreihe darunter. Selektor als
|
||||
.turnen-dash.turnen-dash--groups (nicht nur .turnen-dash--groups), damit die Spezifität die
|
||||
:not(--single)-Regeln oben trifft — gleiche Klassenzahl, gewinnt durch spätere Position. */
|
||||
.turnen-dash.turnen-dash--groups {
|
||||
grid-template-areas:
|
||||
"venue venue lead"
|
||||
"groups groups groups";
|
||||
}
|
||||
.turnen-dash.turnen-dash--notice.turnen-dash--groups {
|
||||
grid-template-areas:
|
||||
"notice notice notice"
|
||||
"venue venue lead"
|
||||
"groups groups groups";
|
||||
}
|
||||
/* Die Reihe spannt alle drei Spalten; das interne Kartenraster liefert .group-list--grid. */
|
||||
.turnen-dash__groups { grid-area: groups; }
|
||||
|
||||
/* Trainingsort: Adresse links, Route-Button rechts (wie team-bento__venue). */
|
||||
.turnen-dash__venue {
|
||||
flex-direction: row;
|
||||
@@ -2750,6 +2768,19 @@
|
||||
"lead"
|
||||
"venue";
|
||||
}
|
||||
.turnen-dash.turnen-dash--groups {
|
||||
grid-template-areas:
|
||||
"venue"
|
||||
"lead"
|
||||
"groups";
|
||||
}
|
||||
.turnen-dash.turnen-dash--notice.turnen-dash--groups {
|
||||
grid-template-areas:
|
||||
"notice"
|
||||
"venue"
|
||||
"lead"
|
||||
"groups";
|
||||
}
|
||||
}
|
||||
|
||||
/* === notice === */
|
||||
@@ -2869,6 +2900,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Versteckte Überschrift: dann fehlt der Zeile, die den Raum zwischen Dashboard und
|
||||
erster Karte gefüllt hat, ihr Inhalt. Übrig blieben 160px (80 unten von der
|
||||
Dashboard-Sektion + 80 oben von dieser), in denen nur nackte Hintergrund-Textur stand
|
||||
— gemessen und als Loch aufgefallen (31.07.2026). Die 80px des Dashboards reichen. */
|
||||
.group-list-section--tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Schlichte Gruppen (z. B. Kurse) als Raster, beschreibende Gruppen gestapelt. */
|
||||
.group-list--grid {
|
||||
display: grid;
|
||||
@@ -2901,7 +2940,10 @@
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: var(--space-4xl);
|
||||
align-items: start;
|
||||
padding: var(--space-3xl) var(--space-3xl);
|
||||
/* --pad-card-lg statt fester 40px, dieselbe Korrektur wie beim Tab-Panel:
|
||||
auf schmalen Handys stapeln sich Container- und Kartenabstand sonst so, dass
|
||||
dem Inhalt kaum Breite bleibt. Obergrenze ist der bisherige Wert. */
|
||||
padding: var(--pad-card-lg) var(--pad-card-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -3211,6 +3253,15 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Knöpfe unter einer Antwort (optionales ctas-Feld, siehe app/components/faq.php).
|
||||
flex-wrap, weil zwei Knöpfe auf einem schmalen Handy nicht nebeneinander passen. */
|
||||
.faq__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-s);
|
||||
margin-top: var(--space-m);
|
||||
}
|
||||
|
||||
/* Antwort beim Öffnen sanft einblenden (reduced-motion via reset.css deaktiviert). */
|
||||
.faq__item[open] .faq__a {
|
||||
animation: faq-reveal 260ms ease;
|
||||
@@ -4001,8 +4052,12 @@
|
||||
"venue"
|
||||
"stat";
|
||||
}
|
||||
/* Das Abteilungsfoto ist hochkant (1000×1333 = 3:4). Die Kachel bekommt
|
||||
genau dieses Verhältnis, damit object-fit: cover es passgenau füllt und
|
||||
niemand aus dem Gruppenfoto beschnitten wird (16/10 quer hatte hochkant
|
||||
auf einen Mittelstreifen zusammengeschnitten). */
|
||||
.intro-bento__image {
|
||||
aspect-ratio: 16 / 10;
|
||||
aspect-ratio: 3 / 4;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -5785,7 +5840,8 @@ a.match-row__link:hover {
|
||||
.stadionzeitung-kontakt,
|
||||
.stadionzeitung-partner,
|
||||
.stadionzeitung-momente,
|
||||
.stadionzeitung-historie {
|
||||
.stadionzeitung-historie,
|
||||
.stadionzeitung-aufruf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
@@ -5796,7 +5852,8 @@ a.match-row__link:hover {
|
||||
.stadionzeitung-kontakt .stadionzeitung-page__cta,
|
||||
.stadionzeitung-partner .stadionzeitung-page__cta,
|
||||
.stadionzeitung-momente .stadionzeitung-page__cta,
|
||||
.stadionzeitung-historie .stadionzeitung-page__cta {
|
||||
.stadionzeitung-historie .stadionzeitung-page__cta,
|
||||
.stadionzeitung-aufruf .stadionzeitung-page__cta {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -6040,6 +6097,25 @@ body.is-sz-full .back-to-top {
|
||||
}
|
||||
|
||||
|
||||
/* Titelgeschichte einer Sonderausgabe: das Fest statt der Begegnung. Coolvetica
|
||||
in Versalien wie der Masthead, aber kleiner — es soll die zweite Stimme auf
|
||||
dem Cover sein, nicht mit der Wortmarke konkurrieren. */
|
||||
.stadionzeitung-cover__feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.7cqi;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stadionzeitung-cover__feature-title {
|
||||
font-family: var(--ff-heading);
|
||||
font-size: 13cqi;
|
||||
line-height: 0.88;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 2px 10px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Vereins-Hero-Bild als Hintergrund (dieselbe Quelle wie die Startseite). */
|
||||
.stadionzeitung-cover__bg {
|
||||
position: absolute;
|
||||
@@ -6078,6 +6154,63 @@ body.is-sz-full .back-to-top {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Hauptplakat einer Sonderausgabe an der Stelle des Spielerfotos: rechts
|
||||
randabfallend, leicht schräg gelegt und an zwei gegenüberliegenden Ecken
|
||||
gerundet (links oben, rechts unten) — wie ein aufs Cover gelegter Aushang,
|
||||
nicht wie ein eingesetztes Bild (Felix, 03.09.2026).
|
||||
|
||||
Warum kleiner als das Spielerfoto (98cqi hoch): ein Freisteller ist schmal,
|
||||
ein Plakat im Hochformat ist bei gleicher Höhe knapp 60cqi breit und läge damit
|
||||
über der Typografie unten links. Bei 66cqi Höhe belegt es die rechte Bildhälfte
|
||||
und lässt Masthead und Fest-Titel frei — die Textspalte wird dafür schmaler
|
||||
(siehe --poster .stadionzeitung-cover__corner).
|
||||
|
||||
Schatten statt Rahmen, damit das Plakat auf dem Foto-Hintergrund abhebt:
|
||||
box-shadow ist unbedenklich, die Druck-Falle im Projekt betrifft text-shadow
|
||||
auf umbrechendem Text (siehe .stadionzeitung-cover__corner). */
|
||||
.stadionzeitung-cover__poster {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
/* VOLLSTÄNDIG im Bild, nicht randabfallend wie das Spielerfoto: ein Plakat
|
||||
trägt Text (Datum, Ort), und angeschnitten fehlte rechts genau der. Die
|
||||
Rotation lässt die Ecken trotzdem über die Passepartout-Linie laufen, das
|
||||
Plakat liegt also sichtbar „auf" der Seite. */
|
||||
/* Unten rechts randabfallend („angeschnitten", Felix 03.09.2026): das Plakat
|
||||
läuft über zwei Kanten hinaus und wirkt dadurch aufgelegt statt eingesetzt.
|
||||
Angeschnitten wird nur die untere rechte Ecke, nicht die Kopfzeile mit den
|
||||
Terminen — die bleibt lesbar. */
|
||||
right: -1.5cqi;
|
||||
/* bottom: 0 statt negativ — die Box endet an der Blattkante, angeschnitten
|
||||
wird nur noch die gedrehte Ecke (4 Grad auf 53cqi Breite = knapp 4cqi
|
||||
Überhang). Das hält den Anschnitt-Look und rückt das Plakat gleichzeitig
|
||||
aus dem unteren Bereich nach oben (Felix, 03.09.2026). */
|
||||
bottom: 0;
|
||||
/* Höhe ist NICHT frei wählbar: das Plakat darf die Textspalte links nicht
|
||||
berühren. Rechnung bei 4 Grad Drehung — belegte Breite = Höhe / 1,414
|
||||
(Hochformat) + sin(4°) × Höhe (Überhang der gedrehten Ecken) ≈ 0,78 × Höhe.
|
||||
Bei 72cqi sind das 56,2cqi; mit 1,5cqi Überhang rechts liegt die linke Kante
|
||||
bei 45,3% und die Textspalte endet bei 44%. Der Überhang ist bewusst winzig:
|
||||
vom Plakat bleiben so 97% sichtbar und seine Schlagzeile „Sportfest" steht
|
||||
ganz im Bild (Felix, 03.09.2026) — bei 9cqi Überhang fehlte rechts ein
|
||||
Siebtel und das Wort lief aus dem Bild. Angeschnitten wirkt es trotzdem,
|
||||
das macht die gedrehte Ecke unten. Höhe und Überhang hängen also
|
||||
zusammen: weiter nach links (kleinerer Überhang) heißt kleiner, größer
|
||||
heißt weiter nach rechts — oder die Textspalte muss schmaler
|
||||
(--poster .stadionzeitung-cover__corner). Nicht nur eine Zahl drehen. */
|
||||
height: 72cqi;
|
||||
width: auto;
|
||||
border-radius: 5cqi 0 5cqi 0;
|
||||
transform: rotate(4deg);
|
||||
box-shadow: 0 3cqi 6cqi rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
/* Schmaler als das Standard-Cover (56cqi): das Plakat belegt die rechte Hälfte.
|
||||
„Sportfest 2026" bricht dadurch zweizeilig — auf einem Cover trägt das eher,
|
||||
als es schadet. */
|
||||
.stadionzeitung-cover--poster .stadionzeitung-cover__corner {
|
||||
max-width: 38cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-cover__inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
@@ -6552,11 +6685,17 @@ body.is-sz-full .back-to-top {
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 4.4cqi;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stadionzeitung-termine__meta {
|
||||
font-size: 3.1cqi;
|
||||
color: var(--clr-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === termine-list (Termine/Events-Seite) === */
|
||||
@@ -6980,6 +7119,106 @@ a.news-feature__link:hover .news-feature__more {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* === stadionzeitung-gegner (Live-Seite) === */
|
||||
.stadionzeitung-gegner {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__duell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 2.5cqi;
|
||||
margin-bottom: 5cqi;
|
||||
padding: 3.3cqi;
|
||||
border: 1px solid var(--clr-glass-border);
|
||||
border-radius: var(--radius-btn);
|
||||
background: color-mix(in srgb, var(--clr-accent) 5%, #fff);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__club {
|
||||
--crest-size: 12cqi;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.7cqi;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__club strong {
|
||||
font-size: 3.2cqi;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__club--own strong {
|
||||
color: var(--clr-accent);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__match {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5cqi;
|
||||
font-size: 2.5cqi;
|
||||
color: var(--clr-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__match strong {
|
||||
font-size: 2.9cqi;
|
||||
color: var(--clr-text);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich {
|
||||
border-block: 1px solid var(--clr-glass-border);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich-head,
|
||||
.stadionzeitung-gegner__vergleich-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich-head {
|
||||
padding-block: 1.7cqi;
|
||||
font-size: 2.5cqi;
|
||||
font-weight: var(--fw-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--ls-caps);
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich-row {
|
||||
min-height: 6.5cqi;
|
||||
border-top: 1px solid var(--clr-glass-border);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich-row span {
|
||||
font-size: 2.8cqi;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich-row strong {
|
||||
font-family: var(--ff-heading);
|
||||
font-size: 4.8cqi;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__vergleich-row strong:first-child {
|
||||
color: var(--clr-accent);
|
||||
}
|
||||
|
||||
.stadionzeitung-gegner__empty {
|
||||
padding: 4.2cqi;
|
||||
border: 1px solid var(--clr-glass-border);
|
||||
font-size: 3.3cqi;
|
||||
color: var(--clr-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === stadionzeitung-vorschau (Live-Seite) === */
|
||||
/* Nächstes Spiel je Mannschaft nach dem Spieltag — Meta-Zeile + Begegnung in
|
||||
zwei Wappen-Zeilen (dasselbe Zeilen-Muster wie der Ergebnis-Rückblick),
|
||||
@@ -7131,19 +7370,31 @@ a.news-feature__link:hover .news-feature__more {
|
||||
}
|
||||
|
||||
.stadionzeitung-interview__qa {
|
||||
margin-bottom: 3.3cqi;
|
||||
margin-bottom: 2.1cqi;
|
||||
}
|
||||
|
||||
/* Bei einem langen Interview den Kopf-Abstand etwas straffen, damit die drei
|
||||
Fragen sicher auf eine A5-Seite passen (Trennlinie enger an den Text). */
|
||||
.stadionzeitung-interview .stadionzeitung-page__rule {
|
||||
margin-bottom: 2.4cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-interview__qa:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Frage größer als die Antwort, damit sie klar als Frage vorne steht (vorher
|
||||
gleich groß, dadurch zu leise — Felix). Abel hat nur einen fetten Schnitt,
|
||||
die Auszeichnung trägt also die GRÖSSE, nicht ein stärkeres Bold. Rot
|
||||
scheidet als Textfarbe aus (Kontrast auf Weiß < 4,5:1). Größe bewusst auf
|
||||
4.2cqi gedeckelt: bei einem langen Interview mit drei Fragen füllt es sonst
|
||||
die A5-Seite über die Kante (gemessen: 4.6cqi ergab 8px Überhang). */
|
||||
.stadionzeitung-interview__frage {
|
||||
margin-bottom: 0.8cqi;
|
||||
margin-bottom: 1cqi;
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 3.4cqi;
|
||||
line-height: 1.3;
|
||||
font-size: 4.2cqi;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.stadionzeitung-interview__antwort {
|
||||
@@ -7152,6 +7403,350 @@ a.news-feature__link:hover .news-feature__more {
|
||||
line-height: var(--lh-body);
|
||||
}
|
||||
|
||||
/* === stadionzeitung-aufruf (Live-Seite) === */
|
||||
/* Redaktioneller Handlungsaufruf (zuerst der Sportfest-Helferaufruf): Kopf-
|
||||
Muster + Trennlinie wie die Geschwister-Seiten, Vorspann in der Lese-Serif
|
||||
(wie Vorwort/Interview), darunter die Aufgaben-Liste und die Ansprechpartner
|
||||
(Namen + Nummern aus der geteilten Quelle), unten der geteilte CTA-Fuß mit
|
||||
QR aufs Ziel. Inhalt chat-gepflegt (aufruf-Feld der Ausgabe). */
|
||||
/* Intro + Aufgaben stehen normalerweise gestapelt (ohne Poster ist __cols ein
|
||||
schlichter Block, keine Layout-Änderung). Trägt die Ausgabe ein Poster,
|
||||
wird __cols zur Reihe: Text links, kleines Plakat rechts. */
|
||||
.stadionzeitung-aufruf__cols--poster {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4.2cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__cols--poster .stadionzeitung-aufruf__col-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Kleines Plakat rechts — Hochformat-Flyer, bewusst klein („weißt du was ich
|
||||
meine"): ein visueller Anker fürs Fest neben dem Aufruf, keine zweite große
|
||||
Anzeige. Feiner Rahmen wie ein aufgehängter Zettel, kein Schatten (druckt
|
||||
zuverlässiger, siehe Cover-Kommentar). */
|
||||
.stadionzeitung-aufruf__poster {
|
||||
flex: none;
|
||||
width: 30cqi;
|
||||
height: auto;
|
||||
border: 1px solid var(--clr-glass-border);
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__intro {
|
||||
margin-bottom: 4.2cqi;
|
||||
font-family: var(--ff-serif);
|
||||
font-size: 3.6cqi;
|
||||
line-height: var(--lh-body);
|
||||
}
|
||||
|
||||
/* Im Zweispalter trägt die Aufgabenliste die Unterkante der linken Spalte —
|
||||
kein Abstand mehr darunter, sonst steht das Plakat höher als der Text endet. */
|
||||
.stadionzeitung-aufruf__cols--poster .stadionzeitung-aufruf__task-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__tasks-title,
|
||||
.stadionzeitung-aufruf__contacts-intro {
|
||||
margin-bottom: 1.6cqi;
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 2.9cqi;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--ls-caps);
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__task-list {
|
||||
margin: 0 0 4.2cqi;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__task {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2.2cqi;
|
||||
padding-block: 1.9cqi;
|
||||
font-size: 3.8cqi;
|
||||
line-height: 1.3;
|
||||
border-bottom: 1px solid var(--clr-glass-border);
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__task:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Icon in Akzentrot als Fläche/Glyph (nie roter Fließtext, Kontrastregel):
|
||||
das Häkchen trägt Farbe, der Text bleibt schwarz. */
|
||||
.stadionzeitung-aufruf__task-icon {
|
||||
flex: none;
|
||||
font-size: 3.4cqi;
|
||||
color: var(--clr-accent);
|
||||
transform: translateY(0.3cqi);
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__contact-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__contact {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 3.3cqi;
|
||||
padding-block: 2.2cqi;
|
||||
border-bottom: 1px solid var(--clr-glass-border);
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__contact:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__contact-name {
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 3.8cqi;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__contact-phone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.4cqi;
|
||||
flex: none;
|
||||
font-size: 3.1cqi;
|
||||
color: var(--clr-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stadionzeitung-aufruf__contact-icon {
|
||||
font-size: 2.9cqi;
|
||||
color: var(--clr-accent);
|
||||
}
|
||||
|
||||
/* === stadionzeitung-programm (Live-Seite, Sportfest-Ausgabe) === */
|
||||
/* Tagesprogramm aus data/veranstaltungen.json → programm[] (dieselbe Liste wie
|
||||
die Website-Detailseite, hier fürs A5-Blatt gesetzt): je Tag ein Abel-Caps-
|
||||
Tagestitel, darunter Zeit | Titel. Die Zeit trägt links den roten Strich wie
|
||||
.veranstaltung-programm__zeit auf der Website — gleiche Sprache, andere
|
||||
Skala. Maße in cqi (Selbst-Containment, siehe .stadionzeitung-page). */
|
||||
/* Abstände bewusst straff: ein dreitägiges Fest bringt gut zwölf Punkte plus
|
||||
drei Tagestitel auf EIN A5-Blatt (gemessen: mit den üblichen 3.3cqi lief die
|
||||
Seite 59px über die Blattkante). Wer hier lockerer setzt, misst die
|
||||
Seitenhöhe nach. */
|
||||
.stadionzeitung-programm__tag {
|
||||
margin-bottom: 2.2cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__tag:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__tag-titel {
|
||||
margin-bottom: 0.9cqi;
|
||||
padding-bottom: 0.6cqi;
|
||||
border-bottom: 1px solid var(--clr-glass-border);
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 2.9cqi;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--ls-caps);
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__liste {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__punkt {
|
||||
display: grid;
|
||||
grid-template-columns: 11cqi minmax(0, 1fr);
|
||||
gap: 2.2cqi;
|
||||
align-items: baseline;
|
||||
padding-block: 0.5cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__zeit {
|
||||
padding-left: 1.4cqi;
|
||||
border-left: 2px solid var(--clr-accent);
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 3.3cqi;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__titel {
|
||||
display: block;
|
||||
font-size: 3.3cqi;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stadionzeitung-programm__text {
|
||||
display: block;
|
||||
margin-top: 0.3cqi;
|
||||
font-size: 2.7cqi;
|
||||
line-height: 1.3;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
/* === stadionzeitung-turnier (Live-Seite, Sportfest-Ausgabe) === */
|
||||
/* Ein Jugendturnier pro Blatt: Spielplan mit LEERER Ergebnisspalte
|
||||
(Schreiblinien) und leere Abschlusstabelle — das Blatt ist zum Eintragen am
|
||||
Platz gedacht, deshalb echte Schreibhöhe in den leeren Zellen. Eigenes Team
|
||||
wie league-table tr.is-own (rote Tönung + Abel-Fett). Tabellenmaße an
|
||||
.stadionzeitung-page .league-table angelehnt (2.7/2.3cqi). Der Modifier
|
||||
--dicht (ab neun Spielen, E-Jugend) rückt die Zeilen enger, sonst läuft die
|
||||
Abschlusstabelle über die Blattkante. */
|
||||
.stadionzeitung-turnier__label {
|
||||
caption-side: top;
|
||||
text-align: left;
|
||||
margin: 3.3cqi 0 1.2cqi;
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 2.9cqi;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--ls-caps);
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
p.stadionzeitung-turnier__label:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__teams {
|
||||
font-size: 3.1cqi;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__spiele,
|
||||
.stadionzeitung-turnier__tabelle {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 2.9cqi;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__spiele thead th,
|
||||
.stadionzeitung-turnier__tabelle thead th {
|
||||
padding: 0.8cqi 0.9cqi;
|
||||
border-bottom: 1px solid var(--clr-glass-border-strong);
|
||||
font-family: var(--ff-body);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 2.3cqi;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--ls-caps);
|
||||
color: var(--clr-text-muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__spiele td,
|
||||
.stadionzeitung-turnier__tabelle td {
|
||||
padding: 1cqi 0.9cqi;
|
||||
border-bottom: 1px solid var(--clr-glass-border);
|
||||
vertical-align: middle;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Dichte Variante ab neun Spielen: die E-Jugend spielt zehn Partien auf zwei
|
||||
Feldern und braucht darunter noch eine Tabelle mit fünf Zeilen. Mit den
|
||||
normalen Abständen lief das Blatt 69px über. Alle vier Stellen unten gehören
|
||||
zusammen — eine allein reicht nicht. */
|
||||
.stadionzeitung-turnier--dicht .stadionzeitung-turnier__spiele td {
|
||||
padding-block: 0.3cqi;
|
||||
}
|
||||
|
||||
/* Die Zeilenhöhe des Spielplans macht NICHT die Schriftgröße, sondern die
|
||||
Schreiblinie: sie ist das höchste Element in der Zeile. Bei zehn Spielen
|
||||
zählt deshalb nur diese Stelle (eine kleinere Schrift brachte gemessen
|
||||
einen einzigen Pixel). 2.8cqi sind auf A5 gut 4mm über der Linie, genug
|
||||
für zwei Ziffern mit dem Stift. */
|
||||
.stadionzeitung-turnier--dicht .stadionzeitung-turnier__blank {
|
||||
height: 2.8cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier--dicht .stadionzeitung-turnier__tabelle td {
|
||||
padding-block: 0.6cqi;
|
||||
}
|
||||
|
||||
/* 4cqi Zellhöhe sind auf A5 knapp 6mm plus Innenabstand — genug, um Tore und
|
||||
Punkte mit dem Stift einzutragen, und es passt neben zehn Spielzeilen. */
|
||||
.stadionzeitung-turnier--dicht .stadionzeitung-turnier__tabelle td:empty {
|
||||
height: 4cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier--dicht .stadionzeitung-turnier__label {
|
||||
margin: 1.8cqi 0 0.7cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier--dicht .stadionzeitung-turnier__teams {
|
||||
font-size: 2.9cqi;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__nr {
|
||||
width: 6cqi;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__zeit {
|
||||
width: 9cqi;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__paarung {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__vs {
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__team.is-own {
|
||||
padding-inline: 0.6cqi;
|
||||
border-radius: 0.6cqi;
|
||||
background: rgb(var(--clr-accent-rgb) / 0.16);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
/* Leere Ergebnisspalte: zwei Schreiblinien um den Doppelpunkt, unten
|
||||
ausgerichtet wie eine Zeile zum Ausfüllen. */
|
||||
.stadionzeitung-turnier__ergebnis {
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__blank {
|
||||
display: inline-block;
|
||||
width: 6cqi;
|
||||
height: 3.4cqi;
|
||||
border-bottom: 1px solid var(--clr-text-faint);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__colon {
|
||||
display: inline-block;
|
||||
padding-inline: 0.8cqi;
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
/* Schreibhöhe der leeren Tabellenzellen (Platzierung wird von Hand eingetragen). */
|
||||
.stadionzeitung-turnier__tabelle td:empty {
|
||||
height: 5.6cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-turnier__tabelle th:nth-child(n + 3),
|
||||
.stadionzeitung-turnier__tabelle td:nth-child(n + 3) {
|
||||
width: 9cqi;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === stadionzeitung-news (Live-Seite) === */
|
||||
/* Eigene Liste statt des geteilten news-feed von /news: feste Medienspalte
|
||||
(Wappen/Foto/Icon-Badge in EINER Größe), damit alle Titel bündig stehen —
|
||||
@@ -7564,6 +8159,57 @@ a.news-feature__link:hover .news-feature__more {
|
||||
color: var(--clr-text);
|
||||
}
|
||||
|
||||
/* === stadionzeitung-danke (Live-Seite) === */
|
||||
/* Dank an die Partner: oben die platzierte Anzeige (Motiv aus dem Seiteneintrag,
|
||||
siehe stadionzeitung-danke.php), darunter Kopf, Logo-Wand und der QR auf
|
||||
/partner-werden. Vier Logos pro Reihe in EINER festen Zellhöhe, damit
|
||||
unterschiedlich hohe Logos auf einer Linie sitzen — dieselbe Idee wie die
|
||||
feste Medienspalte auf der News-Seite. Maße in cqi (Selbst-Containment). */
|
||||
.stadionzeitung-danke {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.stadionzeitung-danke__anzeige {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 5cqi;
|
||||
border: 1px solid var(--clr-glass-border);
|
||||
}
|
||||
|
||||
.stadionzeitung-danke__head {
|
||||
margin-bottom: 3.3cqi;
|
||||
}
|
||||
|
||||
.stadionzeitung-danke__logos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 3.3cqi 2.5cqi;
|
||||
margin: 0 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stadionzeitung-danke__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Feste Zellhöhe: Logos sind teils quer, teils hoch — ohne sie tanzt die Reihe. */
|
||||
height: 11cqi;
|
||||
}
|
||||
|
||||
/* Logos liegen hier auf Weiß statt auf dem dunklen Website-Grund: contain statt
|
||||
cover, damit nichts beschnitten wird, und keine Tönung — ein Sponsorenlogo
|
||||
wird nicht umgefärbt. */
|
||||
.stadionzeitung-danke__logo img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* === stadionzeitung-momente (Live-Seite, Heft-Abschluss) === */
|
||||
/* 2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-Ticket — quadratische
|
||||
Kacheln wie im Instagram-Raster, bewusst ohne Bildunterschriften: die
|
||||
@@ -8395,4 +9041,3 @@ body.lightbox-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,7 +176,13 @@
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
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;
|
||||
/* Gleichmäßiges Padding rundum — Pillen stehen oben/unten/seitlich gleich dezent frei. */
|
||||
padding: var(--space-2xs);
|
||||
|
||||
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/instagram/Db-XCrOsd4q.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/assets/img/instagram/Db2ya5TDMLC.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/assets/img/instagram/Dbk0vKCs-p2.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/assets/img/instagram/Dbs_SENMqBr.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/assets/img/instagram/DcJDDkEMo0d.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/assets/img/instagram/DcPBlNMjKbP.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/assets/img/instagram/DcQa-pwshfy.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/assets/img/instagram/DcavewWMsVH.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/assets/img/instagram/DcjmUaMMJnD.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/assets/img/pages/jugend-team-1000.jpg
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
public/assets/img/pages/jugend-team-640.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 130 KiB |
BIN
public/assets/img/pages/turnen-eltern-kind-1536.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 113 KiB |
BIN
public/assets/img/pages/turnen-erwachsenenturnen-1536.jpg
Normal file
|
After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 119 KiB |
BIN
public/assets/img/pages/turnen-fitness-pilates-1536.jpg
Normal file
|
After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 655 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 36 KiB |
BIN
public/assets/img/persons/kerstin-tauber.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |