Compare commits

...

61 Commits

Author SHA1 Message Date
fs
e761231442 Regel ergaenzen: nach dem Veroeffentlichen auf dem Server committen und pushen
Der Snapshot und die eingefrorenen fix-*-Bilder existieren nach
bin/stadionzeitung-publish.php zunaechst nur auf dem Server. Bleiben sie dort
uncommittet, scheitert der naechste Pull, und wer die Aenderung verwirft,
verliert veroeffentlichte Ausgaben. Am 03.09.2026 mit drei Ausgaben passiert;
der Reparaturweg (Server als Wahrheit, Zusammenfuehren je Slug) steht jetzt mit
dabei.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 11:13:33 +02:00
0a01134583 Sportfest-Ausgabe veroeffentlicht 2026-09-03 11:12:31 +02:00
5854e452f7 Veroeffentlichte Ausgaben und eingefrorene Bilder vom Server sichern 2026-09-03 11:11:29 +02:00
fs
f2f193b223 Sportfest-Ausgabe der Stadionzeitung zum Heimspiel gegen ATS Wartenfels
Neue Live-Seiten, geschaltet vom chat-gepflegten Feld `veranstaltung` (Slug aus
data/veranstaltungen.json) — Programm und Turnierpläne kommen damit aus derselben
Quelle wie die Website, nicht als Kopie ins Heft:

- Programm-Seite (programm[], rendert zugleich auf /veranstaltungen/sportfest-2026)
- vier Turnierplan-Seiten (turniere[]) mit leerer Ergebnisspalte und leerer
  Abschlusstabelle zum Eintragen am Platz
- Gegnerseite je Mannschaft mit Heimspiel am Fest statt nur der tragenden:
  stadionzeitung_gegner_fixtures() ist die einzige Stelle, die das entscheidet,
  Seitenplanung und Snapshot lesen beide dort. `gegner` ist deshalb eine Map je
  Team-Key; die Vorschau überspringt jedes Spiel mit eigener Gegnerseite
- Danke-Seite mit Partner-Logo-Wand aus data/partners.json und QR auf
  /partner-werden, dazu das Anzeigen-Feld `platzierung` für Motive mit festem
  Platz auf einer Live-Seite (Emons über der Logo-Wand). Vorher klebte der Dank
  als leere Fläche unten an einem Anzeigen-Bild
- Cover einer Sonderausgabe: das Fest als Titelgeschichte statt einer der drei
  Begegnungen, dazu das Hauptplakat schräg gelegt an Stelle des Spielerfotos

Ausgabe 2026-09-04-ats-wartenfels als ENTWURF angelegt, 48 Seiten (12 Bögen),
Tabellen auf gerader Seite, längster Anzeigenblock zwei. Veröffentlicht wird auf
dem Server, dort liegt der frische Matchcenter-Stand.

Nebenbei behoben: bin/anzeige-add.php räumte beim Anlegen alle Motive weg, deren
Slug mit demselben Text beginnt (`emons` löschte `emons-partnerdank` und
`emons-stuebinger`). Muster jetzt auf die Breiten-Endung eingeschränkt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 10:57:52 +02:00
fs
b4d2cde149 Jugend-Bento: Abteilungsfoto mobil ungeschnitten (3:4 statt 16:10)
Das hochkante Gruppenfoto (1000x1333) wurde auf dem Handy in eine
16:10-Querkachel gecovert und dadurch auf einen Mittelstreifen
zusammengeschnitten. Die Mobil-Kachel bekommt jetzt das echte 3:4-Format,
sodass object-fit: cover passgenau fuellt und niemand beschnitten wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WACMqwVB4JpPW5Wkz1Y1DU
2026-09-01 08:53:39 +02:00
fs
0d80f69a76 Veranstaltung Tagesskifahrt 2027 anlegen (Hochzillertal, 20.02.)
Auswärts-Event ohne Karte (kein karte:club), Preise und Anmeldung als
lesbarer Text für Screenreader/Suche, Plakat über bin/veranstaltung-bilder.php.
Direkt veröffentlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tTL6VE17FgQhhaH4Pc8h
2026-08-31 21:05:26 +02:00
fs
6313b98f9e Jugend: neue Ansprechpartner-Fotos, Abteilungsbild, aktuelle Trainingszeiten
- Fabi & Kerstin als 480x600-Porträts (Kopf/Schultern, oben ausgerichtet),
  Kerstin in ansprechpartner.json verknüpft
- gemeinsames Jugendbild als Intro-Bento-Bild (ersetzt die Hero-Dublette)
- Trainingszeiten auf Fabis Stand, C/B-Jugend zusammengefasst, B-Karte entfällt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tTL6VE17FgQhhaH4Pc8h
2026-08-31 20:59:51 +02:00
fs
f88b5f1dd1 Stadionzeitung gegen ASV Marktschorgast vorbereiten 2026-08-28 13:11:43 +02:00
fs
736a588a88 Turnen: neue Fotos + Fitness/Pilates zu Functional Fitness I/II umgebaut
Feedback der Turnabteilung (Batch 13.–18.08.):
- Kinderturnen, Erwachsenenturnen und Fitness/Pilates bekommen echte
  (Symbolbild-)Fotos in Kachel UND Detail-Hero (statt Luftbild-Platzhalter),
  responsive 640/1000/1536, og_image der drei Seiten mitgezogen.
- fitness-pilates: „Fitness & Stretching" -> „Functional Fitness I" (Mi),
  neuer Kurs „Functional Fitness II" (Do, Uhrzeit folgt), Beschreibungen für
  Pilates und beide Functional-Fitness-Kurse, gestapeltes Layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GueFkHGX17MqfsoraXwGM
2026-08-19 22:29:59 +02:00
fs
550616aacf Stadionzeitung: langes Interview passt wieder auf die A5-Seite
Die vergrößerte Interview-Frage (4.6cqi) schob eine Ausgabe mit drei Fragen um
8px über die Blattkante (gemessen). Frage auf 4.2cqi gedeckelt (bleibt größer
als die Antwort) und Abstände von Frage/Absätzen/Trennlinie leicht gestrafft —
Text unverändert, passt jetzt mit Reserve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFnFYLm2MoBskyk9mc84ZW
2026-08-13 17:35:44 +02:00
fs
c688d155cc Stadionzeitung Ausgabe 2 (TSV Ködnitz, 15.08.): Entwurf + neue Aufruf-Seite
- Neue generische Live-Seite `aufruf` (erste Nutzung: Sportfest-Helferaufruf):
  Komponente, Dispatcher, Skript-Einbindung (Planung im Aufschlag, QR aus
  aufruf.link, Feld-Carryover), CSS, optionales kleines Poster rechts.
- Ausgabe 2026-08-15-tsv-koednitz als ENTWURF: Vorwort (Benedikt, Revanche
  dezent), Interview (Ali, 3 Fragen), Sportfest-Helferseite mit Kontakten aus
  ansprechpartner.json; Rückseite/Cover wie Hutschdorf wiederverwendet.
- Interview-Frage größer/prominenter gesetzt.
- Sportfest-Plakat-Anzeige deaktiviert (redundant zur Helferseite) -> 40 Seiten.

Sync-Dateien (instagram/matchcenter) bleiben gitignored, Snapshot wird beim
Veröffentlichen auf dem Server eingefroren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFnFYLm2MoBskyk9mc84ZW
2026-08-13 17:27:41 +02:00
fs
1839632b66 Turnen: schlichte Gruppen als Bodenreihe direkt ins Bento (groupsInDash)
Neue opt-in-Prop groupsInDash in group-schedule zieht schlichte Gruppen
(ohne intro/details) als volle Bodenreihe MIT ins Dashboard-Bento, ohne
eigene Sektion und ohne "Unsere Gruppen"-Ueberschrift. Trampolin nutzt
das, damit Hinweis, Trainingsort, Kontakt und die drei Gruppen-Karten ein
zusammenhaengender Block sind statt einer losgeloesten Kartenreihe darunter.
Wiederverwendung von .group-list--grid/.group-card, Karten-Markup DRY in
renderSimpleCard. Wettkampfturnen/Kinderturnen bleiben unberuehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXDvgRDHQ9iGjs4KCXykEj
2026-08-03 19:58:39 +02:00
fs
d410c0bedf Trampolin: echte Trainingszeiten, Trainerinnen und Kontakt vom Hallen-Aushang
Drei Karten nach Zielgruppe (Kinder+Erwachsene Mo, Kinder ab 5 Do,
Kinder ab 10 und Fortgeschrittene Do) mit Tag/Zeit und Trainerteam je
Session, Schnupper-Hinweis auf "nach Absprache" umgestellt, Fußnote zu
den Schulferien. group-schedule bekommt zwei optionale Props
(contactEmail/contactPhone) fuer Tinas persoenlichen Kontakt in der
Kontaktkachel (tel:-Muster wie person-card, keine neue CSS-Regel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXDvgRDHQ9iGjs4KCXykEj
2026-08-03 19:49:25 +02:00
fs
133c3f926e „Trainerteam" statt „Übungsleitung", Eva Wünsch mit Sternchen
Eva Wünsch aus der Turnabteilung am 31.07.2026: „Der Sternchentext müsste
eigentlich sein, dass die Person mit dem Sternchen Übungsleiter ist. Von daher
müsste vor den Namen 'Trainerteam' stehen. Einige sind auch nicht ÜL."

Ich hatte davon nur die Fußnote erledigt. Das Label blieb „Übungsleitung" und
damit stand die falsche Aussage weiter auf der Seite: neun namentlich genannte
Personen liefen unter „Übungsleitung", vier davon sind es. Das ist keine
Formulierungsfrage, die Seite behauptete etwas über echte Menschen.

Label jetzt „Trainerteam:" an beiden Stellen (Trainingszeit und Gruppe), der
Stern bleibt die Auszeichnung und die Fußnote erklärt ihn. So stand es auch auf
der alten Seite: „TRAINERTEAM (* = ÜBUNGSLEITER)". Ein Kommentar an der Stelle
sagt, warum es nicht zurückbenannt werden darf.

Dabei fiel ein zweiter Datenfehler auf: die alte Seite führt „Annelie Frisque*,
Eva Wünsch*", bei uns stand Eva Wünsch ohne Stern. Sie ist Übungsleiterin.

Geprüft: „Übungsleitung" kommt auf keiner der sechs Turn-Seiten mehr vor, die
Grashüpfer-Zeile lautet jetzt Zeichen für Zeichen wie in Evas Screenshot. Die
Fußnote erscheint nur in Sektionen mit mehreren Gruppen — nachgezählt, dass
beide Disziplinen mit Sternchen-Namen mehrere Gruppen haben, die
Einzelgruppen-Disziplinen tragen keine Sterne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:57:31 +02:00
fs
146471bae8 Wettkampfturnen: Karten offen untereinander statt hinter Tabs
Bei zwei Gruppen verstecken Tabs mehr Inhalt als sie ordnen, und die offene
Kartenreihe hält den Bento-Eindruck der Seite (Entscheidung Felix). Das
Kinderturnen bleibt bei Tabs, dort sind es drei textreiche Gruppen — die
Flamingos-Karte allein ist am Handy 2142px hoch.

.group-list--stacked lag vollständig im CSS, wurde aber von KEINEM PHP
ausgegeben: totes CSS aus der Zeit vor den Tabs, das heute morgen sogar noch
meine minmax(0, …)-Korrektur mitbekommen hat, ohne dass es jemand sehen konnte.
Jetzt ist es lebendig. Dritter toter Rest heute: "layout": "blocks" in
data/turnen.json las ebenfalls niemand, ist raus.

Der Karteninhalt steht dafür jetzt einmal statt zweimal. Er wird in zwei
Fassungen gebraucht (Tab-Panel mit role/aria und gestapelt ohne) und ist
deshalb eine Closure geworden — eine zweite Kopie wäre die Stelle, an der beide
in einem halben Jahr auseinanderlaufen.

Die Überschrift ist visually-hidden, nicht entfernt: sonst hätte die Sektion
keinen zugänglichen Namen und die Überschriften-Gliederung eine Lücke. Dasselbe
Muster wie $title_hidden in team-cards. Damit braucht die Seite auch kein
group-tabs.js mehr, das ich zwei Commits vorher erst dazugeschrieben hatte.

Zwei Nachbesserungen, beide gemessen:
- Die versteckte Überschrift hinterließ ein Loch. Zwischen Dashboard und erster
  Karte standen 160px (80 unten von der einen Sektion, 80 oben von der anderen),
  in denen nur nackte Hintergrund-Textur zu sehen war — vorher füllte die
  Überschrift diesen Raum. .group-list-section--tight nimmt das obere Padding
  weg, jetzt 80px.
- Die gestapelten Karten hatten feste 40px Innenabstand; auf --pad-card-lg
  umgestellt, dieselbe Korrektur wie heute morgen beim Tab-Panel. Am Desktop
  unverändert 40px, am Handy 28px.

Nicht behoben, weil kein Fehler: die Hintergrund-Textur wirkt in leeren Flächen
weich. Sie ist 1920x2545 groß und wird bei 1478px Fensterbreite auf Faktor 0,77
VERKLEINERT, ist also nicht unscharf gerechnet. Sichtbar wurde sie durch den
Leerraum plus background-attachment: fixed, das sie beim Scrollen stehen lässt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:56:21 +02:00
fs
f3f004d13e Neues Trampolin-Kachelbild nachgezogen
Die Datei lag geändert im Arbeitsverzeichnis (640×381 statt 640×425, 53 statt
128 KB) und war das einzige Stück, das noch fehlte: die zugehörige Änderung an
app/pages/turnen.php (widths auf [640], Kommentar zur WhatsApp-Quelle) und das
Entfernen der 1000er und 1600er Variante stecken bereits in 592acf0, wo sie
durch mein `git add app/pages/turnen*.php` mitgekommen sind. Ohne diesen Commit
zeigte das Repo das alte Bild bei neuer Deklaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:40:25 +02:00
fs
7bbf783562 Flamingos zum Wettkampfturnen, Inhalte gegen die alte Seite abgeglichen
Die Flamingos sind organisatorisch vom Kinderturnen zum Wettkampfturnen
gewechselt. Felix hat dazu die Fassung geschickt, die die Turnabteilung selbst
auf der alten Seite gepflegt hatte — dabei kamen sieben Abweichungen heraus,
alle korrigiert:

- Die zweite Gruppe heißt "Leistungsturnen", nicht "Wettkampfturnen" (so hieß
  bei uns die Gruppe wie die Disziplin).
- Reihenfolge: Flamingos zuerst, Leistungsturnen danach.
- Den Flamingos fehlte eine Trainingszeit: Montag gibt es ZWEI Blöcke
  (18:00–19:00 und 19:00–20:00), wir hatten nur den ersten.
- Ansprechpersonen sind pro Gruppe verschieden (Flamingos: Theresa Diener,
  Leistungsturnen: Franziska Trotzke). Dafür ist `contact` neu an einer Gruppe.
  Die Kontakt-Kachel oben kann nur EINE Person nennen und wäre für die andere
  Gruppe falsch — deshalb ist `lead` bei dieser Disziplin jetzt leer, die Kachel
  trägt nur noch die Abteilungs-Mail. So war es auf der alten Seite auch: dort
  gab es keine Ansprechperson für die Disziplin, nur je Gruppe.
- Theresa Diener steht im Trainerteam vorn und ohne Stern (bei uns hinten).
- Intro, Trainingsinhalte, Wettkämpfe und der Hinweis waren gekürzt; jetzt im
  Wortlaut der Abteilung. Den Gedankenstrich in "(Kür modifiziert –
  Leistungsklasse 3)" ersetzt ein Komma (Sprache & Ton).
- Die Fußnote sagte "* So im Trainingsplan der Abteilung gekennzeichnet" — eine
  Verlegenheitsformulierung, weil wir die Bedeutung nicht kannten. Die alte
  Seite schreibt "* = ÜBUNGSLEITER", also jetzt "* Übungsleiterinnen und
  Übungsleiter" (Paarform, Haus-Stil).

`level` bleibt "Turnen · fortgeschritten" ohne "Alter nach Absprache" — das
hatte Felix am 31.07.2026 ausdrücklich aus dem Chip genommen.

Zwei Folgen, die sonst still kaputtgegangen wären: die Seite hatte eine einzige
Gruppe ohne Beschreibung und lief damit im Einzelgruppen-Layout. Mit zwei
Gruppen, davon eine mit Beschreibung, rendert group-schedule eine Tab-Leiste —
die braucht group-tabs.js, das die Seite nicht lud (tote Tab-Knöpfe). Und drei
Flamingos-Trainerinnen tragen ein Sternchen, die erklärende Fußnote wurde auf
dieser Seite nicht übergeben (drei unerklärte Sterne).

Geprüft: Tabs "Flamingos" und "Leistungsturnen", Skript geladen, Fußnote da,
Flamingos auf /turnen/eltern-kind-turnen nicht mehr erwähnt, kein Überlauf bei
390px, alle sechs Turn-Seiten 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:40:25 +02:00
fs
f21224ea84 Nav-Labels an die Seitentitel angeglichen, Dropdown-Breite nachgerechnet
Im Menü stand „Eltern-Kind-Turnen", die Seite heißt „Eltern-Kind- &
Kinderturnen" — das Kinderturnen fehlte also im Menü, obwohl Eltern genau
danach suchen. Beim Prüfen fielen zwei weitere Abweichungen auf, alle drei
sind jetzt gleich:

  Eltern-Kind-Turnen  → Eltern-Kind- & Kinderturnen
  Fitness und Pilates → Fitness & Pilates
  Trampolin           → Trampolinturnen

Punkte („…") braucht es dafür nicht: das Dropdown hat min-width und
white-space: nowrap, das Panel wächst also mit dem längsten Punkt statt zu
beschneiden. Ein abgeschnittener Menüpunkt wäre auch die schlechtere Lösung,
weil man dann nicht mehr liest, wohin der Link führt.

Es war aber knapper als es aussah. Gemessen mit dem längsten Punkt (187,6px):
ruhend 190,0px Platz, im Hover UND auf der aktiven Seite nur 186,0px, weil
padding-left dort von --space-s (16px) auf --space-m (20px) wächst. Das Panel
wäre beim Rübergehen um zwei Pixel gewachsen und auf dieser Seite dauerhaft
breiter gewesen — sichtbar als Flackern, weil es mittig sitzt und sich nach
beiden Seiten schiebt.

Behoben mit einem Wert: min-width 15rem → 15,5rem. Alle drei Panels bleiben
jetzt in jedem Zustand bei 248px, mit 8,4px Luft statt 1,6px Fehlbetrag. Auch
bei 1250px Viewport (der schmalsten Breite mit Desktop-Menü) liegt kein Panel
am Rand. Die Rechnung steht als Kommentar über der Regel, damit der nächste
längere Menüpunkt nachgemessen wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:31:58 +02:00
fs
592acf02b0 Turn-Anmeldung als eigene FAQ-Frage, Knöpfe in FAQ-Antworten möglich
Der Anmelde-Vorgang der Abteilung (erst Platz anfragen, dann Aufnahmeantrag)
stand nirgends auf der Seite. Auf der alten Seite gab es dafür einen Block
„ANMELDUNG" mit zwei Knöpfen.

Erst als eigene Sektion unter den Beiträgen gebaut (cta-band), auf Wunsch von
Felix wieder entfernt und stattdessen als eigene FAQ-Frage umgesetzt. Zwei
Sektionen mit denselben Knöpfen auf einer Seite schwächen beide Stellen, und
man weiß nicht mehr, welcher der richtige Weg ist. Deshalb ist auch
overview.cta ganz aus data/turnen.json raus statt unbenutzt liegen zu bleiben —
genau so ein toter Block hat heute schon einmal in die Irre geführt (die Notiz
„NOCH unbenutzt" stand monatelang im Docblock von turnen.php).

faq.php bekommt dafür ein optionales ctas-Feld pro Eintrag, gerendert über die
bestehende cta-Komponente, also keine neue Knopf-Variante. .faq__actions
bricht um, weil zwei Knöpfe auf einem schmalen Handy nicht nebeneinander passen.

Die Antwort nennt beide Wege bewusst AUCH in Worten: faq_schema() liest nur q
und a, Knöpfe landen nie im FAQPage-Schema. Stünde in der Antwort nur „so
geht's" plus zwei Knöpfe, hätte Google eine Antwort ohne Ziel. Nachgeprüft:
sechs Fragen im Schema, der neue Antworttext ist HTML- und Markup-frei.

Gemessen bei 390px: kein Überlauf, Knöpfe 130x44 und 193x44 (Trefferfläche
erfüllt). Die fünf Turn-Unterseiten hatten als einzige von 105 PHP-Dateien
keinen Zeilenumbruch am Dateiende, das ist mitkorrigiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:31:57 +02:00
fs
63a045835e Austritt Turnen: Kündigung darf formlos per Mail kommen
Rückmeldung der Turnabteilung. Der Austritt-Block auf /turnen nannte bisher nur
die Schriftform und die Frist zum 30. September, nicht aber, wohin die Kündigung
geht.

Die Adresse steht wie überall ausschließlich in club.json (email_turnen), der
Satz in turnen.json markiert ihre Stelle nur mit {mail} — dasselbe Muster wie
der Material-Aufruf auf /historie. Ersetzt wird nach inline_links_html(), damit
der Fließtext escaped bleibt und nur der Platzhalter zu einem mailto-Link wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoqDYuANFbJDGFQAgaRdT
2026-07-31 22:26:37 +02:00
fs
06a18a2d19 301-Weiterleitungen der alten CMS-Adressen
Der Aufnahmeantrag der Turnabteilung ging als alte URL in den Einladungen an
die neuen Familien raus und lief seit dem Umzug in einen 404, ebenso der Link
auf die Turn-Seite. Beide leiten jetzt weiter, dazu alle uebrigen Seiten der
alten Navigation unter /08/de/.

Die PDF-Regel matcht am Umlaut vorbei: der Dateiname kommt je nach Mailprogramm
als NFC, als NFD (so stand er in der alten Datenbank) oder roh als UTF-8 an.
Die Sammelregel wirft den alten Query-String weg, sonst haengt mod_rewrite
?group_filter_id=14 ans neue Ziel.

Erledigt damit den offenen Punkt F3 aus docs/deploy.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoqDYuANFbJDGFQAgaRdT
2026-07-31 22:11:08 +02:00
fs
e7572b34d6 Trampolin: Schnupper-Einladung, Hinweise bekommen zwei Tonlagen
Info von Eva aus der Trampolin-Gruppe: neue Kinder können ab September
(Schulbeginn) gerne schnuppern kommen. Die Info stand nirgends.

Rot wäre dafür falsch gewesen. Die Farbe bekommt deshalb eine Bedeutung:
--stop (gefüllt rot) heißt „geht nicht", --info (Glas-Fläche, Rot nur im Icon)
heißt „geht". Eine Einladung in Alarmrot liest sich wie eine Absage, und wenn
alles rot ist, stumpft Rot ab und der echte Wartelisten-Stopp bei „Tiger" und
„Hühner" verliert seine Wirkung. --info ist ab jetzt der Default.

Damit steht der Hinweis an drei Stellen, also greift die Bedingung aus dem
letzten Commit („kommt ein dritter Ort dazu, gehören die gemeinsamen
Eigenschaften in eine geteilte Klasse"): neu app/components/notice.php als
einzige Render-Stelle statt dreimal dasselbe Markup. Die beiden bestehenden
Klassen schrumpfen auf das, was sie wirklich beitragen —
.turnen-dash__notice nur noch grid-area, .contact__notice nur noch die
Formularbreite. Die tote .turnen-dash__notice-icon-Regel ist raus, der
CSS-Abschnitt neu sortiert (notice als eigener Block hinter turnen-dash statt
mitten hinein).

Text, Tonlage und Icon stehen wie gehabt in data/turnen.json bei der
Disziplin, nicht im Template. Kinderturnen trägt jetzt explizit
notice_tone: stop, Trampolin nichts und ist damit neutral.

Das Grid-Template für --notice + --single gab es schon, an beiden
Breakpoints — die Trampolin-Seite hat eine Gruppe und wäre sonst
auseinandergefallen.

Gemessen bei 390px: kein Überlauf auf allen drei Seiten, Trampolin-Fläche
rgba(0,0,0,0.22) (Glas), die beiden anderen #e20612. Zwölf Seiten geprüft,
alle 200. preflight ohne neue Meldungen.

OFFEN, mit Eva oder Tina zu klären: der zweite Satz („Schreib uns davor
einfach kurz eine Nachricht") ist von mir, Eva hat ihn nicht gesagt. Wenn die
Kinder einfach zum Training kommen dürfen, gehört er weg. Und die Einladung
steht bisher nur auf /turnen/trampolin, nicht auf der Übersicht /turnen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:04:41 +02:00
fs
921a55635c Wartelisten-Hinweis für Tiger und Hühner, über dem Anfrageformular
Die Turnabteilung (Maxi) beantwortet laufend Anfragen nach einem Wartelistenplatz
für „Tiger" und „Hühner", den es nicht gibt. Auf der alten Seite stand der Hinweis
direkt über dem Anfrageformular, hier fehlte er.

Schlimmer: an der Stelle stand das GEGENTEIL. Der bisherige Hinweis lautete
"Aktuell sind alle Kinderturngruppen belegt. Bei Interesse setzen wir sie gerne
auf die Warteliste." — für alle Kindergruppen, also auch für die beiden, die
keine Liste führen. Der Satz hat einen Teil der Anfragen vermutlich erst erzeugt.
Er ist nicht gelöscht, sondern auf die übrigen Gruppen eingegrenzt und aufs Du
umgestellt (Haus-Stil; vorher stand dort klein "sie").

Der Text steht genau EINMAL, in data/turnen.json unter
disciplines.eltern-kind-turnen.notice + notice_title. Zwei Stellen lesen ihn:
die rote Kachel auf /turnen/eltern-kind-turnen (gab es schon) und neu ein Block
direkt über dem Formular auf /turnen. Ändert die Abteilung die Lage, ist das eine
Zeile in einer Datei.

Umsetzung:
- notice_title ist neu, weil eine rote Fläche ohne Überschrift wie eine
  Fehlermeldung liest. Abel statt Coolvetica, der Schnitt ist bei ~19px in
  Versalien unleserlich (CLAUDE.md, Design).
- Der Block steht AUSSERHALB des <form> und damit außerhalb der
  role="status"-Region: ein dauerhafter Hinweis, keine Rückmeldung zum Absenden.
  In der Live-Region läse ihn ein Screenreader bei jeder Formularmeldung erneut
  vor. Kein role="alert", er steht schon beim Laden da.
- Überschrift als h3 unter der h2 des Intro-Blocks, damit die Folge nicht springt.
- .contact__notice übernimmt bewusst die Optik von .turnen-dash__notice, damit
  beide Stellen als derselbe Hinweis erkennbar sind. Weiß auf --clr-accent
  erreicht 4,92:1 und trägt damit Fließtext (WCAG 1.4.3). Kommt ein dritter Ort
  dazu, gehören die gemeinsamen Eigenschaften in eine geteilte .notice-Klasse;
  für zwei wäre das verfrüht.

Gemessen: Hinweisbreite gleich Formularbreite (720px bei 1280, 347px bei 390),
kein Überlauf. preflight ohne neue Meldungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:54:10 +02:00
fs
25f36e5a53 Karten-Innenabstände fluid: mehr Platz auf schmalen Handys
Auf schmalen Bildschirmen stapeln sich Container- und Kartenabstand so, dass für
den Inhalt kaum Breite bleibt. Gemessen bei 309px: Container 11,5 + Karte 32 je
Seite, das Eingabefeld hatte 220px, also 71% der Bildschirmbreite.

Kein harter Breakpoint ("ab 310px vollflächig"), sondern fluide Abstände — ein
Umbruch bei 310px sähe bei 311px wieder falsch aus. Das Muster gab es im Projekt
schon: fee-table, membership-card, legal__card und matchcenter-empty nutzen
clamp()-Abstände. Fest waren genau die drei, über die es stolperte: .form (32px),
.group-card (32px) und .group-tabs__panel (40px).

Zwei neue Token in tokens.css als einzige Quelle, --pad-card (20…32px) und
--pad-card-lg (24…40px). Die OBERGRENZEN sind die bisherigen festen Werte, am
Desktop ändert sich also nichts — nachgemessen: 32px bei 1269px Viewport, wie
vorher. Es wird nur nach unten schmaler.

Ergebnis: Feldbreite bei 309px von 71% auf 76%, bei 379px von 74% auf 80%.
Kein Überlauf auf /turnen, den Disziplinseiten und /kontakt bei 320px und 1280px.

Die vier bereits fluiden Karten behalten ihre eigenen clamp()-Werte, damit ihr
Desktop-Bild unangetastet bleibt; sie in die Token einzusammeln ist eine
Aufräumung für später.

VORBESTEHEND und nicht von dieser Änderung verursacht (per git stash gegengeprüft,
mit und ohne Änderung identisch): /mitglied-werden läuft bei 320px um 12px über,
/turnen bei 300px um 13px. Die 320px sind eine echte Gerätebreite (iPhone SE),
das gehört als Nächstes angeschaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:34:29 +02:00
fs
6a9244d847 Turn-Gruppenkarten: fr-Spuren begrenzen (Textumbruch bei 300px)
Bei sehr schmalen Breiten stand der Text über den Kartenrand hinaus. Gemessen bei
300px Viewport (clientWidth 289): Dokument 317px, also 28px Überlauf; die einzelne
Grid-Spur des Panels rechnete sich auf 267,7px aus, obwohl die Karte nur 193px
Inhaltsfläche hat.

Ursache: `grid-template-columns: 1.6fr 1fr` bzw. `1fr` — eine fr-Spur hat als
automatisches Minimum min-content und kann deshalb nicht schmaler werden als das
längste Wort darin. Bei deutschen Komposita ("sportartenübergreifende",
"Grundlagenausbildung") sprengt das die Karte. Jetzt minmax(0, …) in beiden
Spurdefinitionen von .group-tabs__panel, und dieselbe Korrektur bei
.group-list--stacked, wo das Muster latent drinsteckte.

Zwei Umwege, die ich mir hätte sparen können: min-width: 0 am Grid-KIND reicht
nicht, die SPUR muss das Schrumpfen erlauben. Und overflow-wrap: break-word
verkleinert die min-content-Breite nicht (nur `anywhere` täte das) — beides bleibt
trotzdem drin, zusammen mit hyphens: auto, weil die Seite lang="de" ist und der
Browser die Komposita dann sauber trennt statt hart umzubrechen.

Geprüft: alle sechs Turn-Seiten bei 300px, 320px und 390px ohne Überlauf. Desktop
unverändert zweispaltig (648,6 / 405,4 px, Verhältnis 1,6:1).

OFFEN und bewusst nicht mitgefixt: /turnen läuft bei 300px noch um 13px über, ab
320px nicht mehr. Es kommt aus dem Kontaktformular, nicht aus den Beitragskarten
oder Gruppenkarten — die min-content-Breite des Formulars in der Grid-Spur von
.container. .container global anzufassen ist mir für 13px auf einer Breite, die
kein Gerät hat, zu riskant (das schmalste verbreitete Display ist 320px).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:27:56 +02:00
fs
8c15b2584b Flamingos: Chip gekürzt und Ursache des Mobil-Überlaufs behoben
Rückmeldung der Turnabteilung mit Screenshot: auf dem iPhone war im
Flamingos-Panel der Fließtext rechts abgeschnitten.

Gemessen bei 390px mit aktivem Flamingos-Tab (die anderen Panels sind
ausgeblendet, ihre Chips haben Breite 0 — deshalb fiel es beim Prüfen der Seite
nicht auf): Dokument 442px bei 379px Bildschirm, also 63px Überlauf; der Chip
393px breit in einer 345px-Karte.

Ursache war nicht die Textlänge allein, sondern .group-card__level mit
`white-space: nowrap` UND `flex-shrink: 0`: der Chip konnte seinen Text nicht
brechen und auch nicht schrumpfen, seine Mindestbreite spannte die ganze Karte
auf. Von vier level-Chips war nur dieser betroffen (47 Zeichen gegen 17 bis 25).

Zwei Änderungen:
- Inhalt wie gewünscht gekürzt: "Turnen · fortgeschritten · Alter nach Absprache"
  → "Turnen · fortgeschritten".
- Sicherheitsnetz im CSS: nowrap raus, max-width: 100% rein. Ein Chip trägt
  Information, deshalb umbrechen statt mit … kürzen. flex-shrink: 0 bleibt, es
  hält den Chip neben einem kurzen Gruppennamen in Form.

Gegenprobe, damit der Fix nicht nur am kürzeren Text hängt: mit dem LANGEN Text
und dem CSS-Fix bleibt das Dokument bei 379px, der Chip bricht auf zwei Zeilen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:17:24 +02:00
fs
c4ffe4a1f4 Turn-Anfrageformular: Feld „Geburtsdatum des Kindes" wieder da
Auf der alten Seite gab es das Feld, beim Neubau ist es untergegangen. Gegen
2024.tsv08kulmbach.de geprüft: dort hieß es input_alter_kind, war type="date",
OPTIONAL und trug das Label „Geburtsdatum des Kindes" — genau so wieder
eingebaut. Die Abteilung braucht es, weil die Gruppen nach Alter eingeteilt sind.

Nur in der Turn-Variante. In der allgemeinen wäre es sinnlos und würde ohne Not
personenbezogene Daten Minderjähriger erheben. Dafür drei Stellen:
- contact_variant(): Schalter ask_birthdate (true nur für turnen)
- contact-form.php: Feld definiert, bedingt gerendert, direkt nach der
  Gruppenauswahl wie auf der alten Seite
- contact-submit.php: Validierung UND Rücksetzen auf leer, wenn die Variante das
  Feld nicht anbietet — sonst könnte ein manipulierter POST es an der allgemeinen
  Variante vorbei einschmuggeln

Validierung serverseitig, nicht nur per type=date: echtes Datum, nicht in der
Zukunft, nicht vor 1900. createFromFormat mit Rückvergleich, weil PHP sonst
Scheindaten wie 2019-02-31 stillschweigend auf den 3. März verschiebt.
Geprüft mit ausreichend altem Token (die Time-Trap verlangt 3 s, meine ersten
Versuche liefen deshalb als Bot durch): 2019-02-31 abgewiesen, 2030-01-01
abgewiesen, gültiges Datum passiert. Ohne JS bleibt der Wert bei HTTP 422
erhalten (value="2019-03-12" im Re-Render nachgewiesen).

Kein neues Markup: form-field kann type=date und min/max längst, die Prop für
den Hilfetext heißt hint (nicht help, das war mein erster Fehlgriff). Docblock
der Komponente um date und min/max ergänzt.

Datenschutzerklärung mitgezogen, wie die Projektregel es für neue Formularfelder
verlangt — mit Zweckbindung (Zuordnung zur Altersgruppe) und dem ausdrücklichen
Hinweis, dass es keine Pflichtangabe ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:10:47 +02:00
fs
75c06dbd04 Beitragsblock: Sektions-Kopf nach Hausmuster, Preise größer
Drei Dinge, zwei aus deinem Feedback und eines von mir.

1) Der Einleitungstext klebte ohne Abstand an der Überschrift und lief über die
   volle Breite, über 120 Zeichen je Zeile ("schaut weird aus"). Ursache: ich
   hatte <p class="lead"> geschrieben — eine Klasse, die es im Projekt gar nicht
   gibt, der Absatz war also völlig unformatiert. Jetzt das etablierte Muster
   (.membership-signup__head, instagram, partners): zentrierter Kopf, 60ch breit,
   Luft nach unten. Anmerkung im Code: dasselbe Kopf-Muster steht damit zum
   vierten Mal fast gleich in components.css — ein gemeinsames .section__head
   wäre die Aufräumung, aber ein eigener Umbau.

2) Preise von 28px auf 34px. Sie tragen die Hervorhebung allein, seit die Farbe
   weg ist, und sind die Information, auf die man in einer Beitragstabelle sieht.

3) Selbst aufgefallen: die Überschrift "Austritt" lag in .prose und rendert dort
   mit 21px Abel — exakt Fließtextgröße, also gar keine Auszeichnung. In
   Impressum und Artikeln ist das richtig, deshalb .prose nicht angetastet,
   sondern eigene Klassen für den Hinweis: 28px Coolvetica (die Untergrenze für
   Versalien) mit derselben dezenten Sperrung wie die Kartentitel.

Außerdem: data/turnen.json ist wieder in seiner ursprünglichen Formatierung.
Mein JSON-Schreiber hatte im vorigen Commit die kompakten Arrays aufgeklappt
("widths": [640, 1000, 1600] wurde vierzeilig) — inhaltlich identisch, aber 371
Diff-Zeilen für eine Umbenennung. Jetzt textuell ersetzt statt neu geschrieben:
17 Diff-Zeilen, Inhalt per Vergleich identisch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:55:48 +02:00
fs
6bc6b0d599 Step Aerobic entfernt, Seite heißt jetzt Fitness & Pilates
Rückmeldung der Turnabteilung: Step Aerobic findet nicht mehr statt.

Wichtig war der Befund vor der Änderung: die Seite bündelt DREI Angebote —
Pilates, Fitness & Stretching und Step-Aerobic. Ein pauschales "die Card raus"
hätte Pilates und Fitness mitgenommen. Entfernt ist deshalb nur die eine Gruppe
(Donnerstag 19:30, Heike Wahler); Pilates und Fitness & Stretching bei Karola
Lorz laufen unverändert weiter. Heike Wahler stand nur in dieser Gruppe und
erscheint damit nicht mehr auf der Website — das ist die einzige Nebenwirkung,
die jemandem auffallen könnte.

Umbenannt in "Fitness & Pilates" an allen zehn Fundstellen: navigation.json,
turnen.json (Disziplin-Schlüssel, Hero-Titel, drei Übersichtstexte),
ansprechpartner.json, news.json, turnen.php (Karte, Alt-Text, Meta) und die
Seiten-Datei selbst.

URL mitgezogen wie besprochen: turnen/step-aerobic-fitness-pilates →
turnen/fitness-pilates, samt Seiten-Datei und den drei Bilddateien (git mv,
Historie bleibt). Der alte Slug war in der Sitemap und ist indexiert, deshalb
eine 301 in public/.htaccess — VOR dem Front Controller, sonst würde index.php
den Pfad als unbekannten Slug abfangen und 404 rendern.

Geprüft: /turnen/fitness-pilates liefert 200 mit nur noch zwei Gruppen,
Sitemap führt ausschließlich den neuen Slug, Navigation, Übersichtskarte und der
News-Artikel verweisen darauf, keine Referenz auf die alten Bildnamen mehr.
Der alte Slug gibt lokal 404, weil php -S keine .htaccess liest; die 301 greift
auf Apache.

Nicht geändert: das Wort "Step-Aerobic" in der CLAUDE.md-Sprachregel. Dort ist
es ein Beispiel für orthografisch nötige Bindestriche und bleibt als solches
richtig, auch ohne den Kurs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:50:47 +02:00
fs
9244149bac Sperrung der Kartentitel: neuer Token --ls-heading statt --ls-caps
--ls-caps (0.08em) war zu viel: bei 34px sind das 2,7px und die Überschriften
wirkten auseinandergezogen. Gewollt war ein Hauch Luft, etwa 0,4px (Felix).

Dafür gibt es keinen Token, und rohe Werte verbietet die Token-Disziplin — also
--ls-heading: 0.012em, was bei 34px genau 0,408px ergibt (nachgemessen). Der Fall
trifft jede Coolvetica-Überschrift in h3-Größe, nicht nur diese Karten: der
Schnitt ist condensed, bei 60px trägt er ohne Sperrung, bei 34px kleben die
Versalien. --ls-caps bleibt für kleine Labels, wofür es gedacht ist.

Klarstellung zum vorigen Commit: die Titel waren und bleiben Coolvetica. Der
Verweis auf Abel in meiner Begründung war ein Zitat der CLAUDE.md-Regel für
kleine Auszeichnungen, keine Änderung an der Schriftart.

CLAUDE.md entsprechend berichtigt, inklusive der Warnung, hier nicht --ls-caps
zu nehmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:46:25 +02:00
fs
97676e91ee Beitragskarten: Titel gesperrt, Preise weiß statt rot
Zwei Punkte aus dem Gegenlesen, beide gemessen statt geschätzt.

1) Die Kartentitel waren schwer lesbar. Gemessen war letter-spacing "normal",
   also gar keins gesetzt — nicht zu eng eingestellt. Coolvetica ist ein
   Condensed-Schnitt: bei 60px (Abschnitts-h2) trägt das, bei 34px in einer
   schmalen Kartenspalte kleben die Versalien. Jetzt var(--ls-caps), der
   dokumentierte Token für gesperrte Uppercase-Auszeichnungen.

2) Die Preise standen in Akzentrot. Das ist kein Geschmacksfehler, sondern ein
   Kontrastfehler: #e20612 auf dem Kartengrund (rgba(0 0 0 / 0.22) über #222)
   erreicht 3,50:1 und verfehlt die 4,5:1 für Fließtext. Weiß liegt bei 17,22:1.
   CLAUDE.md verbietet Rot für Fließtext auf dunklem Grund ohnehin. Hervorgehoben
   bleibt der Preis durch Coolvetica und 28px gegen 21px Fließtext.

Die Preisfarbe wirkt auch auf /mitglied-werden, wo dieselbe Komponente steht —
dort ist der Grund dasselbe dunkle Glas (geprüft), die Änderung verbessert also
beide Seiten.

Beide Erkenntnisse als Regel in CLAUDE.md, mit den gemessenen Zahlen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:44:17 +02:00
fs
a975bed33c Turn-Beiträge sichtbar machen (standen nirgends auf der Website)
Die Turnabteilung verlinkt die Beiträge in Einladungen für neue Familien und
nimmt zum September auf — die Angaben waren aber auf der ganzen Website nicht
zu finden. Nachgeprüft: 0 Treffer auf /turnen und allen fünf Disziplin-Seiten,
/mitglied-werden führt nur die Fußball-Beiträge.

Es war keine verlorene Seite, sondern eine Lücke im Code: app/pages/turnen.php
rendert nur Hero, Karten und Formular. In data/turnen.json lagen ungenutzt
cards_intro, fees (drei Tabellen), austritt und faq (fünf Fragen). Der Docblock
behauptete, die Beiträge lägen auf den Unterseiten — sie lagen dort nicht, und
in der Git-Historie wurden sie nie gerendert.

Inhalte 1:1 wie auf der alten Seite (gegengeprüft an 2024.tsv08kulmbach.de,
Tab "Allgemeines"): Zusatzbeitrag 24/48 €, Neumitglieder 84/94/184 €,
Förderstufen 120/180/240 €, Austritt zum 30. September. Nichts umformuliert,
die Abteilung entscheidet über Änderungen.

Struktur jetzt Hero → Karten → Beiträge (#beitraege) → FAQ → Formular. Der
Anker ist die Adresse für die Einladungen. Nur bestehende Komponenten benutzt
(fee-table wie auf /mitglied-werden, faq, team-cards mit intro); neu ist allein
der Wrapper .fee-tables. Die FAQ füllt zusätzlich das FAQPage-Schema, weil
page_schema() sie als zweiten Parameter bekommt.

Feinschliff nach Sichtprüfung: Kartentitel von --fs-650 auf --fs-675, weil 28px
Coolvetica neben 21px Fließtext zu klein wirkten; Karten gleich hoch (der
Grid-Default stretch statt align-items: start, gemessen vorher 252/377/471px)
mit dem Hinweis am Kartenfuß; und minmax(min(22rem, 100%), 1fr) statt
minmax(22rem, 1fr), weil die Karten am Handy 352px breit in einem 347px-Raster
standen und überliefen.

NOCH offen: overview.cta ist weiter unbenutzt. Der Block verlinkt "Zur
Turn-Übersicht" und gehört auf die Disziplin-Unterseiten, nicht auf die
Übersicht — dort wäre es ein Selbstlink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:40:23 +02:00
fs
e0b2184372 Swipe-Viewer: alle Seiten wieder einheitlich A5 (kaputter Kommentar)
Am Handy waren die Seitenhöhen beim Durchwischen unterschiedlich: 350 bis 559px
je Inhalt, statt einheitlich 515. Nur Cover, Vorwort, Termine und Sportheim
trafen A5 — die vier haben eine eigene aspect-ratio.

Ursache war ein Fehler von mir aus dem vorigen Commit: der ergänzte Kommentar
landete HINTER dem schließenden */ des bestehenden. Damit stand roher Text vor
der Regel, und der CSS-Parser verwarf den kompletten Block
".stadionzeitung-viewer.is-enhanced .stadionzeitung-page, … { aspect-ratio }".
Der Browser meldete aspect-ratio: auto, obwohl die Regel im ausgelieferten CSS
stand und der Selektor per matches() passte — genau das Bild, das ein
verworfener Block erzeugt. Ein Testlauf mit aspect-ratio: 1/3 änderte nichts,
was den Verdacht bestätigte. php -l prüft CSS nicht, es gab also keinen
Hinweis.

Dass es vor der align-items-Änderung einheitlich aussah, war Zufall: der
Flex-Default stretch streckte alle Seiten auf dieselbe Zeilenhöhe (559) und
verdeckte damit, dass die A5-Regel nie griff.

Zusätzlich sind die Bild-Seiten (Anzeigen) jetzt in derselben A5-Regel. Sie
leiteten ihre Höhe aus dem eigenen Bildverhältnis ab; drei Motive sind
1440x2037 statt 1440x2043 und waren dadurch 513 statt 515px hoch, was beim
Wischen als Sprung auffiel. object-fit: cover fängt die 0,4% Abweichung ab,
wie die Druckfassung es ohnehin tut.

Gemessen: 40 von 40 Seiten 363x515 bei 390px Viewport. Zwei Seiten brauchen
dort internen Scroll (Ergebnisse +44px, Vorschau +11px), weil lange
Vereinsnamen umbrechen — Textumbruch skaliert nicht mit den cqi-Maßen. Im
Druck ist beides unauffällig, per PDF-Sichtprüfung an der Ergebnisse-Seite
bestätigt: Überschrift, drei Ergebnisblöcke und CTA-Kasten vollständig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 19:38:23 +02:00
fs
c7b23b9f39 Archiv-Zeile: Titel größer und selbsterklärend
Titel lautet jetzt "Stadionzeitung zum Heimspiel gegen <Gegner>" statt nur
"Gegen <Gegner>". Damit ist die Zeile für sich verständlich, auch als Link-Text
für Screenreader. Das "zum" ist eingefügt, damit der Satz grammatisch trägt.

Größe von --fs-650 auf --fs-690, den Token für Karten-Überschriften (28px auf
32 bis 44px). Die alte Begründung für die kleinere Stufe war, dass lange
Gegner-Bezeichnungen in die 280px-Karte passen mussten — die Karte ist jetzt
eine Zeile über die volle Breite, der Grund ist weg. Nach unten bleibt
--fs-650 die harte Grenze für Coolvetica in Versalien.

Geprüft bei 1280px (Titel auf einer Zeile, füllt die Zeile) und bei echten
390px (bricht auf zwei Zeilen, liest sich sauber).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:32:32 +02:00
fs
f6efdfb4b4 Stadionzeitungs-Archiv: Liste über die volle Breite statt Regal
Cover links, Angaben rechts daneben — dasselbe Muster wie .veranstaltung-card,
und aus demselben Grund: der Bestand ist klein.

Vorher war es ein horizontales Regal (grid-auto-flow: column, 280px-Spalten,
Scroll-Snap). Der Kommentar dort nannte genau die richtige Gefahr, "bei 1-3
Karten wie eine leere Fläche mit einer verlorenen Karte", löste sie aber nicht:
mit einer veröffentlichten Ausgabe stand eine 280px-Karte allein links auf einer
1280px breiten Seite. Eine Zeile über die volle Breite füllt die Fläche bei jeder
Anzahl.

Zwei Dinge sind dabei anders als bei den Veranstaltungen, beide begründet:
- Die Medienspalte ist schmaler (clamp bis 230px statt 26%), weil das Medium
  hier die live gerenderte Cover-Seite mit festem A5-Verhältnis ist. Ein <img>
  liesse sich per object-fit beschneiden, das Cover nicht — es bestimmt die
  Zeilenhöhe. 230px Cover ergeben 326px Zeile.
- Das margin-top: auto am CTA ist weg. Es stammte aus der hochformatigen Karte,
  wo der Link an den Kartenfuss gehörte; in der breiten Zeile riss es die drei
  kurzen Zeilen auf 150px auseinander.

Neu in der Zeile: die Coverzeile des Interviews als Teaser (interview.teaser,
dieselbe Quelle wie "Im Heft" auf dem Cover). Sie füllt die Breite mit echtem
Inhalt statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte; ohne
Interview entfällt sie, wie .veranstaltung-card__text.

Geprüft per Screenshot bei 1280px und bei echten 390px (iframe-Wrapper, weil
headless Chrome sonst eine Mindestbreite erzwingt): mobil steht das Cover über
den Angaben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:30:16 +02:00
fs
e24b63147d CLAUDE.md: Falz-Steg und die drei Seiten mit eigener aspect-ratio
Nachtrag zum vorigen Commit — die Doku-Ergänzung war durchgefallen, weil meine
Textsuche an einem Zeilenumbruch scheiterte. Hält beide Fallen fest: Steg nur
für alle Bögen oder für keinen, und die min-height-Absicherung gehört für
Vorwort, Sportheim und Termine in einen gemeinsamen Selektor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:20:48 +02:00
fs
24a3d6234c Druckbogen: Falz-Steg raus, drei Seiten füllen ihre Fläche
Zwei Befunde aus dem Gegenlesen des Hefts, beide gemessen.

1) Die Seiten waren unterschiedlich breit: Umschlagbogen 448,5px,
   Innenblätter 441px. Ursache war ein Falz-Steg von 2%, den nur die
   Innenbögen hatten (:nth-child(n+3)). Der Gedanke war richtig — randabfallende
   Anzeigen stoßen sonst mit 0mm an die Blattmitte — die Umsetzung aber
   uneinheitlich, und das fiel als "das Programm ist schmaler als das Vorwort
   drüber" auf. Entfernt zugunsten von "alle Bögen gleich"; die 9,9mm
   Innenabstand der Seiten halten den Inhalt aus dem Knick. Soll er zurück,
   dann für alle Bögen.

2) Unter dem Foto der Sportheim-Seite blieb ein weißer Streifen: die Seite war
   625,7px hoch in einer 636,4px hohen Fläche. Sie hat wie Vorwort und Termine
   eine eigene aspect-ratio plus negativen Margin, aber die min-height-
   Absicherung hatte nur das Vorwort bekommen. Die drei stehen jetzt in EINER
   Regel, damit eine vierte nicht wieder abdriftet.

Nachgemessen: Sportheim 448,5 x 636,4 statt 441 x 625,7, Foto bündig 0/0/0,
alle Slots ohne Padding, im gedruckten PDF Bogen 11 unten 0,0mm. Sichtprüfung
an Bogen 11: Foto läuft bis an linke und untere Kante.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:20:14 +02:00
fs
f678608140 Druckfassung: alle Bögen randabfallend, unterer Weißstreifen weg
Im Druck war oben fast kein Rand und unten viel — im Chrome-Export etwa 3 zu
8 mm. Der Rand war nicht die Ursache: der Blatt-Inhalt war über eine
aspect-ratio bemaßt und nur 199,1 mm hoch statt 210 mm. Die fehlenden 10,9 mm
sammelten sich immer unten, weil der Inhalt oben ausgerichtet ist. Gemessen an
Blatt 4 des erzeugten PDF:

  margin 5mm  ->  oben 4,8 / unten 5,8 mm   (Rest 0,6 fiel kaum auf)
  margin 0    ->  oben 0,0 / unten 10,9 mm  (der ganze Rest unten)

Ein Rand hätte den Fehler also nur kaschiert. Randabfallend ist die
geometrisch saubere Lösung, weil zwei A5 exakt ein A4 quer sind
(2 x 148,5 = 297 mm, Überschuss null) — es bleibt nichts zu verteilen.

Umgesetzt durch Auflösen einer Ausnahme statt neuer Sonderregeln: die zwei
Umschlagbögen liefen längst randabfallend (@page umschlag, Fläche 210mm), das
gilt jetzt für alle Bögen. Damit fällt auch der Bruch weg, dass der Umschlag
anders gedruckt wurde als die Innenblätter.

Nachgemessen: oben und unten je 0,0 mm auf Blatt 1, 4 und 6. Verbleibendes Weiß
steckt in den Anzeigenmotiven selbst (viele Sponsoren gestalten auf weißem
Grund, 100% weiße Randpixel) und ist nicht unser Layout. Sichtprüfung an
Blatt 4: Sportfest-Plakat läuft bis an alle vier Kanten, nichts beschnitten.

Bewusst in Kauf genommen (Felix): der Bürodrucker beschneidet auf sein
Hardware-Minimum von 4 bis 5 mm und liefert den Rand selbst. Unser Fließtext
verliert dabei nichts, die Live-Seiten haben 9,9 mm Innenabstand. Ein
Falz-Abstand entfällt, die Seiten stoßen mittig aneinander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:09:59 +02:00
8be0441341 Hutschdorf-Ausgabe eingefroren 2026-07-31 17:50:30 +02:00
fs
de5132c2ec CLAUDE.md: Bild-Einfrieren beim Veröffentlichen dokumentieren
Nachtrag zu 7223122 — der Doku-Teil war beim Commit durchgefallen, weil meine
Textsuche in CLAUDE.md nicht griff. Enthält auch den Hinweis, dass veröffentlicht
werden muss, wo der frische Sync-Stand liegt: auf dem Server. data/matchcenter.json
ist gitignored, lokal ist er also älter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:48:27 +02:00
fs
7223122220 Veröffentlichen friert auch die Bilder ein
Der Snapshot fror die Daten ein, verwies aber weiter auf Ordner, die die
Sync-Skripte verwalten: matchcenter-sync räumt Wappen weg, die der BFV nicht mehr
führt (eine Saison), instagram-sync ältere Posts (Wochen). Am 31.07.2026
nachgewiesen — ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe
wäre also mit der Zeit von selbst zerfallen, und "gedruckt = fixiert" war damit
nur die halbe Wahrheit.

bin/stadionzeitung-publish.php kopiert jetzt alles, worauf der Snapshot zeigt
(img/crests, img/instagram, img/news), als fix-* in den Ausgaben-Ordner und
schreibt die Pfade um. Varianten-Sets {base, widths} werden über alle Breiten
kopiert und nur umgeschrieben, wenn alle da sind — ein halbes srcset wäre
schlimmer als der alte Pfad; genau daran fiel img/news/neue-website beim ersten
Versuch auf. --entwurf löscht die fix-*-Dateien wieder, sie gehören zum Snapshot.
Flache Dateinamen statt Unterordner, weil die Aufräum-Logik in
stadionzeitung-add.php nur Dateien entfernt und ein Unterordner als Waise bliebe.

bin/preflight.php warnt, wenn eine veröffentlichte Ausgabe noch auf die
flüchtigen Ordner zeigt — sonst verfiele sie still.

Geprüft am Hutschdorf-Heft: 39 Dateien eingefroren, Snapshot verweist nur noch
auf den Ausgaben-Ordner, 88 Bilder in Viewer und Druckfassung erreichbar, keins
fehlt, preflight-Warnung verschwindet. Der Testlauf wurde danach zurückgenommen:
der lokale Matchcenter-Stand ist einen Tag alt, eingefroren wird auf dem Server
nach dem letzten Sync vor dem Druck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:47:44 +02:00
fs
344727bed0 Vorwort-Seite: Rasenbild bis zur Blattkante
Unter dem einblendenden Rasenbild blieb ein weißer Streifen, responsiv wurde er
größer. Gemessen bei 390px Viewport: Seite 559px hoch, Vorwort 515px — 44px
Lücke.

Ursache lag nicht am Vorwort, sondern an der Seite: .stadionzeitung-viewer__page
ist display:flex, und der Flex-Default align-items:stretch streckt die Seite auf
die Höhe der Flex-Zeile und übersteuert ihre aspect-ratio. Die Seite war damit
gar kein A5 mehr (559/363 statt 210/148) — das betraf ALLE Seiten, sichtbar wurde
es nur beim Vorwort, weil dort ein Bild die Blattkante erreichen muss.

align-items: center behebt es an der Wurzel: alle Seiten sind wieder exakt A5,
Lücke 0px. Zusätzlich bekommt das Vorwort min-height: calc(100% + 2x Padding) als
Absicherung — die aspect-ratio trägt weiter, wenn die Seite keine definite Höhe
hat (No-JS-Stapel, Druck), die min-height füllt sie, wenn sie eine hat.

Gemessen vorher/nachher, Desktop-Viewer per Screenshot gegengeprüft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:43:52 +02:00
fs
a63822f3d4 Druckfassung-Button aus dem Viewer entfernen
Die Druckfassung ist Werkzeug für die Heft-Produktion, kein Besucher-Angebot.
Als sichtbarer Knopf neben Blättern und Vollbild wurde er versehentlich geklickt
und riss Leute aus dem Lesen (Felix, 31.07.2026). Die Route bleibt unverändert
erreichbar, nur über die URL: ?druck=1 an die Ausgabe hängen.

Mit dem Knopf fallen drei CSS-Stellen weg, die es ohne ihn nicht mehr braucht:
die Basisregel, die Mobil-Ausblendung (dort wäre er eine Sackgasse gewesen) und
der Eintrag in der Ausblend-Liste der Druckansicht. In CLAUDE.md vermerkt,
inklusive der Bitte, ihn nicht als Link wieder einzubauen.

Geprüft: Viewer enthält kein druck=1 und kein viewer__print mehr, Vollbild und
Blättern intakt, ?druck=1 antwortet weiter mit 200 und montiert die Blätter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:36:15 +02:00
fs
827a717a02 Stadionzeitungs-Cover: definite Breite im Swipe-Viewer
Das Cover fehlte am iPhone, Chrome am Desktop zeigte es. Es hatte im Viewer eine
eigene Regel mit "max-height: 72vh; width: auto" — und width:auto ist bei
inline-size-Containment genau die Falle, die für .stadionzeitung-page schon
behoben war: der Inhalt zählt für die eigene Breite per Definition nicht mit.
Sichtbar wird der Unterschied, weil .stadionzeitung-viewer__page display:flex ist:
auf einem Flex-Kind heißt width:auto "richte dich nach dem Inhalt". Chrome
rettet sich über aspect-ratio plus gestreckte Höhe, Safari nicht.

Cover und Seite stehen jetzt in EINER Regel mit derselben definiten Breite, damit
sie nicht wieder auseinanderlaufen; nur overflow-y bleibt den Seiten vorbehalten.
Gemessen bei 390px Viewport: Cover 363x559, identisch zu den Folgeseiten.

EHRLICHE GRENZE: Chrome zeigt vor und nach der Änderung dieselben Maße, der
Fehler ist im Desktop-Browser nicht reproduzierbar. Die Ursache ist damit
begründet, aber nicht bewiesen — Gegenprobe nur am echten Gerät. In CLAUDE.md
vermerkt, damit das nächste Mal niemand im Desktop-Browser "verifiziert".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:31:35 +02:00
fs
05c64c8f12 Takt über public/cron.php: KAS-Cronjobs können nur URLs
Korrektur meiner eigenen Annahme von heute früh. Der Tarif hat SSH und
Cronjobs, aber das KAS-Formular kennt nur ein Feld "Protokoll / Pfad" mit
https:// — ein `php bin/…` ist dort nicht eintragbar. Der Endpoint, den ich
vorhin stillgelegt hatte, ist damit wieder der einzige Takt-Geber, und zwar in
einer besseren Form als geplant: Aufrufer ist der Hoster selbst, der Schlüssel
verlässt den Webspace nie, kein Dritter ist beteiligt.

Dritter Job 'logrotate' in der Whitelist, denn ohne ihn liefe die Logpflege nie
automatisch und die 2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Er passt in
denselben Rahmen wie die Syncs: kein Parameter aus der URL, keine Verbindung nach
außen, schreibt nur in storage/logs. bin/log-rotate.php trägt dafür denselben
Riegel wie die Syncs (CRON_HTTP) und require_once. Eine vierte Ausnahme braucht
wieder denselben Aufwand an Begründung.

Geprüft: falscher Schlüssel 403, unbekannter Job 403, gültiger Aufruf 204 mit
leerem Body, sofortige Wiederholung 204 plus WARN, Logdateien unversehrt.

Zeitraster angepasst, weil das KAS Wochentage nicht mit Halbstunden kombiniert:
Matchcenter stündlich statt 2x/Tag plus Wochenende, Instagram als zwei
Tageseinträge, Logpflege täglich nachts. Die Abwägung steht in
config.example.php und docs/deploy.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:16:12 +02:00
fs
964ffb932b config.example: Cron-Doku auf den echten Stand
Zwei Korrekturen an Stellen, die beim Anlegen der Cronjobs in die Irre geführt
hätten: (1) der Abschnitt behauptete noch, der Tarif habe keine Cronjobs —
seit 31.07.2026 ist Weg A unser Weg, cron.key ist leer und public/cron.php
stillgelegt. (2) Die Cron-Zeilen leiteten die Fehlerausgabe in die Kanal-Logs,
deren Format `[Zeit] LEVEL Meldung` eine rohe PHP-Warnung bricht (bin/logs.php
wertet Kanäle nach Level aus). Sie geht jetzt nach php-errors.log, das
bin/logs.php bereits als "eigenes Format" behandelt. Dazu die echten Pfade
dieses Webspace statt Platzhalter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:09:35 +02:00
fs
913c058c93 send_mail: Erfolg als INFO ins mail-Log
Bisher schrieb nur der Fehlerfall. Damit war ein funktionierender Versand von
einem stummen nicht zu unterscheiden: beim Deploy am 31.07.2026 zeigte
bin/logs.php gar keinen mail-Kanal, was "nichts abgeschickt" und "einwandfrei
versandt" gleichzeitig bedeuten konnte.

Geloggt wird nur der Kontext (welches Formular) — die Werte sind feste Labels
wie "Kontaktanfrage" oder "Sportheimbuchung", nie Empfänger, Absender oder
Inhalt. Das verbietet die Logging-Regel, und für "läuft der Versand?" braucht es
davon auch nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:07:51 +02:00
fs
f1ba364922 config.example: transiente SMTP-Auth-Fehler und Rückfallweg dokumentieren
Auf dem Server scheiterte die Anmeldung mit "Could not authenticate", lief
Minuten später ohne jede Änderung fehlerfrei und wurde parallel vom
Entwicklungsrechner durchgehend angenommen. Es war also vorübergehend, nicht
falsch konfiguriert — vermutlich der Brute-Force-Schutz des Mailservers.

Notiert, damit das nicht als Konfigurationsfehler behandelt wird, plus der
nachgewiesene Rückfallweg (localhost:25 ohne Anmeldung, der lokale Relay).
Bewusst nicht der Standard: mit localhost liefen Dev und Produktion auseinander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:05:02 +02:00
fs
748f029afb smtp-test: verdeckte Passwort-Eingabe und Zugangsweg-Sonde
Auf dem Server scheiterte der Login mit "Could not authenticate", während
dieselben Zugangsdaten vom Entwicklungsrechner angenommen werden. Beide Namen
(w01ca75d.kasserver.com und der Webserver) zeigen auf dieselbe IP — vom Webspace
aus verbindet sich PHP also mit der eigenen Maschine, und die kann Anmeldungen
anders behandeln als Verbindungen von außen.

--probe testet vier Zugangswege (587/STARTTLS, 465/SSL, localhost:25 mit und
ohne Anmeldung) und nennt den ersten, der trägt. Bewusst nur vier: jeder
Fehlversuch ist ein fehlgeschlagener Login, zu viele sperren ein Postfach.

--frage fragt das Passwort verdeckt ab, damit ein neu gesetztes Postfach-Passwort
geprüft werden kann, BEVOR es in eine Datei auf zwei Rechnern wandert.
Die Fehlerausgabe nennt bei "Could not authenticate" jetzt die drei Ursachen,
die dahinter stecken können.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:03:00 +02:00
fs
2066b43a57 Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari)
Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache
war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und
12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget
übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder.

Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa
238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener
Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein
Handy mit DPR 3 die größte Variante. Das Original ist gelöscht.

Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt
PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default
bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern.

bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand
"unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau
diese Annahme ist gerissen.

Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter
POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter
Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand
am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
fs
a3cfd4941d Banner-Hintergründe CSP-fest: <img> statt Inline-Variable
Beim ersten Deploy blieben die Slides des Banner-Sliders schwarz. Ursache war
kein fehlendes Bild, sondern die CSP: public/.htaccess setzt
default-src 'self' ohne style-src, der Browser verwirft damit jedes
style-Attribut — und die Komponente lieferte Bild und Akzentfarbe genau so
(style="--banner-bg: url(…); --banner-accent: #6cbbbc"). Lokal war das
unsichtbar, weil php -S keine .htaccess liest.

Der Hintergrund ist jetzt ein <img class="ad-banner__bg"> über die bestehende
img-Komponente (CSP erlaubt img-src) und bringt srcset mit: das Handy lädt die
640er statt der 1600er, die erste Slide lädt eager mit fetchpriority. Der
dunkle Leseverlauf liegt auf ::before statt im background-image.

Die Akzentfarbe kann als beliebiger Hex-Wert nicht ohne Inline-Style in die
Seite. Sie steht deshalb als Token in tokens.css (wo Farben laut CLAUDE.md
hingehören), components.css bildet den Namen darauf ab, und banners.json nennt
nur noch "accent": "breadcrumb".

bin/preflight.php bricht ab, wenn unter app/ wieder ein style-Attribut
auftaucht — der Fehler wäre sonst erneut erst in Produktion sichtbar. Beim
Bauen dieser Wache zweimal selbst hineingetappt: ein Muster mit [^>]* bricht an
jedem schließenden PHP-Tag ab, und die Zeichenfolge dieses Tags beendet den
PHP-Modus auch in einem //-Kommentar (preflight gab ab dort seinen eigenen
Quelltext aus, php -l meldete nichts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:50:43 +02:00
fs
e8296a465b Deploy-Anleitung: Verzeichnisschutz ehrlich als Empfehlung einordnen
Stand vorher "nicht weglassen". Tatsächlich mindern die Canonical-Tags auf
www.tsv08kulmbach.de das Indexierungsrisiko so weit, dass der Schutz eine
Empfehlung ist und kein Blocker. Am 31.07.2026 bewusst weggelassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:39:39 +02:00
1213fa4ca1 Sync-Stand vom Server: Wappen und Instagram-Bilder aktualisiert 2026-07-31 16:36:23 +02:00
fs
8ed5707d24 Deploy-Anleitung: Git-Identität auf dem Server als eigener Schritt
Fehlte, und ohne sie bricht jeder Commit auf dem Server mit "Author identity
unknown" ab — womit der Update-Ablauf aus F1 nicht funktioniert. Dazu der
Umgang mit einem abgewiesenen Push (pull --rebase).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:36:03 +02:00
fs
3cf8c2688b Deploy-Anleitung: Platzhalter ohne spitze Klammern
<host> liest die zsh als Eingabe-Umleitung, der scp brach mit "no such file or
directory: host" ab. Platzhalter heißen jetzt SERVER.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:28:58 +02:00
fs
dd18906348 Deploy-Anleitung: absolute Pfade, ~ ist auf diesem Webspace /
Das SSH-Konto hat die Systemwurzel als Home. cd ~ führt aus dem Webspace heraus,
~/tsv08kulmbach-website existiert nicht. Betraf das scp-Ziel und jede cd-Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:21:09 +02:00
fs
4f1bfce8b2 Deploy-Anleitung: echte Server-Pfade statt Platzhalter
B1 auf dem Webspace durchgeführt: PHP-CLI 8.3.29 unter /usr/bin/php, Composer
2.9.8 global, Git 2.43. Der Ordnername tsv08kulmbach.de ist von der alten
CMS-Seite belegt (die der Rückweg beim Umschalten ist), der neue heißt deshalb
tsv08kulmbach-website.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:19:22 +02:00
fs
f0ec8614b2 Deploy-Anleitung + Cron-Endpoint stilllegen
Der Tarif hat jetzt SSH und Shell-Cronjobs. Damit fällt die Begründung für
public/cron.php weg: der Endpoint war ausschließlich der Ersatz dafür.
cron.key steht in der Produktiv-Config auf '' (403 auf jeden Aufruf), der Code
bleibt als Rückweg samt Begründung stehen.

docs/deploy.md beschreibt den Weg in kleinen Schritten. Kern: die neue Seite
läuft erst auf einer per Verzeichnisschutz geschlossenen Test-Subdomain mit
DERSELBEN Produktiv-Config, die später live geht — kein Config-Wechsel beim
Umschalten. Möglich, weil die Host-Weiterleitung in public/.htaccess nur beim
exakten Host ohne www greift. Die alte CMS-Seite bleibt bis zuletzt online und
ist der Rückweg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:13:14 +02:00
fs
a495250db8 CLAUDE.md: uploads-Regel dokumentieren
Der Satz „Weil uploads/ gitignored ist" stimmte nicht mehr. Ersetzt durch die
Regel, die den Ordner im Repo tragfähig macht (Server pusht zuerst) und den
dauerhaften Preis (Historie schrumpft nie).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:07:13 +02:00
fs
aee8a3a901 Dateiablage /dateien versionieren (Vereinsmaterial sichern)
483 Dateien, 174 MB: Fotos und Videos vom Sportfest 2025, die Fundstücke der
Chronik 2028 samt ihren .quelle.txt-Belegen, das Logo. Damit ist das Material
gesichert und beim Entwickeln lokal verfügbar.

Der Ordner ist ein Schreibziel für andere, deshalb hat die Reihenfolge Bedeutung:
auf dem Server committen und pushen, dann lokal pullen, dann entwickeln. Und da
Git nichts vergisst, schrumpft das Repo nie wieder — große Videos sparsam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:05:19 +02:00
fs
e6f72a0e4b Wappen und Instagram-Bilder versionieren
Eine veröffentlichte Stadionzeitung friert Verweise auf genau diese Dateien
dauerhaft ein: 37 der 86 Bilder im Hutschdorf-Heft lagen in gitignorierten
Ordnern (33 Wappen, 4 Instagram-Fotos). Auf einem frischen Klon wären sie
kaputt, und der Instagram-Sync räumt alte Posts irgendwann weg, womit eine
archivierte Ausgabe nachträglich zerfiele.

Die JSON-Caches bleiben draußen, die baut der Sync neu. Maschinenverwaltet und
flüchtig ist nicht dasselbe wie maschinenverwaltet und für immer referenziert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:02:56 +02:00
814 changed files with 19484 additions and 641 deletions

28
.gitignore vendored
View File

@@ -17,22 +17,30 @@
!/storage/cache/.gitkeep
!/storage/ratelimit/.gitkeep
# Maschinenverwaltet (bin/instagram-sync.php)
# Maschinenverwaltet: die JSON-Caches bleiben draußen, der Sync baut sie neu.
#
# Die BILDER dagegen sind versioniert, obwohl sie auch der Sync herunterlädt:
# eine VERÖFFENTLICHTE Stadionzeitung friert Verweise auf genau diese Dateien
# dauerhaft ein (Wappen in Tabellen/Ergebnissen/Vorschau, Instagram-Fotos auf der
# Momente-Seite). Ein frischer Klon zeigte sonst 37 kaputte Bilder im Heft, und
# der Instagram-Sync räumt alte Posts irgendwann weg — dann bräche eine
# archivierte Ausgabe nachträglich. Maschinenverwaltet und flüchtig ist nicht
# dasselbe wie maschinenverwaltet und für immer referenziert.
/data/instagram.json
/public/assets/img/instagram/*
!/public/assets/img/instagram/.gitkeep
# Maschinenverwaltet (bin/matchcenter-sync.php)
/data/matchcenter.json
/public/assets/img/crests/*
!/public/assets/img/crests/.gitkeep
# Dateiablage /dateien — Nutzerdaten und Laufzeit-Kram der Files Gallery.
# Dateiablage /dateien — Laufzeit-Kram der Files Gallery.
# Committed bleiben nur system/users/*/config.php (Rechte, keine Passwörter) und
# system/include/* (Skin). system/config/config.php generiert die App selbst und
# überschreibt sie bei jedem Update — unsere Werte stehen in _filesconfig.php.
/storage/dateien/uploads/*
!/storage/dateien/uploads/.gitkeep
#
# uploads/ IST versioniert (Beschluss 31.07.2026) — das Material des Vereins ist
# damit gesichert und lokal verfügbar. Zwei Bedingungen tragen das:
# 1. Der Ordner ist ein Schreibziel für ANDERE. Deshalb gilt: erst auf dem
# Server committen und pushen, dann lokal pullen, dann entwickeln. Nie
# umgekehrt, sonst arbeiten zwei Stände gegeneinander.
# 2. Git vergisst nichts. Eine gelöschte Datei bleibt in der Historie, das
# Repo schrumpft nie wieder. Große Videos deshalb bewusst sparsam.
/storage/dateien/system/cache/
/storage/dateien/system/config/

325
CLAUDE.md
View File

@@ -7,7 +7,9 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
- **Frontend:** Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node.
- **Backend:** Vanilla PHP ≥ 8.1, Composer nur für `phpmailer/phpmailer`. Keine Template-Engine.
- **Hosting-Ziel:** Apache Shared Hosting, PHP 8.x, `.htaccess`, Cronjobs verfügbar.
- **Hosting-Ziel:** All-Inkl Shared Hosting, PHP 8.x, `.htaccess`, SSH (seit 31.07.2026) und
Cronjobs, die aber **nur URLs aufrufen können** — deshalb läuft der Takt über
`public/cron.php`, siehe dort. Deploy-Anleitung Schritt für Schritt: **`docs/deploy.md`**.
- **Lokales Dev:** `php -d upload_max_filesize=250M -d post_max_size=260M -S localhost:8000 -t public public/index.php`
(index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt). Die beiden `-d`-Schalter
brauchst du für die Dateiablage `/dateien`: der CLI-Server liest keine `.user.ini`, und mit den
@@ -52,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 |
@@ -70,6 +72,13 @@ Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pag
`sportsevent_nodes()` (SportsEvent-Knoten für kommende Spiele, Matchcenter) und
`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
@@ -118,7 +127,18 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
Quelle für Termine-Badges und Hero-Beschriftung) und `upcoming_highlights(3)` (die nächsten
Termine für den Startseiten-Hero, s. Hero-Regel).
- **CSS:** 6 Dateien als `<link>` in fester Reihenfolge: tokens → reset → base → layout → components →
utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit
utilities. Kein `@import`, **kein Inline-Style — die CSP erzwingt das, und zwar nur in
Produktion.** `default-src 'self'` ohne `style-src` heißt: der Browser verwirft jedes
`style`-Attribut und jeden `<style>`-Block stillschweigend. Lokal fällt das **nie** auf,
weil `php -S` keine `.htaccess` liest. Genau so blieben die Banner-Slides beim ersten
Deploy schwarz (31.07.2026): die Komponente setzte `style="--banner-bg: url(…)"`.
**Die zwei Auswege, wenn ein Wert aus `data/*.json` ins Aussehen muss:**
ein **Bild** wird ein `<img>` (die CSP erlaubt `img-src 'self' data:`, und `srcset`
gibt es gratis dazu), eine **Farbe** wird ein Token in `tokens.css` plus eine Klasse in
`components.css`, und die JSON nennt nur den Namen (`"accent": "breadcrumb"`).
`bin/preflight.php` bricht mit Fehler ab, wenn irgendwo unter `app/` ein `style`-Attribut
auftaucht. Per JavaScript gesetzte Styles (`element.style.…`) sind nicht betroffen.
Komponenten-Styles in `components.css` mit
Banner-Kommentaren (`/* === hero === */`).
**Kontext-Regeln setzen Custom Properties, kein Layout.** Wenn eine Komponente in einem Kontext
anders aussehen soll, gehört auf `.kontext .komponente` nur die Variable (`--crest-size`,
@@ -164,7 +184,11 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
- **Varianten statt Zweitformular:** das Kontaktformular hat zwei Ausprägungen, gesteuert vom
Hidden-Feld `department` (`''` = allgemein, `'turnen'` = Turnabteilung auf `/turnen`).
`contact_variant()` (`helpers.php`) ist die **einzige** Quelle für Auswahlliste, Feldregeln
(Betreff-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide dort. Ein
(Betreff-Feld, Geburtsdatum-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide
dort. Ein variantenspezifisches Feld braucht **drei** Stellen: den Schalter in
`contact_variant()`, die bedingte Ausgabe in `contact-form.php` und in der Action beides —
Validierung **und** ein Zurücksetzen auf leer, wenn die Variante das Feld nicht anbietet (sonst
könnte ein manipulierter POST es an einer Variante vorbei einschmuggeln). Ein
unbekannter Wert fällt auf die allgemeine Variante zurück, ein manipuliertes Feld erreicht also
keinen fremden Empfänger. Die Turn-Variante geht an `club.email_turnen`, hat die Disziplinen aus
`data/turnen.json` als Auswahl (Pflicht, ersetzt den Betreff) und eine freiwillige Nachricht.
@@ -215,7 +239,9 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
Web wie CLI. Kein `error_log()`, kein `file_put_contents()` auf eine Logdatei, keine eigene
Log-Closure in einem neuen Skript.
- Kanal = Dateiname unter `storage/logs/`: `mail` · `spam` · `instagram` · `matchcenter` ·
`stadionzeitung` · `veranstaltungen`
`stadionzeitung` · `veranstaltungen` · `chronik` · `filesgallery` · `logrotate`
(letzterer nur für den Übersprungen-Hinweis des Cron-Endpoints, der seinen Kanal aus dem
Job-Namen bildet)
(`php-errors.log` schreibt PHP selbst, eigenes Format).
- Level mit klarer Bedeutung: **INFO** Normalbetrieb (auch ein geblockter Bot — der Schutz
arbeitet, das ist kein Fehler) · **WARN** Auffälligkeit, Betrieb läuft weiter (Einzelbild
@@ -264,7 +290,29 @@ nicht durchgezogen.
darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen
Abel verwenden, ggf. mit `letter-spacing` + uppercase. `base.css` setzt das durch: `h1``h3` tragen
Coolvetica, **`h4``h6` laufen über `--ff-body` + uppercase + `--ls-caps`**.
- **Coolvetica unter ~40px braucht `letter-spacing: var(--ls-heading)`** (0.012em, also 0,4px bei
34px). Der Schnitt ist condensed: bei 60px (Abschnitts-h2) trägt er ohne Sperrung, bei 34px in einer
schmalen Spalte kleben die Versalien und der Titel wird schwer lesbar (aufgefallen an den
Beitragskarten, 31.07.2026). **Nicht `--ls-caps` (0.08em) nehmen** — das ist für kleine Labels
gedacht und lässt eine Überschrift auseinandergezogen wirken. Die globalen `h1``h3` stehen
bewusst auf `letter-spacing: normal`, gesperrt wird pro Komponente.
- Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
**Gemessene Zahl dazu:** `#e20612` auf der Glas-Karte (`rgba(0 0 0 / 0.22)` über `#222`) erreicht
**3,50:1** und verfehlt die 4,5:1 für Fließtext; Weiß liegt bei 17,22:1. Genau daran sind die
Beitragspreise hängengeblieben, die zuerst rot waren. Hervorhebung über Schriftart und Größe
(Coolvetica 28px gegen 21px Fließtext) trägt ohne Farbe.
- **Hervorgehobene Hinweise laufen über `component('notice')`** (`app/components/notice.php`,
Klassen `.notice`/`.notice--info`/`.notice--stop`), nie als eigenes Markup. Die Tonlage trägt
Bedeutung und ist keine Geschmacksfrage: **`--stop` (gefüllt rot) heißt „geht nicht"**
(Wartelisten-Stopp der Turnabteilung), **`--info` (Glas-Fläche, Rot nur im Icon) heißt „geht"**
(neue Kinder dürfen zum Schnuppern kommen). Eine Einladung in Alarmrot liest sich wie eine
Absage, und wenn alles rot ist, stumpft Rot ab und der echte Stopp verliert seine Wirkung.
**`--info` ist der Default, Rot die Ausnahme.** Der Text und die Tonlage stehen in der
`data/*.json` der Seite (`notice`, `notice_title`, `notice_tone`, `notice_icon`), nie im
Template; eingebettet wird über eine Zusatzklasse, die **nur** Position bzw. Breite setzt
(`.turnen-dash__notice` = `grid-area`, `.contact__notice` = Formularbreite).
Eine Überschrift ist Pflicht, wenn der Hinweis rot ist: ohne sie liest sich die rote Fläche
wie eine Fehlermeldung.
- Alle Werte als CSS Custom Properties in `tokens.css`; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
### Token-Disziplin (gilt für jede CSS-Änderung)
@@ -274,6 +322,7 @@ Keine rohen Werte in `base/layout/components/utilities.css` — es gibt für all
| Was | Token | Anmerkung |
|---|---|---|
| `padding` / `margin` / `gap` | `--space-4xs … --space-7xl` (14 Stufen, 4px-Raster) | `--space-4xs` (2px) **nur** für optische Korrekturen (Icon-/Baseline-Nudges), nie fürs Layout |
| Innenabstand von Karten-Flächen | `--pad-card` (20…32px) / `--pad-card-lg` (24…40px) | Keine festen Werte. Auf einem 309px-Handy stapeln sich Container- und Kartenabstand sonst so, dass dem Eingabefeld nur 71% der Bildschirmbreite bleiben. Die Obergrenzen entsprechen den früheren festen Werten, am Desktop ändert sich dadurch nichts. Ein harter Breakpoint (etwa „ab 310px ohne Abstand") wäre die Alternative, sieht aber bei 311px wieder falsch aus |
| `font-size` (Text) | `--fs-300 … --fs-1000` | `--fs-550`/`--fs-690` sind die fluiden Zwischenstufen (Lead, Karten-Überschrift) |
| `font-size` (Icons) | `--icon-s/m/l/xl` (rem), `--icon-inline-sm/--icon-inline/--icon-inline-lg` (em) | em-Stufen für Icons, die mit ihrer Textzeile mitwachsen sollen |
| `letter-spacing` | `--ls-tight/caps/wide/wider` | |
@@ -329,13 +378,29 @@ Keine rohen Werte in `base/layout/components/utilities.css` — es gibt für all
- [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
- [ ] `php -l` sauber; Seite lokal geprüft
## Cron-Endpoint `public/cron.php` (Ausnahme 3b)
## Cron-Endpoint `public/cron.php` (Ausnahme 3b) — **der Takt-Geber**
**Der Tarif kennt keine zeitgesteuerten Aufgaben** — das KAS meldet „in deinem Tarif nicht verfügbar",
weder Shell- noch URL-Cronjobs (geprüft 27.07.2026). Die Sync-Skripte müssen aber getaktet laufen, also
kommt der Takt von außen: ein externer Cron-Dienst ruft
`https://…/cron.php?job=matchcenter` bzw. `?job=instagram` auf, der Endpoint startet das Skript
serverseitig. Schlüssel aus `config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter.
**Die KAS-Cronjobs können ausschließlich URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026:
das Anlege-Formular hat nur ein Feld „Protokoll / Pfad" mit `https://`). Der Tarif hat inzwischen SSH
und Cronjobs, aber ein `php bin/…` ist dort nicht eintragbar. Deshalb ruft der **KAS-Cron diesen
Endpoint** auf, und er startet das Skript serverseitig. Der Ablauf steht in **`docs/deploy.md`**, Teil D,
die drei Zeitpunkte in `config/config.example.php`.
Das ist besser als der ursprünglich geplante externe Cron-Dienst: **der Aufrufer ist der Hoster
selbst**, der Schlüssel verlässt den Webspace nie, es ist überhaupt kein Dritter beteiligt.
`cron.key` muss also gesetzt sein — steht er leer, läuft **kein** Sync mehr.
Ein externer Cron-Dienst ruft, falls je nötig, dieselben URLs auf:
`https://…/cron.php?job=matchcenter` · `?job=instagram` · `?job=logrotate`. Schlüssel aus
`config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter (im KAS notgedrungen als
Parameter, das Formular kennt keine Header).
**Warum es drei Jobs sind:** `logrotate` kam am 31.07.2026 dazu, weil dieser Endpoint der einzige Weg
ist, überhaupt etwas getaktet laufen zu lassen. Ohne ihn liefe die Logpflege nie automatisch und die
2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Der Job passt in denselben Rahmen wie die Syncs: kein
Parameter aus der URL, keine Verbindung nach außen, er schreibt nur in `storage/logs`. Eine **vierte**
Ausnahme braucht wieder denselben Aufwand an Begründung. `bin/log-rotate.php` trägt dafür denselben
Riegel wie die Syncs (`PHP_SAPI !== 'cli' && !defined('CRON_HTTP')`) und `require_once`.
**Warum dieser Weg und nicht GitHub Actions mit FTP-Upload:** dort müsste ein Dritter Schreibzugang
zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der ausschließlich „starte einen der beiden
@@ -363,8 +428,8 @@ Besucher keine Aktualisierung, und Fire-and-Forget ist auf Shared Hosting unzuve
`$argv` wird im Endpoint gesetzt, weil es im Web-SAPI nicht existiert.
`bin/preflight.php` prüft Schlüssellänge, Verwechslung mit `app_secret` und ob `min_interval` den
gewünschten Takt sperrt. **Wenn der Tarif je Cronjobs bekommt:** `cron.key` leeren, dann ist der
Endpoint zu, und der Takt läuft wieder wie in `config.example.php` dokumentiert.
gewünschten Takt sperrt. Der damals hier notierte Ausstieg („wenn der Tarif je Cronjobs bekommt,
`cron.key` leeren") ist am 31.07.2026 genau so eingetreten und vollzogen.
**Offen:** ob der Instagram-Scraper von der Server-IP überhaupt durchkommt. Instagram sperrt
Rechenzentren; hier lief er nur über einen privaten Anschluss. Einmal auf dem Server prüfen. Falls er
@@ -403,8 +468,15 @@ Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
## Stadionzeitung (Ausgaben-Archiv)
Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) mit
Vorschau-Karten, pro Ausgabe ein Swipe-Viewer (`/stadionzeitung/<ausgabe-slug>`, dynamische Route,
Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (`/stadionzeitung`) als
**Liste über die volle Breite, Cover links und die Angaben rechts daneben** (dasselbe Muster wie
`.veranstaltung-card`, und aus demselben Grund: der Bestand ist klein). Vorher war das ein
horizontales Regal mit 280px-Karten; bei **einer** veröffentlichten Ausgabe stand die Karte allein
links auf einer 1280px breiten Seite (geändert 31.07.2026). Der Titel der Zeile lautet
**„Stadionzeitung zum Heimspiel gegen <Gegner>"** (nicht nur „Gegen <Gegner>"): so ist die Zeile für
sich verständlich, auch als Link-Text für Screenreader. Als Teaser darunter steht die **Coverzeile des
Interviews** (`interview.teaser`, dieselbe Quelle wie „Im Heft" auf dem Cover) — ohne Interview
entfällt sie. Pro Ausgabe ein Swipe-Viewer (`/stadionzeitung/<ausgabe-slug>`, dynamische Route,
siehe „Architektur-Muster"). **Bewusst kein PDF auf der Website** — die Ausgabe entsteht ohnehin in
Canva, jede Seite wird dort direkt als Bild (PNG/JPG) statt als PDF exportiert. Ein PDF-Export der
Muster-Datei war ~80 MB (hochauflösende Druck-Scans) und hätte serverseitig Ghostscript/Imagick
@@ -444,17 +516,23 @@ kein Download-Link — nur die Web-Ansicht. Der Ablauf pro Heimspiel ist damit:
**Feste Vorlage** (seit Interview 29.07.2026, Anzeigen-Verteilung überarbeitet 31.07.2026) — der
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
@@ -475,12 +553,30 @@ Lücken bekommen 7 Lücken zwei. Zwei hintereinander ist das Optimum, nicht eins
Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf.
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
@@ -489,8 +585,41 @@ die Live-Seiten (Tabellen/News/Ergebnisse/Vorschau) bei jedem Aufruf frisch aus
Stand als `snapshot`-Feld ein (`stadionzeitung_build_snapshot()`, dieselbe Berechnung wie die
Entwurfs-Vorschau — `stadionzeitung_live_data()` in `helpers.php` entscheidet snapshot vs. live) und
setzt `status: veroeffentlicht`. Ab dann ändert sich die Ausgabe nie mehr („gedruckt = fixiert");
sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync. `--entwurf` zieht zurück
(Snapshot wird verworfen). Ein erneuter `stadionzeitung-add.php`-Lauf setzt eine veröffentlichte
sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync — **auf dem Server**, dort
liegt der frische Sync-Stand (`data/matchcenter.json` ist gitignored, lokal also älter).
`--entwurf` zieht zurück (Snapshot wird verworfen).
**Nach dem Veröffentlichen auf dem Server SOFORT committen und pushen** — das ist keine Kür,
sondern Teil des Vorgangs. `bin/stadionzeitung-publish.php` schreibt den Snapshot in
`data/stadionzeitung.json` und legt die eingefrorenen `fix-*`-Bilder an; beides existiert danach
zunächst **nur auf dem Server**. Bleibt es dort liegen, passieren zwei Dinge: ein `git pull` auf dem
Server scheitert beim nächsten Deploy an der geänderten Datei, und wer sie dort verwirft, verliert
die veröffentlichten Ausgaben ersatzlos (ein lokaler Stand setzt sie auf `entwurf` zurück, weil er
die Snapshots nie gesehen hat).
**Am 03.09.2026 genau so eingetreten:** drei veröffentlichte Ausgaben (01.08., 15.08., 29.08.)
lagen samt Snapshots und 70 `fix-*`-Dateien ausschließlich auf dem Server, der Pull der
Sportfest-Ausgabe brach mit „local changes would be overwritten" ab. Reparatur, in dieser
Reihenfolge: Server-Datei außerhalb des Repos sichern → `git checkout --` + `git pull` →
Ausgaben-Listen **je Slug** zusammenführen, **Server als Wahrheit**, aus dem Repo nur ergänzen was
dort fehlt → `git add data/stadionzeitung.json public/assets/img/stadionzeitung` → committen und
pushen. Ein Merge über den Dateiinhalt wäre keine Option gewesen: 1600 geänderte Zeilen JSON,
Konflikte mitten in eingefrorenen Datensätzen.
**Mit dem Snapshot werden auch die BILDER eingefroren** (seit 31.07.2026): das Skript kopiert
alles, worauf er zeigt (`img/crests/`, `img/instagram/`, `img/news/`), als `fix-*` in den
Ausgaben-Ordner und schreibt die Pfade um. **Grund:** vorher fror nur die Daten ein, die Pfade
zeigten weiter in Ordner, die die Sync-Skripte verwalten — `matchcenter-sync` räumt Wappen weg,
die der BFV nicht mehr führt (eine Saison), `instagram-sync` ältere Posts (Wochen). Am 31.07.2026
nachgewiesen: ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe wäre also von
selbst zerfallen. `img/news` ist mit dabei, weil ein Artikelbild im Chat ersetzt werden kann.
**Varianten-Sets** (`{base, widths}`) werden über alle Breiten kopiert und nur umgeschrieben, wenn
**alle** da sind — ein halbes `srcset` wäre schlimmer als der alte Pfad. Fehlt eine Quelle, bleibt
der Pfad stehen und es gibt eine WARN (die `crest`-Komponente fällt auf Initialen zurück; ein
fehlendes Bild darf das Veröffentlichen nicht verhindern). Flache `fix-*`-Dateinamen statt eines
Unterordners, weil die Aufräum-Logik in `stadionzeitung-add.php` nur Dateien entfernt und ein
Unterordner als Waise bliebe. `--entwurf` löscht sie wieder, sie gehören zum Snapshot.
`bin/preflight.php` warnt, wenn eine veröffentlichte Ausgabe noch auf `img/crests` oder
`img/instagram` zeigt — Reparatur: `--entwurf`, dann neu veröffentlichen. Ein erneuter `stadionzeitung-add.php`-Lauf setzt eine veröffentlichte
Ausgabe zurück auf Entwurf (Seiten ersetzt = Stand muss neu geprüft und veröffentlicht werden).
Ausgaben ohne `status`-Feld gelten als veröffentlicht (Bestandsschutz). Die Kontakt-Seite ist
bewusst NICHT im Snapshot: sie liest immer `data/ansprechpartner.json` (ein Snapshot würde nur
@@ -545,6 +674,73 @@ Fragen/Antworten, Claude trägt sie ins `interview`-Feld der Ausgabe ein (`tease
Live-Seite nur ein, wenn das Feld existiert — Ausgaben ohne Interview haben weder Seite noch
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
@@ -583,10 +779,24 @@ bedrucktes Blatt ~140mm) proportional exakt gleich aus — mit rem-Maßen lief d
Kontext über die feste A5-Fläche (reproduzierbar: abgeschnittene Vorwort-Signatur, beschnittene
Tabellen-Legende). Bewusste Ausnahme von der Token-Disziplin (siehe oben): ein bedrucktes
A5-Blatt ist kein responsives Website-Element und braucht eine eigene Skala. `letter-spacing`
bleibt trotzdem über `--ls-*` (dimensionslose `em`-Vielfache, kein Konflikt). **Falle beim
bleibt trotzdem über `--ls-*` (dimensionslose `em`-Vielfache, kein Konflikt). **Falle beim Bauen (31.07.2026):** ein zusätzlicher Kommentar wurde versehentlich HINTER das
schließende `*/` des bestehenden gesetzt. Damit stand roher Text vor der Regel, der CSS-Parser
verwarf den kompletten Regelblock — und die A5-Höhe im Swipe-Viewer war wirkungslos, ohne dass
irgendetwas einen Fehler meldete. Die Seiten fielen dadurch auf Inhaltshöhe zurück und waren 350
bis 559px hoch statt einheitlich 515. **`php -l` prüft CSS nicht**; wer hier etwas ändert, zählt
`/*` gegen `*/` und misst eine Seitenhöhe nach.
**Falle beim
Containment:** `width: auto` + `max-height` kollabiert eine inline-size-contained Box auf ihr
Padding (Inhalt zählt nicht mehr) — im Swipe-Modus bekommt `.stadionzeitung-page` deshalb eine
definite Breite (`min(calc(72vh * 148 / 210), 100%)`), nie `width: auto`. Das Cover war der
Padding (Inhalt zählt nicht mehr) — im Swipe-Modus bekommen `.stadionzeitung-page` **und
`.stadionzeitung-cover`** deshalb eine definite Breite (`min(calc(72vh * 148 / 210), 100%)`),
nie `width: auto`. **Beide stehen dafür in EINER Regel**, und das ist der Punkt: das Cover hatte
als eigene Klasse zunächst nur eine „harmlose Höhen-Deckelung" mit `width: auto` und blieb am
iPhone unsichtbar, während Chrome am Desktop es zeigte (31.07.2026). Der Unterschied entsteht,
weil `.stadionzeitung-viewer__page` `display: flex` ist: auf einem Flex-Kind heißt `width: auto`
„richte dich nach dem Inhalt", Chrome rettet sich über `aspect-ratio` plus gestreckte Höhe,
Safari nicht. **Chrome kann diesen Fehler nicht reproduzieren** — wer hier etwas ändert, prüft
es an einem echten iOS-Gerät, nicht im Desktop-Browser. Das Cover war der
Anfang dieses Prinzips:
Kontrast der Schlagzeile kommt aus der flachen Verlauf-Fläche selbst, bewusst **ohne** `text-shadow`:
Chrome rendert `text-shadow` auf mehrzeiligem, umbrechendem Text (lange Gegner-Namen) beim
@@ -653,8 +863,12 @@ nicht `.stadionzeitung-page` (Details siehe unten). Bild-, Live- wie Cover-Seite
A5-Seitenverhältnis (148:210), damit sich das Durchblättern gleichmäßig anfühlt.
**Druck — die Druckfassung `?druck=1`** (seit Interview 29.07.2026): das Heft wird als **gefaltetes
A5-Heft** produziert (A4 quer beidseitig drucken, Blätter stapeln, mittig falten). Der
„Druckfassung"-Link im Viewer führt auf dieselbe Ausgaben-Route mit `?druck=1`
A5-Heft** produziert (A4 quer beidseitig drucken, Blätter stapeln, mittig falten). Erreichbar
**nur über die URL**: an die Ausgaben-Route ein `?druck=1` anhängen. Der frühere
„Druckfassung"-Button im Viewer ist am 31.07.2026 entfernt worden (Felix): die Druckfassung ist
Werkzeug für die Heft-Produktion, kein Besucher-Angebot, und als sichtbarer Knopf neben Blättern
und Vollbild wurde er versehentlich geklickt. **Nicht als Link wieder einbauen** — wer sie
braucht, kennt die URL.
`app/components/stadionzeitung-druck.php` montiert dort die Seiten in
**Broschüren-Reihenfolge** (Sattelheftung: Blatt 1 vorne = letzte Seite + Cover, hinten = Seite 2 +
vorletzte, …). Die Montage liegt bewusst im PHP, nicht im Druck-CSS: CSS kann Seiten nicht über
@@ -664,8 +878,37 @@ Druckfassung füllt notfalls mit weißen Leerseiten VOR der Rückseite auf. Auf
die Route eine Blatt-Vorschau (A4-quer-Flächen mit „Blatt 1 · Vorderseite"-Labels) samt Anleitung
(**beidseitig, an der kurzen Kante spiegeln**); `assets/js/stadionzeitung-druck.js` liefert nur den
„Jetzt drucken"-Button (`window.print()`, progressive enhancement — ohne JS Strg/Cmd+P). **Kein
serverseitiges PDF-Tool** (passt zur Ghostscript/Imagick-Vermeidung oben). Bürodrucker mit weißem
Rand ist der Anspruch (bewusst gleichmäßiges Passepartout, kein Randabfallend). `@media print`
serverseitiges PDF-Tool** (passt zur Ghostscript/Imagick-Vermeidung oben).
**Alle Bögen laufen RANDABFALLEND** (`@page { margin: 0 }`, Beschluss 31.07.2026 — vorher galt hier
„Bürodrucker mit weißem Rand ist der Anspruch, bewusst gleichmäßiges Passepartout"). Der Wechsel kam
aus einem gemessenen Fehler: die Innenblätter hatten einen 5mm-Rahmen, die zwei Umschlagbögen nicht,
und der Blatt-Inhalt war über eine `aspect-ratio` bemaßt und nur **199,1mm** hoch statt 210mm. Die
fehlenden 10,9mm sammelten sich **immer unten**, weil der Inhalt oben ausgerichtet ist — gemessen an
Blatt 4: mit `margin: 5mm` oben 4,8 / unten 5,8mm, mit `margin: 0` oben 0,0 / unten 10,9mm. Ein Rand
kaschierte den Fehler, behob ihn aber nicht. Randabfallend ist die saubere Lösung, weil **zwei A5
exakt ein A4 quer sind** (2 × 148,5 = 297mm, Überschuss null): es bleibt nichts zu verteilen.
Nachgemessen im erzeugten PDF: oben und unten je 0,0mm auf jedem Bogen. Verbleibendes Weiß steckt in
den **Anzeigenmotiven selbst** (viele Sponsoren gestalten auf weißem Grund, 100% weiße Randpixel) und
ist nicht unser Layout. Den Rand liefert der Bürodrucker aus seinem Hardware-Minimum (~4 bis 5mm);
unser Fließtext verliert dabei nichts, die Live-Seiten haben 6,7cqi = 9,9mm Innenabstand.
**Keine `@page`-Randbreite wieder einführen, ohne die Blatt-Höhe gegenzurechnen** — sonst ist der
Überschuss zurück.
Es gibt bewusst auch **keinen Falz-Steg** mehr: die beiden Seiten stoßen mittig aneinander, der
Innenabstand der Seiten (9,9mm) hält den Inhalt aus dem Knick. Vorher hatten nur die Innenbögen 2%
Steg, der Umschlagbogen nicht — dadurch waren die Seiten dort 441px statt 448,5px breit, was im Heft
als „das Programm ist schmaler als das Vorwort drüber" auffiel. **Soll der Steg je zurück, dann für
ALLE Bögen**, nie nur für einen Teil.
**Drei Seiten haben eine eigene `aspect-ratio` und heben per negativem Margin das Seiten-Padding auf,
damit ihr Foto bis an die Kante läuft: Vorwort, Sportheim und Termine.** Alle drei brauchen deshalb
`min-height: calc(100% + var(--space-l) * 2)` und stehen dafür in **einer** gemeinsamen Regel — beim
Vorwort allein behoben fehlte sie den anderen zwei, und die Sportheim-Seite war dadurch 625,7px hoch
in einer 636,4px hohen Seite (10,7px weißer Streifen unter dem Foto). Eine vierte Seite mit diesem
Konstrukt gehört in denselben Selektor.
`@media print`
(`:has()`-Scope auf Viewer/Druckfassung, andere Seiten bleiben unangetastet) regelt nur Layout —
Farben sind digital bereits hell. Der normale Viewer druckt als einfacher Seiten-Stapel (Fallback);
fürs Heft immer die Druckfassung nehmen. Bei Änderungen an Komponenten, die in einer Ausgabe
@@ -906,8 +1149,22 @@ mitziehen.
das neue Jahr erneut aufrufen. Das Jahr außen hält die oberste Ebene kurz, die Abteilungen innen sind
dieselben wie die Bereiche der Website (dann ist beim Hochladen klar, wohin das Material gehört).
Tiefer wird nichts vorgegeben; Ereignisse bündeln die Hochladenden selbst als `2026-08-15 Sommerfest`.
Weil `uploads/` gitignored ist, muss das Skript **auf dem Server** laufen — Ordner per FTP hochzuladen
erzeugt bei Umlauten Doppelgänger (macOS NFD vs. Linux NFC).
Das Skript läuft **auf dem Server** — Ordner per FTP hochzuladen erzeugt bei Umlauten Doppelgänger
(macOS NFD vs. Linux NFC).
**`uploads/` ist versioniert** (Beschluss 31.07.2026, vorher gitignored): das Material des Vereins ist
damit gesichert und beim Entwickeln lokal da, und ein frischer Klon ist wirklich vollständig. Der
Ordner ist aber das einzige Verzeichnis im Repo, in das **andere Menschen** schreiben — daraus folgt
die eine Regel, die das Ganze zusammenhält:
> **Erst auf dem Server committen und pushen, dann lokal pullen, dann entwickeln.** Nie umgekehrt.
Hält man sie ein, kann kein Deploy hochgeladene Fotos überschreiben (ein `pull` fasst neue Dateien
auf dem Server ohnehin nicht an, aber ein `reset`/`checkout` schon). Zweiter Preis, dauerhaft: **Git
vergisst nichts** — eine gelöschte Datei bleibt in der Historie, das Repo schrumpft nie wieder.
Deshalb Videos sparsam: zwei Sportfest-Clips machten allein 85 der ersten 174 MB, und für die
Website wären sie ohnehin neu zu kodieren. Wird es unbequem, ist der Schnitt `*.mp4` auszunehmen und
Videos separat zu sichern; die Fotos und die `.quelle.txt`-Belege der Chronik sind der wertvolle Teil.
**Genau ein Patch in der Fremd-Datei** (`index.php:122`, `$localconfigpath` absolut statt relativ) —
ohne ihn startet die App bei abweichendem Arbeitsverzeichnis ohne Login. **Update ausschließlich über

View File

@@ -60,6 +60,7 @@ $name = $field('name');
$email = $field('email');
$phone = $field('phone');
$interest = $field('interest');
$birthdate = $field('birthdate');
$subject = $field('subject');
$message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
@@ -74,6 +75,9 @@ if (!$variant['interest_required']) {
if (!$variant['ask_subject']) {
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
}
if (!$variant['ask_birthdate']) {
$birthdate = ''; // dito — nur die Turn-Variante fragt danach
}
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
@@ -86,6 +90,21 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
if (mb_strlen($phone) > 50) {
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
}
// Geburtsdatum: optional, aber wenn angegeben, dann ein echtes Datum in einem
// plausiblen Bereich. type=date liefert YYYY-MM-DD; createFromFormat mit
// anschließendem Vergleich weist auch Scheindaten wie 2019-02-31 ab (PHP würde
// die sonst stillschweigend auf den 3. März verschieben).
if ($birthdate !== '') {
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
$heute = new DateTimeImmutable('today');
if ($d === false || $d->format('Y-m-d') !== $birthdate) {
$errors['contact-birthdate'] = 'Bitte gib das Geburtsdatum im Format TT.MM.JJJJ an.';
} elseif ($d > $heute) {
$errors['contact-birthdate'] = 'Das Geburtsdatum kann nicht in der Zukunft liegen.';
} elseif ($d < new DateTimeImmutable('1900-01-01')) {
$errors['contact-birthdate'] = 'Bitte prüfe das Geburtsdatum.';
}
}
if (!in_array($interest, $interests, true)) {
$errors['contact-interest'] = $variant['interest_required']
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
@@ -110,7 +129,8 @@ if ($errors !== []) {
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
render_form_invalid($return, [
'name' => $name, 'email' => $email, 'phone' => $phone, 'interest' => $interest,
'subject' => $subject, 'message' => $message, 'privacy' => $privacy ? '1' : '',
'birthdate' => $birthdate, 'subject' => $subject, 'message' => $message,
'privacy' => $privacy ? '1' : '',
], $errors);
}
@@ -148,6 +168,7 @@ $body = $variant['context'] . " über tsv08kulmbach.de\n"
. "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
. ($birthdate !== '' ? 'Geburtstag des Kindes: ' . german_date($birthdate) . "\n" : '')
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
. str_repeat('-', 40) . "\n\n"
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")

View File

@@ -6,11 +6,23 @@ declare(strict_types=1);
* Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth).
* Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo.
* Pro Slide optional: "variant" (CSS-Modifier, z. B. "house" für Eigenwerbung),
* "tag" (Badge-Text, Default "Anzeige"), "background" (Bild), "cta.internal"
* "tag" (Badge-Text, Default "Anzeige"), "cta.internal"
* (interner Link ohne target=_blank/rel=sponsored, für eigene Seiten/Anker),
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot
* der Buttons für diese Slide, z. B. Sponsor-CI).
* für dunkle Firmenlogos).
*
* "background" ist ein Varianten-Set {base, widths} und wird als <img> gerendert,
* NICHT als CSS-Hintergrund. Grund: die CSP (default-src 'self', kein style-src)
* verwirft jedes style-Attribut, eine Inline-Variable --banner-bg käme also nie an.
* Das fiel lokal nie auf, weil `php -S` keine .htaccess und damit keine CSP kennt —
* erst auf echtem Apache blieben die Slides schwarz (31.07.2026). Als <img> ist es
* ohnehin besser: srcset lädt am Handy die 640er statt der 1600er. Ein String bleibt
* als Einzeldatei zulässig (Bestandsschutz, kein srcset).
*
* "accent" ist ein NAME, keine Farbe ("breadcrumb") — die Farbe steht als Token in
* tokens.css, die Klasse ad-banner__slide--accent-<name> in components.css. Ein
* beliebiger Hex-Wert aus der JSON käme nur per Inline-Style in die Seite, und den
* verwirft die CSP. Neuer Sponsor mit eigener CI = ein Token plus eine Klasse.
* Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
@@ -34,25 +46,30 @@ $total = count($banners);
$variant = $banner['variant'] ?? null;
$accent = $banner['accent'] ?? null;
// Slug-Filter für beides: der Wert landet in einem Klassennamen, nie ungeprüft.
$slug = static fn (string $v): string => (string) preg_replace('/[^a-z0-9-]+/', '', strtolower($v));
$classes = 'ad-banner__slide';
if ($bg) {
$classes .= ' ad-banner__slide--bg';
}
if ($accent) {
$classes .= ' ad-banner__slide--accent';
$classes .= ' ad-banner__slide--accent ad-banner__slide--accent-' . $slug((string) $accent);
}
if ($variant) {
$classes .= ' ad-banner__slide--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
$classes .= ' ad-banner__slide--' . $slug((string) $variant);
}
$styleParts = [];
if ($bg) {
$styleParts[] = '--banner-bg: url(' . asset($bg) . ')';
// Hintergrundbild als Bild-Prop für die img-Komponente. alt="" — der Hintergrund
// ist Dekoration, die Aussage der Slide steht als Text daneben.
$bgImage = null;
if (is_array($bg) && !empty($bg['base'])) {
$bgImage = ['base' => $bg['base'], 'widths' => $bg['widths'] ?? [1600], 'alt' => ''];
} elseif (is_string($bg) && $bg !== '') {
$datei = PUBLIC_PATH . '/assets/' . ltrim($bg, '/');
[$w, $h] = is_file($datei) ? (getimagesize($datei) ?: [0, 0]) : [0, 0];
$bgImage = ['src' => $bg, 'width' => $w, 'height' => $h, 'alt' => ''];
}
if ($accent) {
$styleParts[] = '--banner-accent: ' . $accent;
}
$style = $styleParts !== [] ? ' style="' . e(implode('; ', $styleParts)) . '"' : '';
?>
<?php
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
@@ -69,7 +86,17 @@ $total = count($banners);
. '</a>';
};
?>
<li class="<?= e($classes) ?>"<?= $style ?> data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
<li class="<?= e($classes) ?>" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
<?php if ($bgImage !== null): ?>
<?php /* Erstes Kind und absolut hinter dem Inhalt. Die erste Slide lädt eager
(sie ist above the fold und trägt das LCP), die folgenden lazy. */ ?>
<?php component('img', [
'image' => $bgImage,
'class' => 'ad-banner__bg',
'sizes' => '(max-width: 1200px) 100vw, 1200px',
'eager' => $i === 0,
]); ?>
<?php endif; ?>
<?php if (!empty($banner['logo'])): ?>
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>"<?= img_intrinsic_attrs($banner['logo']) ?> loading="lazy" decoding="async">

View File

@@ -9,6 +9,15 @@ declare(strict_types=1);
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
* Startseite springt.
* $notice string optional — hervorgehobener Hinweis DIREKT über dem Formular, plus
* $noticeTitle, $noticeTone und $noticeIcon (siehe components/notice.php). Für Dinge, die man wissen muss, BEVOR
* man schreibt (Wartelisten-Status der Turnabteilung). Steht bewusst außerhalb
* des <form> und damit außerhalb der role="status"-Region: das ist ein
* dauerhafter Hinweis, keine Rückmeldung zum Absenden — in der Live-Region
* würde ihn ein Screenreader bei jeder Formularmeldung erneut vorlesen.
* Der Text selbst gehört in die data/*.json der aufrufenden Seite, damit er
* nicht zweimal existiert (auf /turnen ist es derselbe Hinweis, den
* group-schedule.php auf der Kinderturn-Unterseite als Kachel zeigt).
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
@@ -21,6 +30,10 @@ declare(strict_types=1);
$club = json_load('club');
$return = $return ?? '';
$intro = $intro ?? null;
$notice = $notice ?? '';
$noticeTitle = $noticeTitle ?? '';
$noticeTone = $noticeTone ?? 'info';
$noticeIcon = $noticeIcon ?? 'info-circle';
$variant = contact_variant((string) ($department ?? ''));
$interests = $variant['interests'];
@@ -43,6 +56,14 @@ $f = [
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
// Optional und nur in der Turn-Variante (siehe contact_variant). max/min
// begrenzen die Auswahl im Datepicker schon im Browser; die Action prüft es
// serverseitig noch einmal.
'birthdate' => [
'type' => 'date', 'name' => 'birthdate', 'id' => 'contact-birthdate',
'label' => 'Geburtsdatum des Kindes', 'hint' => 'Optional. Hilft uns, die passende Gruppe zu finden.',
'min' => '1900-01-01', 'max' => date('Y-m-d'),
],
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
'privacy' => [
@@ -60,6 +81,19 @@ $f = [
<p><?= e($intro['text']) ?></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
</div>
<?php endif; ?>
<?php if ($notice !== ''): ?>
<?php /* level h3, weil der Intro-Block direkt darüber die h2 der Sektion trägt — so
bleibt die Überschriften-Folge ohne Sprung. Kein role="alert": der Hinweis steht
schon beim Laden da und ist keine Reaktion auf eine Eingabe. */ ?>
<?php component('notice', [
'title' => $noticeTitle,
'text' => $notice,
'tone' => $noticeTone,
'icon' => $noticeIcon,
'level' => 'h3',
'class' => 'contact__notice',
]); ?>
<?php endif; ?>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
@@ -79,6 +113,9 @@ $f = [
<div class="form__row">
<?php component('form-field', ['field' => $f['phone']]); ?>
<?php component('form-field', ['field' => $f['interest']]); ?>
<?php if ($variant['ask_birthdate']): ?>
<?php component('form-field', ['field' => $f['birthdate']]); ?>
<?php endif; ?>
</div>
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,

View File

@@ -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>

View File

@@ -6,11 +6,11 @@ declare(strict_types=1);
* Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare.
*
* Props: $field (array):
* type text | email | tel | select | textarea | checkbox (Default: text)
* type text | email | tel | date | select | textarea | checkbox (Default: text)
* name, label Pflicht
* id Default: field-<name>
* required bool
* autocomplete, maxlength, rows (textarea)
* autocomplete, maxlength, rows (textarea), min/max (date)
* options[] + placeholder (select)
* hint optionaler Hilfetext (aria-describedby)
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code

View File

@@ -5,13 +5,43 @@ 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
* $noticeIcon string (optional) — Icon-Name, siehe components/notice.php
* Gerendert wird über component('notice') — dieselbe Komponente, die
* contact-form.php über dem Formular benutzt, damit derselbe Hinweis an
* beiden Orten gleich aussieht und nur EINMAL in data/turnen.json steht.
* $groups array von Gruppen:
* { 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.
@@ -20,9 +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 ?? [];
@@ -31,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;
@@ -42,6 +81,9 @@ foreach ($groups as $g) {
}
}
// Schlichte Gruppen direkt ins Dashboard ziehen (nur wenn mehrere und keine „reich").
$groupsInline = $groupsInDash && !$single && !$rich;
$hasVenue = !empty($location['venue']);
$mapsQuery = $hasVenue ? rawurlencode(trim(
($location['venue'] ?? '') . ', '
@@ -50,7 +92,14 @@ $mapsQuery = $hasVenue ? rawurlencode(trim(
', '
)) : '';
/** Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Übungsleitung) ausgeben. */
/**
* Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Trainerteam) ausgeben.
*
* Das Label heißt „Trainerteam", nicht „Übungsleitung": in der Liste stehen auch
* Personen, die KEINE Übungsleitung sind (Eva Wünsch aus der Abteilung, 31.07.2026).
* Wer eine ist, trägt ein Sternchen, und das erklärt die Fußnote der Sektion. Die alte
* Seite schrieb dafür „TRAINERTEAM (* = ÜBUNGSLEITER)". Nicht zurückbenennen.
*/
$renderSession = static function (array $s): void {
?>
<li class="schedule-row">
@@ -65,93 +114,20 @@ $renderSession = static function (array $s): void {
<span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
<?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 !== ''): ?>
<article class="bento__tile turnen-dash__notice">
<?= icon('info-circle', 'turnen-dash__notice-icon') ?>
<p><?= e($notice) ?></p>
</article>
<?php endif; ?>
<?php if ($single): ?>
<article class="bento__tile turnen-dash__training">
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
<ul class="bento__sessions">
<?php foreach ($soleSessions as $s): ?>
<li>
<span class="bento__day"><?= e($s['day']) ?></span>
<span class="bento__time"><?= e($s['time']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php if ($soleNote !== ''): ?>
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
<?php endif; ?>
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
</article>
<?php endif; ?>
<?php if ($lead !== '' || $email !== ''): ?>
<article class="bento__tile bento__tile--accent turnen-dash__contact">
<span class="bento__stat-label">Kontakt</span>
<?php if ($lead !== ''): ?>
<span class="bento__stat-value"><?= e($lead) ?></span>
<?php endif; ?>
<?php if ($email !== ''): ?>
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
<?php endif; ?>
</article>
<?php endif; ?>
<?php if ($hasVenue): ?>
<article class="bento__tile turnen-dash__venue">
<div class="turnen-dash__venue-main">
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
<p class="bento__address">
<strong><?= e($location['venue']) ?></strong>
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
</p>
</div>
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&amp;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>
@@ -180,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">
@@ -211,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&amp;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 !== ''): ?>

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
/**
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props:
* $image array{base: string, widths: int[], alt: string} — Varianten <base>-<w>.jpg
* $image array{base: string, widths: int[], alt: string, ext?: string} — Varianten
* <base>-<w>.<ext>, Default-Endung jpg. ext: 'png' für freigestellte Motive,
* deren Transparenz JPG zerstören würde (Spielerfoto auf dem Cover).
* ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
* $sizes string (optional, default '100vw')
* $eager bool (optional) — above the fold: eager + fetchpriority high
@@ -24,15 +26,16 @@ if (!empty($image['src'])): ?>
$widths = $image['widths'];
$largest = max($widths);
$ext = strtolower((string) ($image['ext'] ?? 'jpg'));
$srcset = implode(', ', array_map(
static fn (int $w): string => asset("{$image['base']}-{$w}.jpg") . " {$w}w",
static fn (int $w): string => asset("{$image['base']}-{$w}.{$ext}") . " {$w}w",
$widths
));
// Maße der größten Variante für width/height (CLS-Vermeidung).
[$w, $h] = (function () use ($image, $largest): array {
[$w, $h] = (function () use ($image, $largest, $ext): array {
static $dims = [];
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg";
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.{$ext}";
$key = $file;
if (!isset($dims[$key])) {
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
@@ -42,7 +45,7 @@ $srcset = implode(', ', array_map(
})();
?>
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>"
src="<?= e(asset("{$image['base']}-{$largest}.{$ext}")) ?>"
srcset="<?= e($srcset) ?>"
sizes="<?= e($sizes) ?>"
alt="<?= e($image['alt']) ?>"

43
app/components/notice.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* Hervorgehobener Hinweis, den es in zwei Tonlagen gibt. Props:
* $text string — der Hinweis selbst (Pflicht, leer → nichts)
* $title string (optional) — Überschrift. Ohne sie liest sich besonders die rote
* Variante wie eine Fehlermeldung; mit ihr ist klar, dass es eine Information ist.
* $tone 'info' (Default) | 'stop' — 'info' ist die neutrale Glas-Fläche mit rotem Icon,
* 'stop' die gefüllte rote. Die Tonlage sagt, ob etwas GEHT oder NICHT geht:
* „neue Kinder können schnuppern" ist eine Einladung und darf nicht so aussehen
* wie „wir können niemanden aufnehmen". Rot ist die Ausnahme, nicht der Standard.
* $icon string (optional, Default 'info-circle') — Icon-Name aus public/assets/icons/
* $level 'h2' (Default) | 'h3' — Überschriftenebene, passend zur Umgebung, damit die
* Folge auf der Seite nicht springt
* $class string (optional) — Zusatzklassen für die Einbettung (Position/Breite im
* jeweiligen Layout). Farbe und Innenaufbau kommen ausschließlich von hier.
*
* Bewusst eine Komponente statt dreimal dasselbe Markup: der Hinweis steht auf
* /turnen über dem Anfrageformular, als Kachel auf /turnen/eltern-kind-turnen und
* auf /turnen/trampolin. Der TEXT gehört nie hierher, sondern in die data/*.json
* der Seite (dort steht auch, welche Tonlage gilt).
*/
$text = trim((string) ($text ?? ''));
if ($text === '') {
return;
}
$title = trim((string) ($title ?? ''));
$tone = ($tone ?? 'info') === 'stop' ? 'stop' : 'info';
$icon = (string) ($icon ?? 'info-circle');
$level = ($level ?? 'h2') === 'h3' ? 'h3' : 'h2';
$class = trim((string) ($class ?? ''));
?>
<div class="notice notice--<?= $tone ?><?= $class !== '' ? ' ' . e($class) : '' ?>">
<?= icon($icon, 'notice__icon') ?>
<div class="notice__body">
<?php if ($title !== ''): ?>
<<?= $level ?> class="notice__title"><?= e($title) ?></<?= $level ?>>
<?php endif; ?>
<p><?= e($text) ?></p>
</div>
</div>

View 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>

View File

@@ -31,11 +31,20 @@ $issues = $issues ?? [];
<?php endif; ?>
</div>
<div class="stadionzeitung-card__body">
<h3 class="stadionzeitung-card__name">Gegen <?= e($issue['opponent']) ?></h3>
<h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e($issue['opponent']) ?></h3>
<p class="stadionzeitung-card__meta">
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
· <?= (int) $issue['pages'] ?> Seiten
</p>
<?php /* Coverzeile als Teaser — dieselbe Quelle (stadionzeitung_teaser()), die auch
auf dem Cover unter „Im Heft" steht: cover.teaser einer Sonderausgabe, sonst
die Coverzeile des Interviews. Sie füllt die breite Zeile mit echtem Inhalt
statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte. Gibt es
beides nicht, entfällt die Zeile (wie bei .veranstaltung-card__text). */
$kartenTeaser = stadionzeitung_teaser($issue); ?>
<?php if ($kartenTeaser !== ''): ?>
<p class="stadionzeitung-card__text"><?= e($kartenTeaser) ?></p>
<?php endif; ?>
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
</div>
</a>

View File

@@ -69,19 +69,82 @@ 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)): ?>
<img class="stadionzeitung-cover__player" src="<?= e(asset($playerImage)) ?>" alt="" loading="lazy">
<?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,
deren dekodierte Größe sein Budget übersteigt (hier gut 53 MB), und auf einer
Ausgabe liegen rund 86 Bilder — das Cover blieb am iPhone leer, während Chrome
am Desktop es zeigte (31.07.2026). Dargestellt wird es mit 98cqi Höhe, bei
480px Seitenbreite also etwa 238x470 CSS-Pixel; die 1000er Variante deckt
damit auch Druck und dreifache Pixeldichte ab.
sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte. */
$playerBild = is_array($playerImage)
? $playerImage
: ['src' => $playerImage, 'width' => 0, 'height' => 0, 'alt' => ''];
?>
<?php component('img', [
'image' => $playerBild,
'class' => 'stadionzeitung-cover__player',
'sizes' => '(max-width: 600px) 50vw, 240px',
]); ?>
<?php endif; ?>
<div class="stadionzeitung-cover__inner">
<header class="stadionzeitung-cover__masthead">
@@ -95,6 +158,17 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
</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. */ ?>
@@ -107,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>

View 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>

View 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>

View 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>

View File

@@ -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; ?>

View File

@@ -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; ?>

View 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>

View File

@@ -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
@@ -71,7 +71,10 @@ $liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
<span class="stadionzeitung-viewer__icon--close"><?= icon('x-lg') ?></span>
</button>
</div>
<a class="btn btn--outline stadionzeitung-viewer__print" href="<?= e(url('stadionzeitung/' . ($issue['slug'] ?? '')) . '?druck=1') ?>">
<?= icon('printer') ?> Druckfassung
</a>
<?php /* Hier stand ein „Druckfassung"-Knopf. Er ist raus (31.07.2026, Felix): die
Druckfassung ist Werkzeug für die Heft-Produktion, nicht für Besucher — und
als sichtbarer Button neben Blättern und Vollbild wurde er versehentlich
geklickt und riss Leute aus dem Lesen. Die Route bleibt unverändert
erreichbar: an die Ausgaben-URL ein `?druck=1` anhängen. Nicht als Link
wieder einbauen; wer sie braucht, kennt sie. */ ?>
</section>

View File

@@ -208,8 +208,15 @@ function img_intrinsic_attrs(string $rel): string
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
*
* $alpha = true schreibt stattdessen <destBase>-<breite>.png und erhält die
* Transparenz — nötig für freigestellte Motive (Spielerfoto auf dem
* Stadionzeitungs-Cover), bei denen JPG die Freistellung gegen Schwarz wegrechnen
* würde. $quality gilt dann nicht (PNG ist verlustfrei). Der Default bleibt JPG,
* weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern und deren
* Pfade in den data/*.json auf .jpg zeigen.
*/
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78): array
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78, bool $alpha = false): array
{
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
@@ -229,9 +236,25 @@ function resize_image_variants(string $src, string $destBase, array $widths, int
$w = min((int) $width, $srcW); // nie hochskalieren
$h = (int) round($srcH * $w / $srcW);
$frame = imagecreatetruecolor($w, $h);
if ($alpha) {
// Transparenz retten: ohne Blending kopieren (sonst rechnet GD den
// Alphakanal gegen Schwarz weg) und die Fläche vorher transparent füllen.
imagealphablending($frame, false);
imagesavealpha($frame, true);
$leer = (int) imagecolorallocatealpha($frame, 0, 0, 0, 127);
imagefilledrectangle($frame, 0, 0, $w, $h, $leer);
}
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
$file = "{$destBase}-{$w}.jpg";
imagejpeg($frame, $file, $quality);
if ($alpha) {
$file = "{$destBase}-{$w}.png";
imagepng($frame, $file, 9);
} else {
$file = "{$destBase}-{$w}.jpg";
imagejpeg($frame, $file, $quality);
}
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
}
@@ -545,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
@@ -932,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
@@ -969,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 === '') {
@@ -981,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(
@@ -1001,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.
@@ -1015,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'] ?? ''),
@@ -1035,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
@@ -1271,6 +1449,14 @@ function send_mail(array $opts): bool
$mail->Body = (string) $opts['body'];
$mail->send();
// Erfolg als INFO mitschreiben. Vorher loggte nur der Fehlerfall, damit war ein
// funktionierender Versand von einem stummen nicht zu unterscheiden — beim
// Deploy am 31.07.2026 stand im Log gar kein mail-Kanal, was „nichts
// abgeschickt" und „einwandfrei versandt" gleichzeitig bedeuten konnte.
// Nur der Kontext (welches Formular), NIE Empfänger, Absender oder Inhalt:
// das verbietet die Logging-Regel, und für die Frage „läuft der Versand?"
// braucht es davon auch nichts.
log_write('mail', 'INFO', "{$context} versandt.");
return true;
} catch (Throwable $e) {
log_write('mail', 'ERROR', "{$context} fehlgeschlagen: " . $e->getMessage());
@@ -1339,6 +1525,7 @@ function contact_variant(string $department): array
'interests' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
'interest_required' => false,
'ask_subject' => true,
'ask_birthdate' => false,
'message_required' => true,
'to' => '',
'context' => 'Kontaktanfrage',
@@ -1364,6 +1551,13 @@ function contact_variant(string $department): array
// Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
'interest_required' => true,
'ask_subject' => false,
// Geburtsdatum nur hier: die Turnabteilung braucht bei Anfragen für Kinder
// die Altersgruppe, um die passende Gruppe zuzuordnen (Mäuse, Tiger,
// Hühner …). Gab es auf der alten Seite als optionales Feld
// „Geburtsdatum des Kindes" und ist beim Neubau untergegangen.
// In der allgemeinen Variante wäre es sinnlos und würde unnötig
// personenbezogene Daten von Minderjährigen erheben.
'ask_birthdate' => true,
// Bewusst optional (wie auf der alten Seite): „Interessiere mich für
// Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
// Disziplin stehen ja schon da.

View File

@@ -168,7 +168,12 @@ $meta = [
Das <strong>Anfrageformular der Turnabteilung</strong> auf
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
interessierst; eine Nachricht ist dort freiwillig. Diese Anfragen gehen an das
interessierst; eine Nachricht ist dort freiwillig. Zusätzlich kannst du
<strong>freiwillig das Geburtsdatum des Kindes</strong> angeben, um das es bei der
Anfrage geht. Wir brauchen es nur, um die passende Turngruppe zuzuordnen, weil die
Gruppen nach Alter eingeteilt sind. Das Feld ist keine Pflichtangabe: ohne Datum
bearbeiten wir die Anfrage genauso und fragen gegebenenfalls nach dem Alter nach.
Diese Anfragen gehen an das
Postfach der Turnabteilung
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
weil die Abteilung sie selbst beantwortet.

View File

@@ -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">

View File

@@ -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' => ''],
@@ -27,7 +27,10 @@ component('hero', ['hero' => $d['hero']]);
component('group-schedule', [
'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '',
'notice' => $d['notice'] ?? '',
'notice' => $d['notice'] ?? '',
'noticeTitle' => $d['notice_title'] ?? '',
'noticeTone' => $d['notice_tone'] ?? 'info',
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
'groups' => $d['groups'] ?? [],
'footnote' => $data['footnote'] ?? '',
]);

View File

@@ -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'],

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* Turnen → Fitness & Pilates. Inhalt: data/turnen.json
* (disciplines.fitness-pilates). Struktur: Hero → Kurse/Zeiten → CTA.
*
* Hieß bis 31.07.2026 „Step Aerobic, Fitness & Pilates" unter dem Slug
* turnen/step-aerobic-fitness-pilates. Step Aerobic findet nicht mehr statt
* (Rückmeldung der Turnabteilung), die Gruppe ist aus data/turnen.json entfernt.
* Seit 19.08.2026 laufen hier Pilates, Functional Fitness I (Mittwoch) und der neue
* Functional Fitness II (Donnerstag). Der alte Slug leitet per 301 hierher
* (public/.htaccess).
*/
$slug = 'turnen/fitness-pilates';
$d = json_load('turnen')['disciplines']['fitness-pilates'];
$meta = [
'title' => 'Fitness & Pilates in Kulmbach',
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Fitness und Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
'og_image' => 'img/pages/turnen-fitness-pilates-1536.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],
['name' => 'Fitness & Pilates', 'slug' => $slug],
]),
];
component('hero', ['hero' => $d['hero']]);
component('group-schedule', [
'groupsTitle' => 'Unsere Kurse',
'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [],
// Wenige, beschreibende Kurse → offen untereinander statt Tabs (siehe group-schedule.php).
'layout' => 'stacked',
]);

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
/**
* Turnen → Step Aerobic, Fitness & Pilates. Inhalt: data/turnen.json
* (disciplines.step-aerobic-fitness-pilates). Struktur: Hero → Kurse/Zeiten → CTA.
*/
$slug = 'turnen/step-aerobic-fitness-pilates';
$d = json_load('turnen')['disciplines']['step-aerobic-fitness-pilates'];
$meta = [
'title' => 'Step Aerobic, Fitness & Pilates in Kulmbach',
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],
['name' => 'Step Aerobic, Fitness & Pilates', 'slug' => $slug],
]),
];
component('hero', ['hero' => $d['hero']]);
component('group-schedule', [
'groupsTitle' => 'Unsere Kurse',
'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [],
]);

View File

@@ -22,7 +22,15 @@ $meta = [
component('hero', ['hero' => $d['hero']]);
component('group-schedule', [
'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [],
'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'] ?? '',
]);

View File

@@ -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'] ?? '',
]);

View File

@@ -3,9 +3,25 @@
declare(strict_types=1);
/**
* Turn-Übersichtsseite (Hub + Abteilungskontakt). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten → Anfrageformular. Die Detailinhalte
* (Beiträge, Trainingszeiten) leben auf den Disziplin-Unterseiten.
* Turn-Übersichtsseite (Hub + Beiträge + Abteilungskontakt). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten → Beiträge → FAQ → Anfrageformular.
*
* Hier stand vorher „Die Detailinhalte (Beiträge, Trainingszeiten) leben auf den
* Disziplin-Unterseiten" — das war falsch, und zwar folgenreich: die Seite gab
* fees/austritt/faq NIE aus, die Unterseiten auch nicht, und damit standen die
* Turn-Beiträge NIRGENDS auf der Website (nachgeprüft 31.07.2026: 0 Treffer auf
* /turnen und allen fünf Unterseiten; /mitglied-werden führt nur die Fußball-
* Beiträge). Die Turnabteilung verlinkt diese Angaben in Einladungen für neue
* Familien, deshalb ist der Beitragsblock hier verankert (#beitraege).
* Die Daten selbst stimmen 1:1 mit der alten Seite überein (gegengeprüft an
* 2024.tsv08kulmbach.de): Zusatzbeitrag 24/48 €, Neumitglieder 84/94/184 €,
* Förderstufen 120/180/240 €, Austritt zum 30. September.
*
* Der Anmelde-Vorgang der Abteilung (erst Platz anfragen, dann Aufnahmeantrag) steht als
* eigene FAQ-Frage samt den zwei Knöpfen, nicht als eigene Sektion. Das war eine bewusste
* Entscheidung von Felix (31.07.2026): ein Band unter den Beiträgen war gebaut und wurde
* wieder entfernt, weil dieselbe Sache dann zweimal auf der Seite stand. Deshalb gibt es
* `overview.cta` nicht mehr — wer die Knöpfe sucht, findet sie in `overview.faq`.
*
* Das Formular ist die allgemeine contact-form-Komponente mit
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
@@ -15,28 +31,45 @@ 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' => 'Step Aerobic, Fitness & Pilates', 'slug' => 'turnen/step-aerobic-fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-step-aerobic', 'widths' => [640, 1000, 1600], 'alt' => 'Step Aerobic, Fitness und Pilates beim TSV 08 Kulmbach']],
['name' => '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']],
];
$meta = [
'title' => 'Turnen in Kulmbach',
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
// Zweiter Parameter = FAQ: sichtbare Fragen und FAQPage-Schema kommen aus
// derselben Quelle, damit sie nie auseinanderlaufen (CLAUDE.md).
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],
]),
], $page['faq'] ?? []),
];
component('hero', ['hero' => $page['hero']]);
@@ -44,10 +77,53 @@ component('team-cards', [
'teams' => $cards,
'title' => 'Unsere Turngruppen',
'title_hidden' => true,
'intro' => $page['cards_intro'] ?? null,
'anchor' => 'gruppen',
]);
?>
<?php /* Beiträge. Der Anker #beitraege ist die Adresse, die die Turnabteilung in
Einladungen weitergibt — nicht umbenennen. Drei Tabellen über die
bestehende fee-table-Komponente (dieselbe wie auf /mitglied-werden),
darunter die Austrittsregel, weil sie zur Beitragsfrage gehört. */ ?>
<section class="section section--tinted fees" id="beitraege">
<div class="container">
<header class="fees__head">
<h2><?= e($page['fees_title']) ?></h2>
<?php if (!empty($page['fees_intro'])): ?>
<p class="fees__intro"><?= e($page['fees_intro']) ?></p>
<?php endif; ?>
</header>
<div class="fee-tables">
<?php foreach ($page['fees'] as $feeTable): ?>
<?php component('fee-table', ['fees' => $feeTable]); ?>
<?php endforeach; ?>
</div>
<?php /* Bewusst NICHT .prose: dort rendert ein h3 mit 21px Abel, also exakt
Fließtextgröße — in Impressum und Artikeln richtig, hier ist „Austritt"
aber eine Auszeichnung und wäre nicht von Fließtext zu unterscheiden. */ ?>
<?php if (!empty($page['austritt']['title'])): ?>
<div class="fees__note">
<h3 class="fees__note-title"><?= e($page['austritt']['title']) ?></h3>
<p><?= $austrittText ?></p>
</div>
<?php endif; ?>
</div>
</section>
<?php
component('faq', ['faq' => $page['faq'] ?? []]);
/* Der Wartelisten-Hinweis steht DIREKT über dem Formular, so wie auf der alten Seite.
Grund ist Arbeitsaufwand in der Abteilung, kein Design: fuer „Tiger" und „Huehner"
kommen laufend Anfragen nach einem Wartelistenplatz, den es nicht gibt, und jede muss
einzeln beantwortet werden. Wer hier schreibt, soll es vorher lesen. Der Text kommt aus
derselben Stelle wie die Kachel auf der Kinderturn-Unterseite
(disciplines.eltern-kind-turnen.notice), ein Hinweis und eine Quelle. */
$kinderturnen = json_load('turnen')['disciplines']['eltern-kind-turnen'] ?? [];
component('contact-form', [
'intro' => $page['contact'],
'return' => 'turnen',
'department' => 'turnen',
'intro' => $page['contact'],
'return' => 'turnen',
'department' => 'turnen',
'notice' => $kinderturnen['notice'] ?? '',
'noticeTitle' => $kinderturnen['notice_title'] ?? '',
'noticeTone' => $kinderturnen['notice_tone'] ?? 'info',
'noticeIcon' => $kinderturnen['notice_icon'] ?? 'info-circle',
]);

View File

@@ -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

View File

@@ -22,7 +22,7 @@ return [
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
'turnen/eltern-kind-turnen' => ['file' => 'turnen-eltern-kind.php', 'sitemap' => true],
'turnen/step-aerobic-fitness-pilates' => ['file' => 'turnen-step-aerobic.php', 'sitemap' => true],
'turnen/fitness-pilates' => ['file' => 'turnen-fitness-pilates.php', 'sitemap' => true],
'turnen/trampolin' => ['file' => 'turnen-trampolin.php', 'sitemap' => true],
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
'mitmachen' => ['file' => 'mitmachen.php', 'sitemap' => true],

View File

@@ -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);
}

View File

@@ -16,11 +16,16 @@ declare(strict_types=1);
*
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
*/
if (PHP_SAPI !== 'cli') {
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// definiert. Nötig, weil die KAS-Cronjobs ausschließlich URLs aufrufen können und
// dieses Skript sonst nie automatisch liefe (Stand 31.07.2026).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$maxMb = 2.0;
$keepDays = 90;

View File

@@ -296,6 +296,114 @@ if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
}
}
// --- Veröffentlichte Stadionzeitungen: Bilder wirklich eingefroren? ---
// Der Snapshot friert die Daten ein. Zeigt er noch auf img/crests oder img/instagram,
// hängt die Ausgabe weiter am Sync: der räumt Wappen weg, die der BFV nicht mehr führt
// (eine Saison), und Instagram-Bilder älterer Posts (Wochen). Am 31.07.2026 löschte ein
// Lauf 18 Wappen und 3 Bilder. Danach zeigte eine gedruckte Ausgabe Lücken.
// bin/stadionzeitung-publish.php kopiert sie deshalb in den Ausgaben-Ordner.
// Reparatur einer Alt-Ausgabe: `--entwurf` und neu veröffentlichen.
$szFile = DATA_PATH . '/stadionzeitung.json';
if (is_file($szFile)) {
$szData = json_decode((string) file_get_contents($szFile), true) ?: [];
foreach ($szData['issues'] ?? [] as $ausgabe) {
if (($ausgabe['status'] ?? 'veroeffentlicht') !== 'veroeffentlicht' || !isset($ausgabe['snapshot'])) {
continue;
}
$rohSnap = (string) json_encode($ausgabe['snapshot']);
$fluechtig = [];
foreach (['img/crests/', 'img/instagram/'] as $ordner) {
// JSON escapt Schrägstriche, deshalb beide Schreibweisen prüfen.
if (str_contains($rohSnap, $ordner) || str_contains($rohSnap, str_replace('/', '\\/', $ordner))) {
$fluechtig[] = rtrim($ordner, '/');
}
}
if ($fluechtig !== []) {
$warnings[] = "Stadionzeitung '{$ausgabe['slug']}' ist veröffentlicht, ihr Snapshot zeigt aber noch auf "
. implode(' und ', $fluechtig) . ' — diese Ordner verwalten die Sync-Skripte, die Bilder verschwinden dort mit der Zeit.'
. ' Einfrieren: php bin/stadionzeitung-publish.php ' . $ausgabe['slug'] . ' --entwurf && php bin/stadionzeitung-publish.php ' . $ausgabe['slug'];
}
}
}
// --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) ---
// Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die
// Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug
// oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil
// die Komponenten bewusst still zurückkehren. Deshalb hier laut.
if (is_file($szFile)) {
$szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []);
$vBySlug = array_column($veranstaltungRows, null, 'slug');
foreach ($szData['issues'] ?? [] as $ausgabe) {
$vSlug = (string) ($ausgabe['veranstaltung'] ?? '');
if ($vSlug === '') {
continue;
}
if (!isset($vBySlug[$vSlug])) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': veranstaltung '{$vSlug}' fehlt in data/veranstaltungen.json.";
continue;
}
$turniere = $vBySlug[$vSlug]['turniere'] ?? [];
foreach ($turniere as $i => $turnier) {
foreach (['key', 'title', 'date', 'teams', 'spiele'] as $feld) {
if (empty($turnier[$feld])) {
$warnings[] = "data/veranstaltungen.json, '{$vSlug}' → turniere[{$i}]: Feld '{$feld}' fehlt oder ist leer.";
}
}
}
$keys = array_column($turniere, 'key');
foreach ($ausgabe['images'] ?? [] as $seite) {
if (($seite['type'] ?? '') === 'live' && ($seite['page'] ?? '') === 'turnier'
&& !in_array((string) ($seite['turnier'] ?? ''), $keys, true)) {
$errors[] = "Stadionzeitung '{$ausgabe['slug']}': Turnierseite '" . ($seite['turnier'] ?? '') . "' hat keinen Eintrag in turniere[] von '{$vSlug}'.";
}
}
}
}
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
// echtem Apache greift die CSP aus public/.htaccess (default-src 'self', KEIN
// style-src), der Browser verwarf das Attribut, die Slides blieben schwarz. Lokal
// war das unsichtbar, weil `php -S` keine .htaccess liest — der Fehler konnte also
// erst in Produktion auffallen. Genau davor schützt diese Wache.
// Hinweis: per JavaScript gesetzte Styles (element.style.…) sind NICHT betroffen,
// die CSP prüft nur vom HTML-Parser gelesene style-Attribute und <style>-Blöcke.
/* Zeilenweise und ohne Tag-Kontext geprüft: ein Muster, das beim öffnenden Tag
ansetzt und mit [^>]* weitersucht, bricht sofort ab — PHP-Templates enthalten
über das schließende PHP-Tag ständig eine spitze Klammer. Ein style-Attribut in
einer PHP-Datei ist praktisch immer echtes Markup, diese Prüfung genügt also.
Dieser Kommentar ist bewusst ein Blockkommentar: die Zeichenfolge des schließenden
PHP-Tags beendet den PHP-Modus AUCH in einem //-Kommentar. Genau daran ist diese
Prüfung beim Bauen einmal gescheitert (preflight gab ab dort seinen eigenen
Quelltext aus, und php -l meldete nichts, weil das gültiges PHP mit HTML ist). */
$inlineStyles = [];
$phpDateien = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ROOT_PATH . '/app'));
foreach ($phpDateien as $datei) {
if (!$datei->isFile() || $datei->getExtension() !== 'php') {
continue;
}
foreach (file($datei->getPathname()) ?: [] as $nr => $zeile) {
$roh = ltrim($zeile);
// Kommentarzeilen überspringen — dieser Abschnitt selbst nennt style=" ja auch.
if ($roh === '' || str_starts_with($roh, '*') || str_starts_with($roh, '//') || str_starts_with($roh, '/*')) {
continue;
}
if (preg_match('/\sstyle\s*=\s*"/i', $zeile) === 1) {
$kurz = str_replace(ROOT_PATH . '/', '', $datei->getPathname());
$inlineStyles[] = $kurz . ':' . ($nr + 1);
break;
}
}
}
if ($inlineStyles !== []) {
$errors[] = 'Inline-Style im Markup — die CSP (kein style-src) verwirft ihn, die Regel greift NUR in Produktion: '
. implode(', ', $inlineStyles)
. '. Bilder als <img> ausgeben, Farben als Token in tokens.css plus Klasse in components.css.';
}
// --- Ausgabe ---
foreach ($warnings as $w) {
echo "WARNUNG {$w}\n";

View File

@@ -9,7 +9,13 @@ declare(strict_types=1);
* NUR CLI — nie im Request-Pfad. Debug-Ausgabe bleibt aus, damit keine
* Zugangsdaten (Base64 im AUTH-Dialog) im Terminal oder in Logs landen.
*
* Aufruf: php bin/smtp-test.php
* Aufruf: php bin/smtp-test.php (Passwort aus config/config.php)
* php bin/smtp-test.php --frage (Passwort verdeckt eingeben)
*
* --frage ist für den Fall, dass das Postfach-Passwort neu gesetzt wurde: erst
* prüfen, ob es überhaupt trägt, dann in die Config schreiben. Sonst schreibt man
* einen ungetesteten Wert in eine Datei, die auf zwei Rechnern liegt. Verdeckte
* Eingabe statt Argument, damit kein Klartext in der Shell-History landet.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
@@ -20,12 +26,90 @@ require dirname(__DIR__) . '/app/bootstrap.php';
use PHPMailer\PHPMailer\PHPMailer;
$smtp = config('smtp');
$frage = in_array('--frage', $argv, true);
if ($frage) {
echo "Postfach-Passwort für {$smtp['username']} (Eingabe unsichtbar): ";
system('stty -echo');
$eingabe = trim((string) fgets(STDIN));
system('stty echo');
echo "\n";
if ($eingabe === '') {
fwrite(STDERR, "Abbruch: kein Passwort eingegeben.\n");
exit(1);
}
$smtp['password'] = $eingabe;
}
if (in_array($smtp['password'] ?? '', ['', 'POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
fwrite(STDERR, "Abbruch: In config/config.php steht noch kein echtes SMTP-Passwort.\n");
fwrite(STDERR, "Zum Ausprobieren ohne Config-Änderung: php bin/smtp-test.php --frage\n");
exit(1);
}
// --- --probe: mehrere Zugangswege durchprobieren ---
// Hintergrund (31.07.2026): dieselben Zugangsdaten wurden vom Entwicklungsrechner
// akzeptiert, vom Webspace aber mit „Could not authenticate" abgelehnt.
// w01ca75d.kasserver.com und der Webserver zeigen auf dieselbe IP — vom Server aus
// verbindet sich PHP also mit der eigenen Maschine, und die kann Anmeldungen anders
// behandeln als von außen. Diese Sonde findet heraus, welcher Weg trägt, statt zu raten.
// Bewusst nur vier Kombinationen: jeder Fehlversuch ist ein fehlgeschlagener Login,
// und zu viele davon können ein Postfach vorübergehend sperren.
if (in_array('--probe', $argv, true)) {
$wege = [
['label' => 'Konfiguriert: Host, Port ' . (int) $smtp['port'] . ', STARTTLS, mit Anmeldung',
'host' => $smtp['host'], 'port' => (int) $smtp['port'], 'sec' => PHPMailer::ENCRYPTION_STARTTLS, 'auth' => true],
['label' => 'Host, Port 465, SSL, mit Anmeldung',
'host' => $smtp['host'], 'port' => 465, 'sec' => PHPMailer::ENCRYPTION_SMTPS, 'auth' => true],
['label' => 'localhost, Port 25, ohne Verschlüsselung, mit Anmeldung',
'host' => 'localhost', 'port' => 25, 'sec' => '', 'auth' => true],
['label' => 'localhost, Port 25, ohne Verschlüsselung, OHNE Anmeldung (lokaler Relay)',
'host' => 'localhost', 'port' => 25, 'sec' => '', 'auth' => false],
];
echo "Sonde: welcher Weg nimmt uns an? Postfach {$smtp['username']}\n\n";
$erfolg = [];
foreach ($wege as $i => $weg) {
$m = new PHPMailer(true);
$m->isSMTP();
$m->Host = $weg['host'];
$m->Port = $weg['port'];
$m->SMTPAuth = $weg['auth'];
$m->SMTPSecure = $weg['sec'];
if (!$weg['auth']) {
$m->SMTPAutoTLS = false;
}
$m->Username = $smtp['username'];
$m->Password = $smtp['password'];
$m->Timeout = 12;
try {
$ok = $m->smtpConnect();
$m->smtpClose();
} catch (Throwable $e) {
$ok = false;
$m->ErrorInfo = $e->getMessage();
}
printf(" %d. %-68s %s\n", $i + 1, $weg['label'], $ok ? 'GEHT' : 'nein');
if (!$ok && $m->ErrorInfo !== '') {
echo ' ' . trim(preg_replace('/\s+/', ' ', $m->ErrorInfo) ?? '') . "\n";
}
if ($ok) {
$erfolg[] = $weg;
}
}
echo "\n";
if ($erfolg === []) {
echo "Kein Weg trägt. Dann liegt es am Postfach selbst: Passwort im KAS neu setzen\n";
echo "und mit php bin/smtp-test.php --frage gegenprüfen.\n";
exit(1);
}
$w = $erfolg[0];
echo "Nimm den ersten Treffer in config/config.php: host {$w['host']}, port {$w['port']}\n";
echo 'Verschlüsselung ' . ($w['sec'] === '' ? 'keine' : $w['sec']) . ', Anmeldung ' . ($w['auth'] ? 'ja' : 'nein') . "\n";
exit(0);
}
echo "Teste {$smtp['host']}:{$smtp['port']} (STARTTLS) als {$smtp['username']}\n";
$mail = new PHPMailer(true);
@@ -42,9 +126,24 @@ try {
if ($mail->smtpConnect()) {
$mail->smtpClose();
echo "OK: Verbindung, STARTTLS und Login erfolgreich — keine Mail versendet.\n";
if ($frage) {
echo "Dieses Passwort trägt. Jetzt in config/config.php unter smtp.password eintragen.\n";
}
exit(0);
}
fwrite(STDERR, 'Fehler: ' . ($mail->ErrorInfo !== '' ? $mail->ErrorInfo : 'Verbindung fehlgeschlagen.') . "\n");
$info = $mail->ErrorInfo !== '' ? $mail->ErrorInfo : 'Verbindung fehlgeschlagen.';
fwrite(STDERR, 'Fehler: ' . $info . "\n");
// „Could not authenticate" heißt: Host, Port, Firewall und STARTTLS sind in
// Ordnung, nur die Zugangsdaten wurden abgelehnt. Das ist die Meldung, die nach
// einem All-Inkl-Tarifwechsel kam (31.07.2026) — dort mussten die
// Postfach-Passwörter im KAS neu gesetzt werden.
if (str_contains($info, 'Could not authenticate')) {
fwrite(STDERR, "\nDie Verbindung stand, abgelehnt wurde nur die Anmeldung. Prüfe:\n");
fwrite(STDERR, " 1. Postfach-Passwort im KAS neu setzen (nach einem Tarifwechsel nötig).\n");
fwrite(STDERR, " 2. Stimmt der Postfachname exakt? Aktuell: {$smtp['username']}\n");
fwrite(STDERR, " 3. Stimmt der Mailserver? Aktuell: {$smtp['host']} (im KAS unter E-Mail nachsehen).\n");
fwrite(STDERR, " Neues Passwort ohne Config-Änderung ausprobieren: php bin/smtp-test.php --frage\n");
}
exit(1);
} catch (Throwable $e) {
fwrite(STDERR, 'Fehler: ' . $e->getMessage() . "\n");

View File

@@ -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];
@@ -481,23 +589,49 @@ if ($total % 4 !== 0) {
$log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
}
// --- Spielerfoto fürs Cover: unverändert kopieren (kein resize_image_variants(),
// das würde über imagejpeg() die Transparenz zerstören — Felix liefert bereits
// ein web-taugliches Foto). Optional, kein Fehlschlag bei Fehlen. ---
// --- Spielerfoto fürs Cover: zuschneiden UND verkleinern. ---
// Hier stand früher „unverändert kopieren, Felix liefert bereits ein web-taugliches
// Foto" — die Annahme stimmte nicht. Das Hutschdorf-Foto hatte 2584x5093 Pixel und
// 12 MB; iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt
// (hier gut 53 MB), und auf einer Ausgabe liegen rund 86 Bilder. Ergebnis: das Cover
// blieb am iPhone leer, während Chrome am Desktop es zeigte (31.07.2026).
// Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa
// 238x470 CSS-Pixel — die 1000er Variante deckt Druck und dreifache Pixeldichte ab.
// resize_image_variants() erhält bei $alpha = true die Freistellung (PNG statt JPG).
// Optional, kein Fehlschlag bei Fehlen.
$playerImage = null;
if ($spieler !== null) {
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
$playerDest = "{$destDir}/spieler.{$ext}";
// PNG: auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
// leerem Rand aus dem Export-Tool). Andere Formate (kein Alpha) unverändert.
$copied = $ext === 'png'
? crop_transparent_png_to_content($spieler, $playerDest)
: copy($spieler, $playerDest);
if ($copied) {
$playerImage = "img/stadionzeitung/{$slug}/spieler.{$ext}";
$log('Spielerfoto übernommen' . ($ext === 'png' ? ' (auf Inhalt zugeschnitten).' : ' (unverändert kopiert).'));
} else {
$log("Spielerfoto konnte nicht kopiert werden: {$spieler}", 'WARN');
$istPng = $ext === 'png';
// PNG: erst auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
// leerem Rand aus dem Export-Tool), dann verkleinern. Der Zuschnitt geht in eine
// Zwischendatei, damit das riesige Original nicht im Ausgaben-Ordner landet.
$quelle = $spieler;
$tmpCrop = "{$destDir}/spieler-zuschnitt.png";
if ($istPng && crop_transparent_png_to_content($spieler, $tmpCrop)) {
$quelle = $tmpCrop;
}
try {
$varianten = resize_image_variants($quelle, "{$destDir}/spieler", [320, 640, 1000], 82, $istPng);
$playerImage = [
'base' => "img/stadionzeitung/{$slug}/spieler",
'widths' => array_map(static fn (array $v): int => $v['width'], $varianten),
'ext' => $istPng ? 'png' : 'jpg',
'alt' => '',
];
$groesse = array_sum(array_map(static fn (array $v): int => $v['bytes'], $varianten));
$log(sprintf(
'Spielerfoto übernommen: %d Varianten%s, zusammen %.1f MB (Original %.1f MB).',
count($varianten),
$istPng ? ' mit Transparenz, auf Inhalt zugeschnitten' : '',
$groesse / 1048576,
filesize($spieler) / 1048576
));
} catch (Throwable $e) {
$log("Spielerfoto konnte nicht verarbeitet werden: {$spieler}", 'WARN');
}
if (is_file($tmpCrop)) {
unlink($tmpCrop);
}
}
@@ -527,6 +661,20 @@ foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'his
}
}
// Aufruf-Seite: QR auf das interne Ziel (z. B. /veranstaltungen/sportfest-2026)
// aus dem chat-gepflegten `aufruf.link`-Feld. Anders als die geteilten CTA-QRs
// ist das Ziel ausgabenspezifisch, deshalb liegt der Code im Ausgaben-Ordner
// (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url()
// wie überall, damit der gedruckte Code auf die echte Domain zeigt.
$aufrufLink = trim((string) ($oldAufruf['link'] ?? ''));
if ($aufrufLink !== '') {
if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) {
$log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt.");
} else {
$log('QR-Code für die Aufruf-Seite konnte nicht erzeugt werden — die Seite rendert ohne QR.', 'WARN');
}
}
// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
// keine Einbindung, Regel 1 bleibt unberührt).
@@ -594,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,

View File

@@ -38,6 +38,112 @@ $log = static function (string $msg, string $level = 'INFO'): void {
echo '[' . date('c') . "] {$level} {$msg}\n";
};
/**
* Bilder, auf die der Snapshot zeigt, in den Ausgaben-Ordner kopieren und die Pfade
* darin umschreiben. Damit ist eine veröffentlichte Ausgabe wirklich eingefroren.
*
* Warum das nötig ist: der Snapshot friert die DATEN ein, verwies aber weiter auf
* Ordner, die die Sync-Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg,
* die der BFV nicht mehr führt, bin/instagram-sync.php ältere Posts — am 31.07.2026
* nachgewiesen: ein Lauf löschte 18 Wappen und 3 Instagram-Bilder. Bei den Wappen
* dauert das eine Saison, bei Instagram Wochen. Danach zeigte eine archivierte
* Ausgabe Lücken, und „gedruckt = fixiert" wäre nur die halbe Wahrheit.
*
* img/news wird mit eingefroren, obwohl News-Bilder nicht maschinenverwaltet sind:
* ein Artikelbild kann im Chat ersetzt werden, und dann änderte sich nachträglich
* eine gedruckte Ausgabe. Dieselbe Begründung, derselbe Handgriff.
*
* Fehlt eine Quelldatei (schon weggeräumt), bleibt der Pfad stehen und es gibt eine
* WARN — die crest-Komponente fällt auf Initialen zurück, ein fehlendes Bild darf
* das Veröffentlichen nicht verhindern.
*/
$freezeAssets = static function (array $snapshot, string $slug, callable $log): array {
$quellen = ['img/crests/', 'img/instagram/', 'img/news/'];
// Präfix je Quellordner, damit zwei gleichnamige Dateien sich nicht überschreiben
// und man im Ausgaben-Ordner sofort sieht, woher eine Datei kommt.
$praefix = ['img/crests/' => 'fix-wappen-', 'img/instagram/' => 'fix-insta-', 'img/news/' => 'fix-news-'];
$zielDir = PUBLIC_PATH . '/assets/img/stadionzeitung/' . $slug;
if (!is_dir($zielDir)) {
mkdir($zielDir, 0755, true);
}
$kopiert = [];
$fehlend = [];
$walk = static function (&$node) use (&$walk, $quellen, $praefix, $zielDir, $slug, &$kopiert, &$fehlend): void {
if (is_array($node)) {
// Varianten-Set {base, widths, ext?} zuerst: `base` ist KEIN Dateipfad,
// sondern ein Stamm ohne Endung — jede Breite ist eine eigene Datei.
// Ohne diesen Zweig lief img/news/neue-website als „Quelldatei fehlt" auf.
if (isset($node['base'], $node['widths']) && is_string($node['base']) && is_array($node['widths'])) {
foreach ($quellen as $ordner) {
if (!str_starts_with($node['base'], $ordner)) {
continue;
}
$ext = strtolower((string) ($node['ext'] ?? 'jpg'));
$neuStamm = $praefix[$ordner] . basename($node['base']);
$alleDa = true;
foreach ($node['widths'] as $w) {
$von = PUBLIC_PATH . "/assets/{$node['base']}-{$w}.{$ext}";
if (!is_file($von)) {
$fehlend["{$node['base']}-{$w}.{$ext}"] = true;
$alleDa = false;
continue;
}
copy($von, "{$zielDir}/{$neuStamm}-{$w}.{$ext}");
$kopiert["{$node['base']}-{$w}.{$ext}"] = true;
}
// Nur umschreiben, wenn ALLE Breiten kopiert sind — ein halbes
// srcset wäre schlimmer als der alte Pfad.
if ($alleDa) {
$node['base'] = "img/stadionzeitung/{$slug}/{$neuStamm}";
}
return;
}
}
foreach ($node as &$wert) {
$walk($wert);
}
return;
}
if (!is_string($node)) {
return;
}
foreach ($quellen as $ordner) {
if (!str_starts_with($node, $ordner)) {
continue;
}
$datei = basename($node);
$neu = $praefix[$ordner] . $datei;
$ziel = "{$zielDir}/{$neu}";
if (!isset($kopiert[$node])) {
$von = PUBLIC_PATH . '/assets/' . $node;
if (!is_file($von)) {
$fehlend[$node] = true;
return; // Pfad unverändert lassen — besser ein Fallback als ein toter Link
}
copy($von, $ziel);
$kopiert[$node] = true;
}
$node = "img/stadionzeitung/{$slug}/{$neu}";
return;
}
};
$walk($snapshot);
$log(sprintf(
'Bilder eingefroren: %d Dateien in img/stadionzeitung/%s/ kopiert%s.',
count($kopiert),
$slug,
$fehlend === [] ? '' : ', ' . count($fehlend) . ' Quelldatei(en) fehlten schon'
));
foreach (array_keys($fehlend) as $f) {
$log("Quelldatei für den Snapshot fehlt, Pfad unverändert: {$f}", 'WARN');
}
return $snapshot;
};
$cacheFile = DATA_PATH . '/stadionzeitung.json';
$data = is_file($cacheFile)
? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
@@ -53,7 +159,16 @@ foreach ($data['issues'] as &$issue) {
if ($unpublish) {
$issue['status'] = 'entwurf';
unset($issue['snapshot']);
$log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen (Live-Seiten rechnen wieder frisch).");
// Die eingefrorenen Bilder gehören zum Snapshot und gehen mit ihm. Sonst
// lägen sie als Waisen im Ausgaben-Ordner und ein erneutes Veröffentlichen
// legte daneben eine zweite Fassung ab.
$weg = 0;
foreach (glob(PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}/fix-*") ?: [] as $f) {
if (is_file($f) && unlink($f)) {
$weg++;
}
}
$log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen, {$weg} eingefrorene Bilder entfernt (Live-Seiten rechnen wieder frisch).");
break;
}
@@ -63,6 +178,9 @@ foreach ($data['issues'] as &$issue) {
}
$snapshot = stadionzeitung_build_snapshot($issue);
// Erst die Bilder sichern, dann den Snapshot ablegen: die Pfade darin zeigen
// danach in den Ausgaben-Ordner und sind vom Sync unabhängig.
$snapshot = $freezeAssets($snapshot, $slug, $log);
$issue['snapshot'] = $snapshot;
$issue['status'] = 'veroeffentlicht';
$issue['published_at'] = date('c');

View File

@@ -7,46 +7,49 @@
* config.php ist gitignored und liegt außerhalb des Webroots (zusätzlich per .htaccess gesperrt).
*
* ---------------------------------------------------------------------------
* TAKT DER SYNC-SKRIPTE — zwei Wege, je nach Tarif
* TAKT DER SYNC-SKRIPTE — über public/cron.php, aufgerufen vom KAS-Cron
* ---------------------------------------------------------------------------
* A) Der Tarif hat Cronjobs → Shell-Cronjobs unten verwenden. Sauberster Weg,
* kein Endpoint, kein Dritter im Spiel.
* B) Der Tarif hat KEINE Cronjobs (Stand 27.07.2026 bei uns der Fall, KAS meldet
* „in deinem Tarif nicht verfügbar“) → public/cron.php + externer Cron-Dienst.
* Dann `cron.key` unten setzen und beim Dienst diese URLs im gewünschten Takt
* aufrufen lassen (Schlüssel besser als Header X-Cron-Key als in der URL):
* Geprüft im KAS am 31.07.2026: der Tarif hat SSH und Cronjobs, aber die Cronjobs
* können ausschließlich **URLs** aufrufen. Ein `php bin/…` ist dort nicht eintragbar
* (das Formular hat nur ein Feld „Protokoll / Pfad" mit https://). Also läuft der Takt
* über public/cron.php, und der Aufrufer ist der Hoster selbst — kein Dritter, und der
* Schlüssel verlässt den Webspace nie.
*
* https://www.tsv08kulmbach.de/cron.php?job=matchcenter
* https://www.tsv08kulmbach.de/cron.php?job=instagram
* URLs (Schlüssel = cron.key unten):
* https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key>
* https://www.tsv08kulmbach.de/cron.php?job=instagram&key=<cron.key>
* https://www.tsv08kulmbach.de/cron.php?job=logrotate&key=<cron.key>
*
* Takt wie unten bei den Cronjobs. Der Dienst muss keine Antwort auswerten:
* 204 = angenommen oder wegen Mindestabstand übersprungen, 403 = Schlüssel oder
* Job falsch (dann ist der Dienst falsch eingerichtet und es syncte nichts).
* Erwarteter Fehlerfall: Instagram sperrt Rechenzentrums-IPs. Einmal von Hand
* prüfen, ob der Scraper auf dem Server überhaupt durchkommt.
* Antwort ist immer leer: 204 = gelaufen oder wegen Mindestabstand übersprungen,
* 403 = Schlüssel oder Job falsch. Der KAS-Cron muss nichts auswerten. Das
* E-Mail-Feld im KAS kann leer bleiben; ob die Syncs laufen, sagt `php bin/logs.php`,
* und `php bin/preflight.php` warnt, wenn ein Datenstand zu alt wird.
*
* ---------------------------------------------------------------------------
* CRONJOBS (im KAS anlegen, Typ „eigener Cronjob“ / Shell) — nur Weg A
* DIE DREI CRONJOBS IM KAS (Zeitraster des KAS, nicht Crontab-Syntax)
* ---------------------------------------------------------------------------
* SITE=/www/htdocs/<KAS-User>/tsv08kulmbach (echten Pfad einsetzen: pwd auf dem Server)
* PHP=/usr/bin/php (All-Inkl ggf. /usr/local/bin/php8.2 — `which php8.2` prüft es)
* 1. Matchcenter — „stündlich", Minute 37
* Das KAS kann keine Wochentage mit Halbstunden-Takt kombinieren, wie es die
* ursprüngliche Crontab vorsah (Sa+So 13-21 Uhr halbstündlich). Stündlich rund um
* die Uhr ist der brauchbare Ersatz: am Spieltag stehen Ergebnis und Tabelle
* spätestens eine Stunde später. Kostet 24 Läufe/Tag statt 2 plus Wochenende.
* Wer den Verkehr zum BFV klein halten will, nimmt stattdessen zwei „täglich"-Jobs
* (07:37 und 19:37) und lebt damit, dass Samstagsergebnisse erst abends stehen.
*
* # Instagram-Feed: 2×/Tag (versetzte Minuten, damit die Läufe sich nicht überlappen)
* 17 6,18 * * * cd $SITE && $PHP bin/instagram-sync.php >> storage/logs/instagram.log 2>&1
* 2. Instagram — ZWEI Jobs „täglich", 06:17 und 18:17
* Zwei Einträge, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht stündlich:
* Instagram sperrt auffällige Zugriffsmuster deutlich schneller als der BFV.
*
* # Matchcenter: 2×/Tag als Grundtakt
* 37 7,19 * * * cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
* 3. Logpflege — „täglich", 03:23 (`?job=logrotate`)
* Rotiert erst über 2 MB und löscht Einträge älter als 90 Tage, ein täglicher Lauf
* ist deshalb meistens ein No-Op. Wöchentlich genügt auch.
*
* # Matchcenter am Spieltag-Nachmittag/Abend ½-stündlich (Sa+So 1321 Uhr),
* # damit Ergebnisse und Tabelle zeitnah stehen.
* 7,37 13-21 * * 6,0 cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
* Die Minuten sind versetzt, damit sich zwei Läufe nicht überlappen (die Syncs sperren
* sich per Lock, sonst fiele einer aus).
*
* # Logpflege: sonntags nachts. Rotiert Dateien über 2 MB und löscht Einträge älter als 90 Tage.
* 23 3 * * 0 cd $SITE && $PHP bin/log-rotate.php > /dev/null 2>&1
*
* Alle Skripte sind CLI-only; die Syncs sperren sich per Lock gegen Parallelläufe und lassen
* bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie.
* Erstlauf nach dem Deploy einmal von Hand anstoßen (füllt Cache + Bilder/Wappen).
* Alle Skripte sind CLI-only bzw. laufen über den authentifizierten Endpoint; die Syncs
* lassen bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf
* schadet nie. Erstlauf nach dem Deploy einmal per SSH anstoßen (füllt Cache + Bilder).
*
* ---------------------------------------------------------------------------
* NACH DEM DEPLOY
@@ -80,6 +83,20 @@ return [
// All-Inkl SMTP (Postfach im KAS anlegen; Host = <KAS-Login>.kasserver.com).
// Verbindung testen ohne Mailversand: php bin/smtp-test.php
//
// „SMTP Error: Could not authenticate" auf dem Server, obwohl dieselben Daten vom
// Entwicklungsrechner angenommen werden? Am 31.07.2026 war das VORÜBERGEHEND:
// derselbe Weg lief Minuten später fehlerfrei, ohne Änderung an der Config
// (vermutlich der Brute-Force-Schutz des Mailservers). Erst `php bin/smtp-test.php --probe`
// laufen lassen, bevor irgendetwas hier geändert wird — und nicht in Serie
// wiederholen, jeder Fehlversuch ist ein fehlgeschlagener Login.
//
// Bekannter RÜCKFALLWEG auf diesem Webspace, falls die Anmeldung öfter kippt:
// host 'localhost', port 25, ohne Verschlüsselung und OHNE Anmeldung — der lokale
// Relay des Hosters nimmt uns an (am 31.07.2026 nachgewiesen). Dann kann eine
// Anmeldung gar nicht mehr fehlschlagen. Bewusst NICHT der Standard: mit
// 'localhost' liefen Dev und Produktion auseinander (lokal gibt es keinen
// Mailserver), und ein Codepfad ist leichter zu pflegen als zwei.
'smtp' => [
'host' => 'w01ca75d.kasserver.com',
'port' => 587, // STARTTLS
@@ -92,12 +109,12 @@ return [
'to' => 'info@tsv08kulmbach.de',
],
// Cron-Endpoint public/cron.php — nur nötig, wenn der Tarif keine Cronjobs hat
// (Weg B oben). Leer oder 'CHANGE_ME' lassen heißt: der Endpoint ist zu und
// antwortet auf jeden Aufruf mit 403.
// Cron-Endpoint public/cron.php — der Takt-Geber (siehe oben). Leer oder
// 'CHANGE_ME' heißt: der Endpoint ist zu und antwortet auf jeden Aufruf mit 403,
// dann läuft KEIN Sync mehr.
'cron' => [
// Langes Zufallsgeheimnis, NICHT app_secret wiederverwenden — der Schlüssel
// reist bei einem externen Dienst mit und darf dort nichts anderes aufschließen.
// Langes Zufallsgeheimnis, NICHT app_secret wiederverwenden: dieser Schlüssel
// steht im KAS in einer Cronjob-URL und darf nichts anderes aufschließen.
// Erzeugen: php -r 'echo bin2hex(random_bytes(24)), "\n";'
'key' => 'CHANGE_ME',
// Mindestabstand zwischen zwei Läufen desselben Jobs in Sekunden. Schützt bei

View File

@@ -40,7 +40,7 @@
"name": "Kerstin Tauber",
"phone": "+49 176 38957526",
"whatsapp": true,
"image": null
"image": "img/persons/kerstin-tauber.jpg"
},
{
"group": "Turnen",
@@ -58,7 +58,7 @@
},
{
"group": "Turnen",
"department": "Step Aerobic, Fitness & Pilates",
"department": "Fitness & Pilates",
"name": "Karola Lorz",
"email": "turnen@tsv08kulmbach.de",
"image": null

View File

@@ -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"
}
]
}

View File

@@ -9,8 +9,19 @@
"title": "Hier könnte Ihre Werbung stehen",
"text": "Werden Sie Werbepartner des TSV 08 Kulmbach und zeigen Sie Ihr Unternehmen genau hier: sichtbar für tausende sportbegeisterte Menschen aus Kulmbach und dem Katzbachtal.",
"logo": "img/logo.svg",
"background": "img/pages/fussball-hero-1600.jpg",
"cta": { "label": "Werbepartner werden", "url": "/partner-werden", "internal": true }
"background": {
"base": "img/pages/fussball-hero",
"widths": [
640,
1000,
1600
]
},
"cta": {
"label": "Werbepartner werden",
"url": "/partner-werden",
"internal": true
}
},
{
"company": "Breadcrumb Solutions GmbH",
@@ -18,9 +29,19 @@
"text": "Beratung, Software & Implementierung aus einer Hand. Wir entwickeln Intranets, Kundenportale, Apps und Individualsoftware, inklusive Einführung, sodass sie im Alltag tatsächlich ankommt.",
"logo": "img/banners/breadcrumb-solutions.svg",
"logo_light": true,
"accent": "#6cbbbc",
"background": "img/banners/breadcrumb-hero-1600.jpg",
"cta": { "label": "Neue Website entdecken", "url": "https://www.breadcrumb-solutions.de/" }
"accent": "breadcrumb",
"background": {
"base": "img/banners/breadcrumb-hero",
"widths": [
640,
1000,
1600
]
},
"cta": {
"label": "Neue Website entdecken",
"url": "https://www.breadcrumb-solutions.de/"
}
}
]
}

View File

@@ -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:3017: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:0018:30 Uhr", "Freitag 16:3017: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:3018:30 Uhr", "Mittwoch 17:3019: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:3018:30 Uhr", "Freitag 17:3018: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:3019:30 Uhr", "Freitag 17:3019: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:0017: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:3017:40 Uhr", "Freitag 16:3017: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:1519:30 Uhr", "Mittwoch 17:4519: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:4019:00 Uhr", "Freitag 17:4019: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:0018:00 Uhr", "Donnerstag 17:0018: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": [

View File

@@ -20,9 +20,9 @@
"children": [
{ "label": "Übersicht", "slug": "turnen" },
{ "label": "Erwachsenenturnen", "slug": "turnen/erwachsenenturnen" },
{ "label": "Eltern-Kind-Turnen", "slug": "turnen/eltern-kind-turnen" },
{ "label": "Step Aerobic, Fitness und Pilates", "slug": "turnen/step-aerobic-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" }
]
},

View File

@@ -36,7 +36,7 @@
{
"typ": "text",
"absaetze": [
"Jede Turngruppe hat jetzt ihre eigene Seite: [Erwachsenenturnen](turnen/erwachsenenturnen), [Eltern-Kind-Turnen](turnen/eltern-kind-turnen), [Step Aerobic, Fitness und Pilates](turnen/step-aerobic-fitness-pilates), [Trampolin](turnen/trampolin) und [Wettkampfturnen](turnen/wettkampfturnen). Auf jeder stehen die Trainingszeiten, die Halle samt Weg dorthin und wer die Gruppe leitet.",
"Jede Turngruppe hat jetzt ihre eigene Seite: [Erwachsenenturnen](turnen/erwachsenenturnen), [Eltern-Kind-Turnen](turnen/eltern-kind-turnen), [Fitness und Pilates](turnen/fitness-pilates), [Trampolin](turnen/trampolin) und [Wettkampfturnen](turnen/wettkampfturnen). Auf jeder stehen die Trainingszeiten, die Halle samt Weg dorthin und wer die Gruppe leitet.",
"Fragen zum Turnen stellst du unten auf der [Turnseite](turnen): du sagst, welche Gruppe dich interessiert, und die Anfrage geht direkt an die Turnabteilung."
]
},

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
},
"ctas": []
},
"cards_intro": "Die Turnabteilung des TSV 08 Kulmbach bietet neben dem klassischen Gerätturnen sowohl Trampolinturnen als auch Step Aerobic, Fitness und Pilates an. Weitere Informationen zu den Gruppen findest du in den einzelnen Rubriken.",
"cards_intro": "Die Turnabteilung des TSV 08 Kulmbach bietet neben dem klassischen Gerätturnen sowohl Trampolinturnen als auch Fitness und Pilates an. Weitere Informationen zu den Gruppen findest du in den einzelnen Rubriken.",
"fees_title": "Mitgliedsbeiträge",
"fees_intro": "Der Beitrag der Turnabteilung kommt zum allgemeinen TSV-Mitgliedsbeitrag hinzu. Trainiert dein Kind besonders viel, greift zusätzlich eine Förderstufe.",
"fees": [
@@ -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, Step Aerobic, Fitness und Pilates, Trampolinturnen sowie Wettkampfturnen, von den Jüngsten ab 2 Jahren bis zu Erwachsenen." },
{ "q": "Welche Turngruppen gibt es beim TSV 08 Kulmbach?", "a": "Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Fitness und Pilates, Trampolinturnen sowie Wettkampfturnen, von den Jüngsten ab 2 Jahren bis zu Erwachsenen." },
{ "q": "Ab welchem Alter kann mein Kind mitturnen?", "a": "Beim Eltern-Kind-Turnen starten die Jüngsten bereits ab etwa 2 Jahren gemeinsam mit ihren Eltern. Für ältere Kinder gibt es nach Alter gestaffelte Breitensportgruppen." },
{ "q": "Wo finden die Turnstunden statt?", "a": "Die meisten Gruppen trainieren im Mäximum Gym (Thurnauer Str. 51, 95326 Kulmbach). Step Aerobic, Fitness und Pilates laufen in der Max-Hundt-Schule, das Trampolinturnen in der Dreifachturnhalle der Carl-von-Linde-Realschule." },
{ "q": "Wo finden die Turnstunden statt?", "a": "Die meisten Gruppen trainieren im Mäximum Gym (Thurnauer Str. 51, 95326 Kulmbach). Fitness und Pilates laufen in der Max-Hundt-Schule, das Trampolinturnen in der Dreifachturnhalle der Carl-von-Linde-Realschule." },
{ "q": "Was kostet die Turnabteilung?", "a": "Bestehende TSV-Mitglieder zahlen einen Zusatzbeitrag von 24,00 € (Einzelperson) bzw. 48,00 € pro Jahr (Familie). Neumitglieder zahlen 84,00 € (Kinder/Jugendliche), 94,00 € (Erwachsene) oder 184,00 € (Familie ab 3 Personen) im Jahr, inklusive Grundbeitrag, Verbandsabgaben und Sportunfall-Versicherung. Bei hohem Trainingsumfang kommt eine Förderstufe hinzu." },
{ "q": "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,13 +98,14 @@
"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": "Aktuell sind alle Kinderturngruppen belegt. Bei Interesse setzen wir sie gerne auf die Warteliste.",
"notice_tone": "stop",
"notice_title": "Hinweis zur Warteliste",
"notice": "Für die Gruppen „Tiger“ und „Hühner“ (ab 8 Jahre) können wir derzeit leider keine neuen Mitglieder aufnehmen und führen deshalb aktuell keine Warteliste. Die übrigen Kinderturngruppen sind ebenfalls belegt, dort nehmen wir dein Kind aber gerne auf die Warteliste.",
"groups": [
{
"name": "Eltern-Kind-Turnen",
@@ -118,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:3017:15 Uhr", "trainers": ["Carina Schleicher*"] },
{ "label": "Grashüpfer", "day": "Donnerstag", "time": "15:3016:15 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch"] }
{ "label": "Grashüpfer", "day": "Donnerstag", "time": "15:3016:15 Uhr", "trainers": ["Annelie Frisque*", "Eva Wünsch*"] }
]
},
{
@@ -149,31 +150,16 @@
{ "label": "Tiger · 810 Jahre", "day": "Dienstag", "time": "18:3019:30 Uhr", "note": "in geraden Kalenderwochen" },
{ "label": "Hühner · ab 11 Jahre", "day": "Dienstag", "time": "19:3020:30 Uhr", "note": "in geraden Kalenderwochen" }
]
},
{
"name": "Flamingos",
"level": "Turnen · fortgeschritten · Alter nach Absprache",
"intro": "In unserer Gruppe für fortgeschrittenes Turnen bauen wir auf bereits erlernten turnerischen Fähigkeiten auf. Das Training orientiert sich an den Anforderungen der LK3 (Kür modifiziert, Leistungsklasse 3).",
"details": [
{ "heading": "Trainingsinhalte", "text": "Je nach Voraussetzung erlernen wir Elemente wie Radwende, Flick-Flack und Salto (Boden), Kippen und Felge vorlings (Barren), Überschläge (Sprung) sowie Räder und Bogengänge (Balken)." },
{ "heading": "Wettkämpfe & Teamgeist", "text": "Wir nehmen vorzugsweise an Mannschaftswettkämpfen teil, um den Teamgeist zu fördern und die individuelle Leistung als Teil eines gemeinsamen Erfolgs zu erleben." }
],
"recommendation": "Eine Teilnahme erfolgt ausschließlich auf Empfehlung und Einladung durch unser Trainerteam. Wir gleichen das Leistungsniveau regelmäßig ab, um für jedes Kind die optimale Gruppe zu finden.",
"trainers": ["Christa Knarr*", "Maxi Roßberg*", "Annelie Frisque*", "Theresa Diener", "Lea Schramm", "Charlyn Simon", "Lia Buchwald"],
"sessions": [
{ "day": "Montag", "time": "18:0019:00 Uhr" },
{ "day": "Mittwoch", "time": "18:0020:00 Uhr" }
]
}
]
},
"step-aerobic-fitness-pilates": {
"fitness-pilates": {
"hero": {
"align": "center",
"compact": true,
"title": "Step Aerobic, Fitness & Pilates",
"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" },
@@ -181,18 +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:3018: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:4519:45 Uhr" } ],
"trainers": ["Karola Lorz"]
},
{
"name": "Step-Aerobic",
"sessions": [ { "day": "Donnerstag", "time": "19:3020:30 Uhr" } ],
"trainers": ["Heike Wahler"]
"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"]
}
]
},
@@ -207,15 +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 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:0020:30 Uhr" },
{ "day": "Donnerstag", "time": "17:30 / 18:0020:30 Uhr" }
],
"note": "Der Einstieg erfolgt gestaffelt um 17:30 bzw. 18:00 Uhr.",
"trainers": []
{ "day": "Montag", "time": "17:3019:30 / 20:30 Uhr", "trainers": ["Sabine Hofmann"] }
]
},
{
"name": "Kinder ab 5 Jahren",
"sessions": [
{ "day": "Donnerstag", "time": "17:3019:00 Uhr", "trainers": ["Michaela Bollingerfehr"] }
]
},
{
"name": "Kinder ab 10 Jahren und Fortgeschrittene",
"sessions": [
{ "day": "Donnerstag", "time": "18:0020: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:0019:00 Uhr" },
{ "day": "Montag", "time": "19:0020:00 Uhr" },
{ "day": "Mittwoch", "time": "18:0020:00 Uhr" }
]
},
{
"name": "Leistungsturnen",
"contact": "Franziska Trotzke",
"sessions": [
{ "day": "Montag", "time": "15:0018:00 Uhr" },
{ "day": "Mittwoch", "time": "15:0018:00 Uhr" },
{ "day": "Donnerstag", "time": "16:3018:30 Uhr" },
{ "day": "Freitag", "time": "14:0017:00 Uhr" },
{ "day": "Samstag", "time": "09:0013:00 Uhr" }
],
"trainers": []
]
}
]
}

View File

@@ -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",

388
docs/deploy.md Normal file
View File

@@ -0,0 +1,388 @@
# Deploy auf All-Inkl (Shared Hosting, SSH + Cronjobs)
Schritt für Schritt vom leeren Webspace zur Live-Seite. Die Reihenfolge ist Absicht:
**die alte CMS-Seite bleibt online, bis die neue auf einer geschützten Test-Subdomain
nachweislich läuft.** Umgeschaltet wird erst am Ende, und das Umschalten ist in einer
Minute rückgängig.
Der Kern des Plans: die Test-Subdomain läuft mit **derselben** `config.php`, die
später live geht. Es gibt keinen Config-Wechsel beim Umschalten, also auch keinen
Fehler, der sich dabei einschleichen kann. Möglich ist das, weil die
Host-Weiterleitung in `public/.htaccess` nur beim exakten Host `tsv08kulmbach.de`
greift; eine Subdomain läuft unbehelligt durch. Einzige Abweichung: Canonical, OG
und Sitemap nennen auf der Test-Subdomain schon die echte Domain. Genau richtig für
den Umschaltmoment, und dank Verzeichnisschutz sieht es ohnehin keine Suchmaschine.
Legende: `[lokal]` auf deinem Mac, `[SSH]` in der Server-Shell, `[KAS]` im
All-Inkl-Kundenmenü.
---
## Teil A — Vorbereitung lokal
### A1 [lokal] Die zwei offenen Werte in `config/config.production.php` setzen
`php bin/preflight.php` bricht sonst auf dem Server ab. Beides betrifft nur die
Dateiablage `/dateien`, nicht die Website selbst.
**Passwörter für die zwei Zugänge.** Als Hash, nicht als Klartext:
```bash
php -r 'echo password_hash("DEIN-PASSWORT", PASSWORD_DEFAULT), "\n";'
```
Ausgabe (beginnt mit `$2y$…`) in `dateien.users.verein.password_hash` bzw.
`dateien.users.admin.password_hash` eintragen. Zwei verschiedene Passwörter: `verein`
gibt der Verein weiter, `admin` behältst du.
**Lizenzschlüssel** der Files Gallery in `dateien.license_key`. Fehlt er, läuft die
Ablage trotzdem, zeigt aber den Hinweis der unlizenzierten Version (nur eine Warnung
in `preflight`, kein Blocker).
Bereits erledigt: `cron.key` ist auf `''` gesetzt und der Endpoint `public/cron.php`
damit zu. Der Takt kommt ab jetzt aus echten Cronjobs (Teil D), der Endpoint war nur
der Ersatz für den fehlenden Cron.
### A2 [lokal] Alles gepusht?
```bash
git status --porcelain # muss leer sein
git push origin main
```
---
## Teil B — Server einrichten
### B1 [SSH] Umgebung feststellen und notieren
```bash
ssh ssh-w01ca75d@SERVER # SERVER = Hostname oder IP aus dem KAS
pwd # das Verzeichnis, in dem SSH startet
which php; php -v # PHP muss >= 8.1 sein
git --version
```
**Ergebnis vom 31.07.2026 auf diesem Webspace:**
| | |
|---|---|
| Webspace-Wurzel | `/www/htdocs/w01ca75d` |
| SITE | `/www/htdocs/w01ca75d/tsv08kulmbach-website` |
| PHP (CLI, für Cron) | `/usr/bin/php` — 8.3.29 |
| Composer | 2.9.8, global vorhanden |
| Alte CMS-Seite (Rückweg) | `/www/htdocs/w01ca75d/tsv08kulmbach.de` |
**Falle: `~` ist hier `/`, nicht der Webspace.** Das SSH-Konto hat die Systemwurzel
als Home-Verzeichnis, `cd ~` führt also aus dem Webspace heraus und `~/…`-Pfade
existieren nicht. Alle Pfade in dieser Anleitung sind deshalb absolut — auch das
`scp`-Ziel und die Cron-Zeilen.
All-Inkl legt pro Domain einen Ordner unter `/www/htdocs/<user>/` an, dieses
Verzeichnis selbst ist also kein Docroot. Der Ordnername `tsv08kulmbach.de` ist von der alten Seite belegt, deshalb
heißt der neue `tsv08kulmbach-website`. Die anderen TSV-Ordner
(`2024.`, `hall-of-fame.`, `kabinendienst.`, `kreisliga-reise.`) sind eigene
Subdomains und vom Umschalten nicht betroffen.
Achtung: das ist die **CLI**-Version. Welches PHP der Webserver nimmt, stellt KAS pro
Domain getrennt ein (Schritt C1).
### B2 [SSH] Repo klonen
```bash
cd /www/htdocs/w01ca75d
git clone https://onion.breadcrumb-online.de/fs/tsv08kulmbach-website.git tsv08kulmbach-website
cd tsv08kulmbach-website && pwd # diesen Pfad notieren: das ist SITE
```
Der Klon lädt gut 460 MB (Website plus das Vereinsmaterial der Dateiablage), das
dauert ein paar Minuten. Die alte CMS-Seite liegt in einem anderen Verzeichnis und
wird nicht angefasst.
### B3 [SSH] `config.php` platzieren
Die Datei ist gitignored, also nicht im Klon. Vom Mac hochladen:
```bash
# [lokal], in einem zweiten Terminal.
# SERVER = derselbe Host wie beim ssh-Login (hier die Server-IP des Webspace).
scp config/config.production.php \
ssh-w01ca75d@SERVER:/www/htdocs/w01ca75d/tsv08kulmbach-website/config/config.php
```
Platzhalter bewusst ohne spitze Klammern: `<host>` liest die zsh als
Eingabe-Umleitung und bricht mit „no such file or directory" ab.
Wichtig: Ziel heißt `config.php`, nicht `config.production.php`. Danach [SSH]:
```bash
chmod 600 config/config.php # enthält SMTP-Passwort und app_secret
```
### B4 [SSH] Composer-Abhängigkeiten
```bash
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
composer install --no-dev --optimize-autoloader
```
Kennt die Shell `composer` nicht, hol ihn dir lokal ins Verzeichnis:
```bash
curl -sS https://getcomposer.org/installer | php
php composer.phar install --no-dev --optimize-autoloader
rm composer.phar
```
Es wird nur PHPMailer installiert.
### B4b [SSH] Git-Identität für den Server setzen
Ohne sie bricht **jeder** Commit auf dem Server ab („Author identity unknown"),
und ohne Commits funktioniert der Update-Ablauf aus F1 nicht.
```bash
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
git config --local user.name "TSV 08 Server"
git config --local user.email "fs@breadcrumb-solutions.de"
```
`--local` gilt nur für dieses Repo — die anderen Projekte auf dem Webspace bleiben
unberührt. Der Name macht in `git log` auf einen Blick sichtbar, welche Commits vom
Server stammen (Uploads, Sync-Stände) und welche vom Mac (Code, Inhalte).
Wird ein Push mit `! [rejected] … (fetch first)` abgewiesen, hat der Mac inzwischen
gepusht: `git pull --rebase origin main` und nochmal pushen. Rebase statt Merge, damit
die Historie linear bleibt.
### B5 [SSH] Schreibrechte prüfen
PHP läuft unter deinem Benutzer, meist passt es also schon. `preflight` sagt es dir
in Teil C4 verbindlich. Falls dort etwas fehlt:
```bash
chmod -R u+rwX storage
```
Schreiben muss die Seite in `storage/logs`, `storage/cache`, `storage/ratelimit`,
`storage/dateien/uploads` und `storage/dateien/system`.
---
## Teil C — Auf der Test-Subdomain prüfen
### C1 [KAS] Subdomain anlegen und schützen
1. **Domains** → Subdomain `neu.tsv08kulmbach.de` anlegen.
2. Als Verzeichnis **`/tsv08kulmbach-website/public`** eintragen, nicht das Repo-Root.
Damit liegen `app/`, `config/`, `data/`, `storage/` und `vendor/` außerhalb des
Docroots und sind grundsätzlich nicht per URL erreichbar. (Die Root-`.htaccess`
ist nur ein Notnagel für Hoster, die das nicht können.)
3. **PHP-Version** für die Subdomain auf 8.1 oder neuer stellen.
4. **SSL** aktivieren (Let's Encrypt). Ohne Zertifikat läuft die Seite in eine
Endlosweiterleitung, denn `public/.htaccess` erzwingt HTTPS.
5. **Verzeichnisschutz** (Basic Auth) — empfohlen, aber kein Blocker. Er hält die
Testseite komplett aus dem Netz. Ohne ihn ist das Risiko klein, weil jede Seite
ein Canonical auf `www.tsv08kulmbach.de` trägt: Google konsolidiert darauf,
doppelter Inhalt entsteht praktisch nicht. Es kann lediglich die Test-URL bis zum
Umschalten in einem Suchergebnis auftauchen. Am 31.07.2026 bewusst weggelassen.
Wer es ohne Basic Auth sauber will: `X-Robots-Tag: noindex` in
`public/.htaccess`, host-bedingt nur für Namen, die mit `neu.` beginnen.
### C2 Erreichbarkeit
`https://neu.tsv08kulmbach.de` aufrufen. Nach der Passwortabfrage muss die
Startseite kommen. Weißes Blatt oder 500er? Dann [SSH]
`tail -30 storage/logs/php-errors.log`.
### C3 [SSH] Datenstände erzeugen
`data/matchcenter.json` und `data/instagram.json` sind bewusst nicht im Repo, die
holen die Syncs. Vor dem ersten Lauf fehlen Tabellen, Spiele und der
Instagram-Block (die Seite bricht deswegen nicht, sie zeigt Ersatzinhalte).
```bash
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
php bin/matchcenter-sync.php # Spiele, Ergebnisse, Tabellen, Wappen
php bin/instagram-sync.php # Posts und Bilder
```
**Der Instagram-Lauf ist der Wackelkandidat des ganzen Deploys.** Instagram sperrt
Rechenzentrums-IPs; bisher lief der Scraper nur über einen privaten Anschluss.
Schlägt er hier fehl, ist das kein Fehler im Deploy: dann bleibt der Weg, ihn von
zu Hause laufen zu lassen und `data/instagram.json` samt Bildern hochzuladen, oder
der Block wird redaktionell gepflegt. Die Website funktioniert in jedem Fall.
### C4 [SSH] `preflight` bis null Fehler
```bash
php bin/preflight.php; echo "Exit: $?"
```
Ziel ist **Exit 0**. Warnungen darfst du bewerten, Fehler nicht. Erwartbare
Warnungen hier: der Hinweis, dass `cron.key` leer ist (richtig so, siehe A1) und
die Uploadgrenze, die erst ein echter Upload beweist (C6).
### C5 Durchklicken
Die Seiten, die eigene Datenquellen haben:
- [ ] Startseite: Banner-Slider, Termin-Karte im Hero, Instagram-Block
- [ ] `/matchcenter`: Tabellen und nächste Spiele mit Wappen
- [ ] `/termine`, `/news`, `/veranstaltungen` plus eine Detailseite
- [ ] `/stadionzeitung` und die Ausgabe durchswipen — **alle Wappen und
Instagram-Bilder da?** (Sie liegen im Repo, sollten also stimmen.)
- [ ] `/stadionzeitung/aktuell` landet auf der neuesten Ausgabe
- [ ] Die Druckfassung einer Ausgabe (`?druck=1`)
- [ ] `/impressum`: Webadresse zeigt `www.tsv08kulmbach.de`, nicht die Subdomain
### C6 Dateiablage und Mailversand
```bash
php bin/dateien-init.php # Jahr/Abteilung-Ordner, MUSS auf dem Server laufen
php bin/smtp-test.php # nur Verbindung und Login, kein Versand
```
`dateien-init.php` gehört auf den Server, weil Umlaute per FTP zu Doppelordnern
führen (macOS NFD gegen Linux NFC).
- [ ] `/dateien` mit beiden Zugängen anmelden
- [ ] Eine große Datei hochladen (>10 MB) — beweist die 250-MB-Grenze
- [ ] Ein Formular abschicken (z. B. `/kontakt`) und die Mail im Postfach prüfen
- [ ] `php bin/logs.php` zeigt keine Fehler
---
## Teil D — Cronjobs
**Die KAS-Cronjobs können nur URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026:
das Formular hat ein Feld „Protokoll / Pfad" mit `https://`, kein Kommandofeld). Der
Takt läuft deshalb über `public/cron.php`. Aufrufer ist der Hoster selbst — der
Schlüssel verlässt den Webspace nie, kein Dritter ist beteiligt.
Voraussetzung: `cron.key` muss in `config/config.php` gesetzt sein (48 Zeichen,
nicht `app_secret`). Steht er leer, antwortet der Endpoint mit 403 und **nichts** synct.
`bin/preflight.php` prüft Länge und Verwechslung.
[KAS] → **Cronjobs** → Anlegen, drei bis vier Einträge. `KEY` durch den Wert aus
`config.php` ersetzen:
| # | Pfad | Zeitpunkt |
|---|---|---|
| 1 | `www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY` | stündlich, Minute 37 |
| 2 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 06:17 |
| 3 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 18:17 |
| 4 | `www.tsv08kulmbach.de/cron.php?job=logrotate&key=KEY` | täglich, 03:23 |
Vier Dinge dahinter sind Absicht:
- **Matchcenter stündlich statt halbstündlich am Spieltag.** Das KAS kann Wochentage
nicht mit einem Halbstunden-Takt kombinieren, wie es die ursprüngliche Crontab vorsah.
Stündlich rund um die Uhr ist der brauchbare Ersatz: am Spieltag steht das Ergebnis
spätestens eine Stunde später. Kostet 24 Läufe/Tag. Wer den Verkehr zum BFV klein
halten will, nimmt zwei „täglich"-Jobs (07:37 und 19:37) und lebt damit, dass
Samstagsergebnisse erst abends stehen.
- **Instagram zwei Einträge**, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht
stündlich: Instagram sperrt auffällige Zugriffsmuster schneller als der BFV.
- **Versetzte Minuten**, damit sich zwei Läufe nie überlappen (die Syncs sperren sich
per Lock, sonst fällt einer aus).
- **Das E-Mail-Feld im KAS bleibt leer.** Die Antwort ist immer leer (204 = gelaufen oder
wegen Mindestabstand übersprungen, 403 = Schlüssel oder Job falsch), es gäbe also
nichts zu mailen. Ob der Takt läuft, sagt `php bin/logs.php`; `php bin/preflight.php`
warnt, wenn ein Datenstand zu alt wird.
**Am Tag nach dem ersten Lauf** einmal `php bin/logs.php` ansehen. Läuft nichts, ist fast
immer der Schlüssel in der URL falsch oder abgeschnitten. Gegenprobe von Hand:
```bash
curl -s -o /dev/null -w '%{http_code}\n' \
'https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY'
```
204 = angenommen, 403 = Schlüssel oder Job falsch. Direkt danach ein zweites Mal
aufgerufen muss es ebenfalls 204 geben — dann hat der Mindestabstand gegriffen und der
Lauf wurde übersprungen, was im Log als WARN steht.
## Teil E — Umschalten auf die Hauptdomain
Ab hier ist die Seite öffentlich. Alles davor muss sitzen.
### E1 Sicherheitsnetz
Notiere, auf welches Verzeichnis `www.tsv08kulmbach.de` **jetzt** zeigt (die alte
CMS-Seite). Das ist dein Rückweg. Verzeichnis und Datenbank der alten Seite bleiben
unangetastet, nicht löschen.
### E2 [KAS] Domain umstellen
**Domains**`tsv08kulmbach.de` und `www.tsv08kulmbach.de` auf
**`/tsv08kulmbach-website/public`** zeigen lassen. PHP-Version prüfen, SSL muss für beide
aktiv sein.
### E3 Sofort danach prüfen
- [ ] `https://www.tsv08kulmbach.de` lädt
- [ ] `http://tsv08kulmbach.de` (ohne www, ohne s) landet per 301 auf
`https://www.tsv08kulmbach.de`
- [ ] `/sitemap.xml` und `/robots.txt` erreichbar
- [ ] `curl -sI https://www.tsv08kulmbach.de | head` — Statuszeile 200, kein
Redirect-Loop
- [ ] Ein Formular live abschicken
- [ ] **Einen gedruckten QR-Code mit dem Handy scannen.** Der Code der
Stadionzeitung zeigt auf `www.tsv08kulmbach.de/stadionzeitung/aktuell`.
Scannen ist die einzige echte Prüfung: die QR-Bibliothek arbeitet nicht
reproduzierbar, ein Bildvergleich beweist nichts.
### E4 Verzeichnisschutz der Test-Subdomain lassen
Nicht entfernen. Sonst steht die Seite zweimal im Netz und Google sieht doppelten
Inhalt. Wenn du die Subdomain nicht mehr brauchst, in KAS löschen.
---
## Teil F — Danach
### F1 Der Update-Ablauf ab jetzt
**Die Reihenfolge ist nicht verhandelbar**, weil `storage/dateien/uploads/` im Repo
liegt und andere Menschen dort hochladen:
```bash
# 1. [SSH] auf dem Server: neue Uploads sichern
cd /www/htdocs/w01ca75d/tsv08kulmbach-website
git add -A storage/dateien/uploads && git commit -m "Uploads vom Server" && git push
# 2. [lokal] holen, dann entwickeln
git pull
# 3. [lokal] fertig, gepusht → [SSH] ausrollen
cd /www/htdocs/w01ca75d/tsv08kulmbach-website && git pull && php bin/preflight.php
```
Nie in der anderen Reihenfolge. Details und Begründung in CLAUDE.md, Abschnitt
Dateiablage.
Ändert sich `composer.json`, gehört ein `composer install --no-dev
--optimize-autoloader` dazu. Neue Formularfelder heißen: Datenschutzerklärung
mitziehen.
### F2 Rückweg, falls etwas Grundlegendes bricht
[KAS] Domain zurück auf das alte Verzeichnis aus E1. Nach ein paar Minuten ist die
alte Seite wieder da. Kein Datenverlust, weil die neue Seite in einem eigenen
Verzeichnis liegt und die alte Datenbank nicht anfasst.
### F3 Offen, nach dem Livegang
- ~~**301-Weiterleitungen der alten URLs.**~~ Erledigt am 31.07.2026: `public/.htaccess`,
Block „Alte CMS-Adressen". Abgedeckt sind der PDF-Aufnahmeantrag der Turnabteilung
(`/userdata/01_Basis/Beitrittserkl…pdf`, so in den Einladungen verschickt) und alle
Seiten der alten Navigation unter `/08/de/…` (Zuordnung aus `main_navigation` im
DB-Dump). **Nicht** dabei und bewusst nicht nachgezogen: die Bilder und das
Hero-Video unter `/userdata/04_Seiteninhalte/` — die liegen auf der neuen Seite in
anderer Form, eine Weiterleitung von Bild-URLs hilft niemandem.
- **Google Search Console:** Property anlegen, `sitemap.xml` einreichen.
- **Instagram-Sync** beobachten, siehe C3.
- Nach dem Sportfest (ab 07.09.2026) fallen zwei Anzeigen per `bis`-Datum aus dem
Bestand, die Stadionzeitung hat dann 38 Seiten statt 40 und ist nicht mehr durch
4 teilbar. `bin/stadionzeitung-add.php` warnt beim nächsten Lauf.

View File

@@ -15,6 +15,40 @@ RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
RewriteCond %{HTTP_HOST} ^tsv08kulmbach\.de$ [NC]
RewriteRule ^ https://www.tsv08kulmbach.de%{REQUEST_URI} [R=301,L]
# --- Umbenannte Seiten (301) ---
# Step Aerobic findet nicht mehr statt (31.07.2026), die Seite heißt jetzt
# „Fitness & Pilates" und liegt unter turnen/fitness-pilates. Der alte Slug war
# in der Sitemap und ist von Google indexiert, deshalb 301 statt 404 — sonst
# verliert die Seite ihre Position und alte Links laufen ins Leere.
# Steht VOR dem Front Controller, sonst würde index.php den Pfad zuerst als
# unbekannten Slug abfangen und einen 404 rendern.
RewriteRule ^turnen/step-aerobic-fitness-pilates/?$ /turnen/fitness-pilates [R=301,L]
# --- Alte CMS-Adressen (301) ---
# Die alte Seite lief unter /08/de/<seite>/ und lieferte Dateien aus /userdata/.
# Beides ist seit dem Umzug tot, die Adressen stehen aber in Einladungen, Aushängen
# und im Google-Index. Steht wie die Regel darüber VOR dem Front Controller.
#
# Der Aufnahmeantrag der Turnabteilung ist der dringendste Fall: er ging so an die
# neuen Familien raus. Der Dateiname trägt ein „ä" und kommt je nach Mailprogramm in
# drei Fassungen an: NFC (%C3%A4), NFD (a%CC%88 — genau so stand der Link in der alten
# Datenbank) oder roh als UTF-8. mod_rewrite prüft den dekodierten Pfad, also matchen
# wir am Umlaut vorbei, statt eine der drei Fassungen zu raten.
RewriteRule ^userdata/01_Basis/Beitrittserkl[^/]*\.pdf$ /assets/downloads/beitrittserklaerung-turnen.pdf [R=301,L,NC]
# Seiten, die ihren Namen behalten haben. Das abschließende „?" wirft den alten
# Query-String weg (die Turn-Seite hatte ?group_filter_id=<n> als Disziplin-Filter,
# 14 war „Allgemeines") — ohne das hängt mod_rewrite ihn ungefragt ans neue Ziel.
RewriteRule ^08/de/(fussball|jugendfussball|matchcenter|turnen|mitglied-werden|kontakt|historie|impressum|datenschutz)/?$ /$1? [R=301,L]
# Seiten, die heute anders heißen
RewriteRule ^08/de/home/?$ / [R=301,L]
RewriteRule ^08/de/aktuelles/?$ /news [R=301,L]
RewriteRule ^08/de/der-verein/?$ /verein [R=301,L]
RewriteRule ^08/de/der-verein/historie/?$ /historie [R=301,L]
RewriteRule ^08/de/der-verein/sportheim/?$ /sportheimbuchung [R=301,L]
RewriteRule ^08/de/personen/?$ /ansprechpartner [R=301,L]
# --- Front Controller ---
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -11,6 +11,14 @@
--clr-accent-hsl: 356.7 94.8% 45.5%; /* dasselbe Rot als H S L */
--clr-accent-h: 356.7; /* nur der Farbton — die Dateiablage (/dateien)
leitet daraus ihre Flächen ab, siehe dateien.css */
/* Akzentfarben einzelner Werbe-Banner (Sponsor-CI statt Vereins-Rot).
Sie stehen hier und nicht in data/banners.json, weil ein Hex-Wert aus der JSON
nur per Inline-Style in die Seite käme — und den verwirft die CSP
(default-src 'self', kein style-src). Die JSON nennt nur den Namen
("accent": "breadcrumb"), components.css bildet ihn auf dieses Token ab.
Neuer Sponsor mit eigener CI = eine Zeile hier plus eine Klasse dort. */
--clr-banner-accent-breadcrumb: #6cbbbc;
--clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */
--clr-bg-deep: #161616; /* abgesetzte Sektionen */
--clr-bg-footer: #000000;
@@ -64,6 +72,12 @@
/* Sperrung. Coolvetica ist Condensed und hat nur einen Schnitt: unter 28px
wird es in Uppercase unleserlich (siehe CLAUDE.md). Kleinere Auszeichnungen
laufen deshalb über --ff-body + uppercase + --ls-caps statt --ff-heading. */
--ls-heading: 0.012em; /* Coolvetica-Überschriften unter ~40px: nur ein Hauch Luft
zwischen den Versalien (0,4px bei 34px). Der Schnitt ist
condensed, ohne Sperrung kleben die Buchstaben — mit
--ls-caps (0.08em) wirkt eine Überschrift dagegen
auseinandergezogen. Nicht für kleine Labels, dafür
--ls-caps/--ls-wide. */
--ls-tight: 0.03em; /* dezente Straffung in Fließtext-Größen */
--ls-caps: 0.08em; /* Standard für gesperrte Uppercase-Auszeichnungen */
--ls-wide: 0.12em; /* kleine Labels, Meta-Zeilen */
@@ -88,6 +102,20 @@
der meistgenutzte). Nach oben wird das Raster gröber, weil große Abstände
das vertragen. --space-4xs (2px) ist ausschließlich für optische
Korrekturen gedacht (Icon-/Baseline-Nudges), nicht für Layout. */
/* Karten-Innenabstände. Fluid statt fest, damit auf schmalen Handys nicht ein
Viertel der Bildschirmbreite in gestapelten Innenabständen verschwindet
(gemessen bei 309px: Container 11,5 + Karte 32 je Seite → nur 71% der Breite
blieben für das Eingabefeld). Ein harter Breakpoint bei ~310px wäre die
Alternative gewesen, sieht aber bei 311px wieder falsch aus.
Die OBERGRENZEN entsprechen genau den bisherigen festen Werten, am Desktop
ändert sich also nichts — es wird nur nach unten schmaler.
Vier Karten (fee-table, membership-card, legal__card, matchcenter-empty)
hatten diese clamp()-Formeln schon einzeln stehen; sie behalten ihre eigenen
Werte, damit ihr Desktop-Bild unangetastet bleibt. Sie hier einzusammeln ist
eine Aufräumung für später. */
--pad-card: clamp(var(--space-m), var(--space-s) + 1.5vw, var(--space-2xl)); /* 20…32px */
--pad-card-lg: clamp(var(--space-l), var(--space-s) + 2.5vw, var(--space-3xl)); /* 24…40px */
--space-4xs: 0.125rem; /* 2px */
--space-3xs: 0.25rem; /* 4px */
--space-2xs: 0.5rem; /* 8px */

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

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