Compare commits

..

10 Commits

Author SHA1 Message Date
fs
2f8619e78d Gedruckte Adressen aus club.website statt base_url
QR-Codes und Adresszeilen trugen `http://localhost:8000` ins Druckmaterial, weil
sie über abs_url() liefen. Nach dem Produktivgang hätten alle zehn Codes neu
erzeugt werden müssen — und ein gedruckter Code mit localhost darin ist Altpapier.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 15:40:59 +02:00
fs
93b67bc9d3 CLAUDE.md: Anzeigen-Bestand, Verteilungsregel, Veranstaltungen, Lightbox
Neue Single-Source-Zeilen für data/anzeigen.json und data/veranstaltungen.json,
Vorlage der Stadionzeitung als Blöcke mit Lücken statt vier Anzeigen-Gruppen,
Doppelseiten-Regel jetzt maschinell. Dazu die Fallen, in die wir gelaufen sind:
Kontext-Regeln setzen Custom Properties und kein Layout (… .crest), --header-space
statt --header-h, [hidden] gegen .lightbox, QR-Codes tragen die base_url fest
eingebacken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
fs
a101b0c3ec Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
fs
3fb480dd78 Inhalte und Bilder: Stadionzeitung, Veranstaltungen, News, Anzeigen-Bestand
Neue Datenquellen:
- data/veranstaltungen.json  Feste mit eigener Detailseite (dritte Termin-Quelle)
- data/anzeigen.json         Sponsoren-Anzeigen als dauerhafter Bestand statt
                             pro Ausgabe abgeliefert
- data/stadionzeitung.json   Ausgaben-Archiv (maschinenverwaltet)
- data/news.json             redaktionelle Meldungen mit Artikelseiten

Bilder einmal statt pro Ausgabe: public/assets/img/anzeigen/ hält die 19
Sponsorenmotive plus drei Eigenanzeigen; vorher lag jede Anzeige als Kopie
unter jeder Stadionzeitungs-Ausgabe.

Zuerst die Inhalte, dann der Code: so ist der Baum in jedem Schritt gültig.
Daten ohne die zugehörigen Seiten sind harmlos, Seiten ohne Daten nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:54:37 +02:00
fs
82cf21085d Design-System: Fokus vereinheitlichen, Formularfeld-Ring WCAG-fest machen
Letzter Teil. base.css hatte längst eine globale :focus-visible-Regel, trotzdem
existierten 15 Fokus-Regeln — die meisten davon Ballast.

Fünf Overrides wiederholten die Basisregel praktisch wortgleich (.back-to-top,
.ad-banner__dot, .faq__q, .table-scroll, .btn) und sind ersatzlos entfallen; sie
unterschieden sich höchstens um 1px Offset. Die Basisregel selbst läuft jetzt
über --focus-ring/--focus-offset, .position-card__head wechselt von Akzentrot
auf Weiß. Übrig bleiben zehn Regeln, davon deklarieren nur noch vier überhaupt
eine outline — jede mit Grund (inset-Offset bei .tab und .position-card__head,
geschichteter Ring beim Formularfeld). Die anderen sechs sind reine Zusatz-
effekte (transform, Farbe, Fläche) und lassen den Basis-Ring durch.

Der eigentliche Fund steckte im Formularfeld: es setzte outline: none und
ersetzte den Ring durch einen roten Rahmen mit 30%-Glow. Akzentrot erreicht auf
der Feldfläche aber nur 2,8:1 und verfehlt damit WCAG 1.4.11 (>=3:1) für
Fokus-Indikatoren — Tastaturnutzer hatten dort also den schwächsten Fokus der
ganzen Seite. Roter Rahmen und Glow bleiben als Marken-Signal, der weiße
Standard-Ring kommt zurück; outline-offset entspricht der Glow-Breite, beide
Ringe liegen bündig nebeneinander statt übereinander.

CLAUDE.md dokumentiert die neuen Regeln: Token-Disziplin mit Tabelle (Spacing,
Typo, Icons, Sperrung, Fokus, Trefferfläche) samt der bewussten em-Ausnahme,
das Button- und Tab-System inklusive der (0,2,0)-Falle durch die
CSS-Ladereihenfolge, und die Fokus-Regel. Dazu ein Punkt in der Checkliste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:33:53 +02:00
fs
eacf9449e0 Design-System: Button-Varianten konsolidieren, 44px-Trefferflächen
Dritter Teil. .btn hatte nur primary + outline + sm/lg, daneben lebten mehrere
Controls als Eigenbau — teils zeichengleich dupliziert.

Tabs: .group-tabs__tab und .filter-bar__tab waren bis auf flex-shrink und 4px
Innenabstand identisch. Beide laufen jetzt über eine gemeinsame .tab-Basis, die
aria-pressed (Filter) und aria-selected (Tabs) gleich behandelt; von den
BEM-Klassen bleibt nur das jeweilige Delta. Innenabstand vereinheitlicht auf
--space-m, die Gruppen-Tabs verlieren dadurch 4px pro Seite.

Icon-Buttons: .ad-banner__nav-btn und .ad-banner__toggle waren vollständige
Duplikate voneinander. Sie tragen jetzt .btn .btn--icon und behalten nur die
Glas-Optik als Delta. Der Selektor schreibt .btn mit — utilities.css lädt nach
components.css, bei gleicher Spezifität gewänne sonst die rote .btn-Fläche.

Trefferflächen auf 44px (WCAG 2.5.5): .btn bekommt min-height und wird
inline-flex, damit Icon und Label ohne Zusatz-Markup zentriert liegen.
Bezeichnend auch hier — .department-choice__btn hatte inline-flex, align-items
und exakt denselben gap bereits selbst deklariert und gibt sie jetzt ab.
Controls, die optisch klein bleiben sollen, strecken die Fläche stattdessen
unsichtbar per Pseudo-Element (.tap-target, automatisch auch für .btn--sm) —
gewertet wird die Trefferfläche, nicht die gezeichnete.

Der Banner-Indikator brauchte einen anderen Weg: er trug overflow: hidden, um
den Fortschrittsbalken zu beschneiden, was jede Erweiterung wieder abgeschnitten
hätte. Der sichtbare 6px-Balken liegt jetzt im ::before, der Fortschritt ist
selbst rund — overflow entfällt, der Button ist 48x44px.

Haupt-Nav-Links, Dropdown-Trigger und Footer-Social-Links bekommen min-height
statt einer Pseudo-Erweiterung: sie stehen in Reihen, wo überlappende
Trefferflächen den Nachbarn verdecken würden. In der 60px hohen Kopfleiste ist
die Höhe ohnehin nicht sichtbar.

Bewusst unverändert: die Formular-Checkbox bleibt bei 24px (WCAG 2.5.8).
<input> ist ein Replaced Element, dort greift ::after nicht zuverlässig — und
das zugehörige <label> ist klickbar und liefert die große Fläche.

.btn--ghost ist angelegt, hat aber aktuell keinen Abnehmer: die vermuteten
nackten Links sind bei näherem Hinsehen Bildkarten (.instagram__link),
Wrapper (.split__cta) oder eigenständige Karten-Affordanzen (.team-card__cta),
keine tertiären Buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:28:02 +02:00
fs
3fa521eaf1 Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.

h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.

Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.

Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.

Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.

Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
fs
87055bd14b Design-System: Spacing auf Tokens migrieren
Alle padding/margin/gap-Deklarationen in components.css, layout.css und
base.css nutzen jetzt die Spacing-Skala — 414 Token-Verwendungen, kein roher
rem-Wert mehr in einer Abstands-Deklaration.

Die Substitution lief mechanisch (nächste Rasterstufe, Gleichstand aufgerundet),
damit sie deterministisch und nachvollziehbar ist statt 450-mal von Hand
geraten. Rund 325 Werte lagen bereits exakt auf einer Stufe und sind
verlustfrei übernommen. 125 Werte verschieben sich:

  ±0,4–2,0px   106 Werte  (0.1/0.15/0.3/0.35/0.4/0.6/0.85/0.9/1.1/1.6rem …)
  ±4px          12 Werte  (2.25 -> 2.5, 2.75 -> 3rem)
  ±8px           8 Werte  (3.5 -> 4, 4.5 -> 5, 7.5 -> 7rem)

Die 8px-Sprünge treffen ausschließlich Sektionsabstände (padding-block großer
Bänder, margin-top von Blöcken), wo sie optisch nicht ins Gewicht fallen. Die
±4px-Gruppe ist 2.25rem, das exakt zwischen zwei Stufen lag.

em-Werte bleiben bewusst roh: sie skalieren mit der font-size (Button-Padding,
Badge-Innenabstände) und gehören damit nicht auf ein festes px-Raster. Nullen,
auto und Prozentwerte sind unangetastet, calc() wurde korrekt mitgezogen
(calc(var(--header-h) + var(--space-5xl))).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:09:33 +02:00
fs
8304095dc3 Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.

Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.

Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.

--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.

Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00
fs
96a698ebdc Mobiles Menü als Drawer: oben verankert, Accordion statt Sprung
Das Overlay war vertikal zentriert. Beim Aufklappen eines Dropdowns wuchs die
Liste und rezentrierte sich — der gerade angetippte Punkt sprang unter dem
Finger weg, bei langen Menüs lief der Inhalt aus dem Screen (nicht scrollbar).

Jetzt ist die Liste oben verankert (justify-content: flex-start, padding unter
Header + Logo-Überhang) und scrollbar mit overscroll-behavior: contain. Punkte
sind volle Tap-Breite mit Hairline-Trennern, Chevron rechtsbündig, aktive Seite
mit rotem Balken links. Untermenüs animieren über die 0fr/1fr-Zeile des
li-Grids statt display: none — weiches Auf-/Zuklappen ohne Layout-Sprung.

Unterpunkte in Abel statt Coolvetica (unter 28px unleserlich, siehe CLAUDE.md),
linksbündig, Desktop-Panel-Optik im Overlay komplett zurückgesetzt (inkl.
backdrop-filter/radius/translateX, die sonst durchschlugen).

nav.js setzt .site-nav--js auf die Nav: nur ohne JS öffnet :focus-within die
Untermenüs. Mit JS wäre das ein Bug — der Button behält nach dem Tipp den
Fokus, das Menü ließe sich nie wieder zuklappen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 09:46:40 +02:00
1080 changed files with 84331 additions and 1523 deletions

14
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Secrets — nur config.example.php wird committed
/config/config.php
/config/config.production.php
# Composer
/vendor/
@@ -8,6 +9,10 @@
/storage/logs/*
/storage/cache/*
/storage/ratelimit/*
# Druck-QR-Codes (bin/qr.php) — Zwischenprodukte für Canva, kein Repo-Inhalt.
# Wichtig: sie enthalten die base_url fest eingebacken, ein versehentlich
# eingecheckter localhost-Code wäre eine Falle.
/storage/qr/
!/storage/logs/.gitkeep
!/storage/cache/.gitkeep
!/storage/ratelimit/.gitkeep
@@ -22,6 +27,15 @@
/public/assets/img/crests/*
!/public/assets/img/crests/.gitkeep
# Dateiablage /dateien — Nutzerdaten und 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
/storage/dateien/system/cache/
/storage/dateien/system/config/
# Referenz-Material der alten Seite — NIE committen
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)
/.context/

863
CLAUDE.md
View File

@@ -8,20 +8,29 @@ 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.
- **Lokales Dev:** `php -S localhost:8000 -t public public/index.php` (index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt).
- **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
PHP-Standardwerten (oft 2 MB) scheitert jeder Video-Upload. Für die Seite selbst sind sie egal.
- **Docroot:** `public/`. Falls der Hoster nur das Repo-Root serviert, greift die Root-`.htaccess` (Rewrite → `public/`, Deny für alles andere).
## Harte Regeln (nicht verhandelbar)
1. **Keine externen Einbindungen.** Keine CDNs, keine externen Fonts/Scripts/iframes/Embeds, keine
Tracking-/Analytics-Dienste, kein reCAPTCHA. Alles wird self-hosted. Einzige erlaubte ausgehende
Verbindungen: All-Inkl-SMTP (Mailversand, serverseitig) und der Instagram-Scraper (CLI/Cron, nie im Request-Pfad).
Verbindungen: All-Inkl-SMTP (Mailversand, serverseitig) und die CLI/Cron-Skripte unter `bin/`
(Instagram-Scraper, Matchcenter-Sync, Files-Gallery-Update — nie im Request-Pfad).
Besucher-Browser kontaktieren ausschließlich unsere Domain. Die CSP (`default-src 'self'`) erzwingt das — nicht aufweichen.
2. **`.context/` und `.context_db/` sind reine Referenz** (alte, unsichere CMS-Seite). Von dort werden nur
Assets (Bilder/Fonts/Videos), Texte und Styling-Werte extrahiert. **Niemals Code übernehmen, niemals
Dateien von dort direkt verlinken.** Assets immer nach `public/assets/` kopieren/optimieren.
3. **Kein Admin-/Pflege-Backend bauen.** Inhalte werden per Chat gepflegt: Claude editiert `data/*.json`
bzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints.
bzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints — mit **zwei** benannten Ausnahmen:
(a) die Dateiablage `/dateien` (siehe unten), eine fremde, gekapselte App, die ausschließlich in
`storage/dateien/uploads/` schreibt und **keine** Website-Inhalte anfasst; (b) der Cron-Endpoint
`public/cron.php` (siehe unten), der nur zwei feste Sync-Skripte startet und keine Eingaben
verarbeitet. Nichts davon darf in den eigenen Code wachsen: kein zweiter Login, keine Schreib-Route
in `app/`, keine dritte Ausnahme ohne denselben Aufwand an Begründung.
4. **Sensible Altdaten nie übernehmen:** Aus dem DB-Dump keine Mail-Logs, Formulareinträge,
Passwort-Hashes, Tokens oder personenbezogene Daten migrieren.
5. **Secrets nur in `config/config.php`** (gitignored, außerhalb des Webroots, per .htaccess denied).
@@ -34,7 +43,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Farben, Fonts, Spacing, Radii, Schatten | `public/assets/css/tokens.css` | Hex-Werte/Magic Numbers in anderen CSS-Dateien |
| Seiten-Slugs & Routing | `app/routes.php` | URLs woanders hart verdrahten |
| Navigation (Labels/Reihenfolge) | `data/navigation.json` | Menüpunkte in Templates |
| Vereinsdaten (Name, Adresse, Kontakt, Social, Geo) | `data/club.json` | Adresse/E-Mail irgendwo als Text duplizieren |
| Vereinsdaten (Name, Adresse, Kontakt, Social, Geo, Slogan, **Web-Adresse**) | `data/club.json` | Adresse/E-Mail irgendwo als Text duplizieren. `club.website` ist die öffentliche Domain für alles Gedruckte (`club_url()`/`club_domain()`) — **nicht** `config('base_url')`, die ist umgebungsabhängig |
| Startseiten-Inhalte | `data/home.json` | Texte in `pages/home.php` |
| Mannschaften (Kader, Trainer, Hero, FAQ) | `data/teams.json` | Spieler-/Trainernamen in Team-Templates |
| Seiteninhalte (Texte, Hero, FAQ) | `data/<slug>.json` (z. B. `fussball`, `jugend`, `turnen`, `verein`, `mitmachen`, `historie`, `partner-werden`, `sportheimbuchung`) | Texte/Hero/FAQ hart in `pages/*.php` |
@@ -43,6 +52,12 @@ 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 |
| 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`) |
| 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 |
Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pages/*.php`) im `$meta`-Array.
@@ -55,61 +70,190 @@ 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.
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
FAQ-Antworten (beide FAQ-Komponenten, `faq_schema()`) und vom Artikelkörper der News-Detailseite.
**Kein rohes HTML in der JSON, keine externen URLs** — die Escaping-Garantie ist der Wert dieser
Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
## Architektur-Muster
- **Front Controller:** `public/index.php` → Slug-Lookup in `app/routes.php` → Page-Datei setzt `$meta`
und emittiert Body via `component()``app/layout.php` rendert die Shell.
und emittiert Body via `component()``app/layout.php` rendert die Shell. **Genau drei bewusste
Ausnahmen** vom exakten Slug-Lookup, alle als benannte Prefix-Routen in `$prefixRoutes`
(`public/index.php`, vor dem `routes.php`-Lookup): `stadionzeitung/<ausgabe>`
`app/pages/stadionzeitung-ausgabe.php`, `veranstaltungen/<slug>``app/pages/veranstaltung.php`
und `news/<slug>``app/pages/news-artikel.php`.
Alle drei ermitteln ihren Detail-Slug selbst wieder aus der URL. **Keine generelle
Wildcard-Fähigkeit** — eine vierte Ausnahme braucht denselben Aufwand an Begründung.
`news/` ist kein neues Muster, sondern das dritte Vorkommen desselben (Detailseite zu einem
Eintrag in einer `data/*.json`); die Alternative, ein exakter `routes.php`-Eintrag pro Meldung,
würde redaktionelle Inhalte ins Routen-Register schreiben und die Single-Source-Regel brechen.
Die Übersichts-Slugs (`stadionzeitung`, `veranstaltungen`, `news`) beginnen nicht mit ihrem
eigenen Präfix und bleiben davon unberührt.
`$current` bleibt dabei der volle URL-Slug (sonst kanonisierte jede Detailseite auf die
Übersicht); dass der Elternpunkt in der Navigation deshalb kein `aria-current` bekommt, ist
bewusst in Kauf genommen.
- **Komponenten:** `app/components/*.php` sind dumme Includes, bekommen Props via
`component('name', ['key' => $val])`. **Component-first-Regel:** Bevor neues Markup/CSS entsteht,
prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen
Seiten → als Komponente extrahieren.
- **Helpers** (`app/helpers.php`): `e()` (Escaping — IMMER für dynamische Ausgaben), `url()` (interne
Pfade), `abs_url()` (absolute URLs für Canonical/OG), `asset()` (Versionierung via filemtime),
Pfade), `abs_url()` (absolute URLs für Canonical/OG), **`club_url()`/`club_domain()`** (absolute
URL bzw. Domain ohne Schema auf der ÖFFENTLICHEN Vereinsadresse `club.website` — für alles, was
gedruckt wird oder als Adresse dasteht; **nie `abs_url()` dafür**, siehe „Gedruckte Adressen" unten),
`asset()` (Versionierung via filemtime;
zweiter Parameter `false` lässt `?v=` weg — nur für Dateien, die auch statisches CSS anfordert,
also die Font-Preloads in `meta.php`, deren URL exakt der `@font-face`-URL aus `base.css`
entsprechen muss; geänderte Fonts bekommen einen neuen Dateinamen statt einer Version),
`json_load('name')` (liest `data/name.json`, static cache), `component()`, `config('key')` (Werte aus
`config/config.php`), `icon('name', 'klasse', 'label?')` (Inline-SVG aus `public/assets/icons/`, siehe
Abschnitt Icons), `form_token()`/`form_token_valid()` (Time-Trap-Token, siehe Formulare),
`img_intrinsic_attrs('pfad')` (width/height-Attribute aus der Bilddatei für CLS-freie `<img>`,
wenn die Maße nicht schon statisch bekannt sind), `match_when('2026-08-01T14:00')` (Anstoß aus
`matchcenter.json``iso` für `<time datetime>`/JS-Ticker + deutscher Countdown-Text, gelesen als
Europe/Berlin, weil PHP global auf UTC läuft).
Europe/Berlin, weil PHP global auf UTC läuft; zweiter Parameter `false` lässt die Uhrzeit weg, für
ganztägige Events), `department_label('turnen')` (Anzeigename eines Vereinsbereichs, gemeinsame
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
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`,
`--card-gap`, …); das Layout der Reihe/Fläche gehört auf den Kontext selbst. Custom Properties
erben, die Komponente bleibt dumm. Gegenbeispiel, das dreimal passiert ist:
`.stadionzeitung-ergebnisse__row .crest { display: grid; grid-template-columns: … }` machte das
**Wappen** zum Grid statt die Zeile — Wappen lag als Block auf eigener Zeile, Vereinsname und Tore
rutschten darunter zusammen. Faustregel: steht in einer `… .crest`-Regel mehr als `--crest-size`,
ist der Selektor falsch.
- **JS:** Vanilla, `defer`, progressive enhancement — alles muss ohne JS funktionieren
(Formulare = normales POST + Redirect). Ein Feature = eine Datei = eine selbst-initialisierende IIFE.
- **Hero-Regel:** Die Startseite hat **einen** großen Hero (`display: true`, mit `kicker`/`tagline`/Video).
**Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser"** — nur `title` + `text`, dazu
`compact: true` und `align: 'bottom-left'`. **Kein `kicker`, keine `tagline`** auf Unterseiten (hält es
klar und einfach). `ctas` nur als begründete Ausnahme. Gilt für JSON- wie Inline-Heros.
- **Hero-Regel:** Die Startseite hat **einen** großen Hero (`display: true`, mit `kicker`/`tagline`/Video,
`align: 'center'`). **Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser"** — nur `title` +
`text`, dazu `compact: true` und **`align: 'center'`** (Standard seit 27.07.2026 — vorher `'bottom-left'`,
testweise auf allen Verein-Unterseiten umgestellt, dann projektweit übernommen). **Kein `kicker`, keine
`tagline`** auf Unterseiten (hält es klar und einfach). `ctas` nur als begründete Ausnahme. Gilt für
JSON- wie Inline-Heros. `align: 'bottom-left'` bleibt als Variante im `hero`-Component erhalten, ist aber
für neue Unterseiten nicht mehr die Konvention.
- **Termin-Aufmacher im Startseiten-Hero:** liegen Termine an, läuft der Hero zweispaltig
(`hero--duo`) — links Titel/Text/CTAs, rechts die Glas-Karte `hero-upcoming` mit den **nächsten
drei Terminen**, die `assets/js/carousel.js` alle 5 s überblendet (Dots mit Fortschrittsring +
Pause-Taste in der Karte). Quelle ist `upcoming_highlights(3)` — Spiele **und** Vereins-Events
chronologisch gemischt, aufgebaut auf `termine_build_lists()`, damit es weiterhin nur eine
Merge-Logik für Termine gibt. Zwei Innenvarianten in einer Karte: `match-feature` (Spiel, mit
Wappen) bzw. `event-feature` (Termin, mit Datums-Chip). Ohne Termine (Sommerpause) bleibt es beim
zentrierten Hero **ohne** Karte, ohne JS ist nur der nächste Termin sichtbar und verlinkt.
Beschriftung: nur der erste Slide sagt „nächstes" („Nächstes Spiel · 1. Mannschaft", danach
„Spiel · 1. Mannschaft" / „Termin · Verein").
- **Neue Seite anlegen** = genau 3 Schritte: `app/pages/<slug>.php` + Eintrag in `app/routes.php`
(+ optional `data/navigation.json`). Sitemap & Canonical folgen automatisch.
## Formulare
- **Felder ausschließlich über `component('form-field', ['field' => …])`** (`app/components/form-field.php`) —
nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext und das
Fehler-Element `#<id>-error` (per `aria-describedby` verknüpft) in einheitlichem Markup.
nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext, das
Fehler-Element `#<id>-error` (per `aria-describedby` verknüpft) und schreibt nach einem
abgewiesenen POST die alten Werte zurück (`form_old()`, inkl. `selected`/`checked`).
- **Jedes neue Formular braucht:** (1) Status-Region `role="status" aria-live="polite" tabindex="-1"
data-form-status` als erstes Form-Kind, (2) Hinweiszeile „Pflichtfelder sind mit * markiert“,
(3) Honeypot-Feld `website` (visually-hidden) + Hidden-Field `ft` mit `form_token()`,
(4) serverseitige Action unter `app/actions/` mit Whitelist-Validierung + PRG-Redirect.
- `form.js` übernimmt automatisch Inline-Feldfehler (Browser-Validierungsmeldung, `aria-invalid`,
Fokus aufs erste ungültige Feld) — Konvention dafür ist die `#<id>-error`-ID aus der Komponente.
data-form-status` als erstes Form-Kind — mit `autofocus`, wenn `form_has_errors()`,
(2) Honeypot-Feld `company_url` (visually-hidden) + Hidden-Field `ft` mit `form_token()`,
(3) serverseitige Action unter `app/actions/` mit Whitelist-Validierung, (4) Fehlermeldungs-Map
mit den Schlüsseln `validation` / `ratelimit` / `busy` / `mail` (identisch in `form.js`).
- **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
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.
**Braucht eine Abteilung ein eigenes Formular, wird es eine weitere Variante** — kein zweites
Formular und keine zweite Action, sonst wäre der komplette Spam-Schutz doppelt zu pflegen.
- **Antwortmuster der Actions** (in dieser Reihenfolge, siehe `contact-submit.php` als Referenz):
Erfolg und Volumen-Abweisungen → PRG-Redirect (`?sent=1` / `?error=…`), fetch-Requests → JSON.
**Feldfehler ohne JS → `render_form_invalid($slug, $old, $errors)`**: rendert die Seite mit
HTTP 422 samt Eingaben und Feldfehlern neu, statt per Redirect alle Eingaben zu verlieren.
`$errors` ist Feld-**ID** → Meldung (passt auf `#<id>-error`), `$old` ist Feld-**Name** → Wert.
- `form.js` zeigt Inline-Feldfehler (Browser-Validierung vor dem Absenden, Server-Feldfehler aus
`fields` in der JSON-Antwort) — Konvention dafür ist die `#<id>-error`-ID aus der Komponente.
- A11y-Vorgaben: Feldränder mit `--clr-glass-border-strong` (≥3:1 Kontrast, WCAG 1.4.11), Checkbox
min. 24px (WCAG 2.5.8), Status-Region nie per `display:none` verstecken (fliegt aus dem Accessibility-Tree).
- Versand via PHPMailer über All-Inkl-SMTP (`w01ca75d.kasserver.com:587`, STARTTLS, Postfach
`noreply@tsv08kulmbach.de`), Config aus `config/config.php`. Verbindung/Login ohne Mailversand
testen: `php bin/smtp-test.php`.
- **Autofill-Fix in `components.css`** (bei `:-webkit-autofill`/`:autofill`): Chrome/Safari rendern
automatisch ausgefüllte Felder mit einer eigenen hellen Füllfläche + schwarzer Schrift, die
normales `background`/`color` nicht übersteuert — nur ein deckender `box-shadow inset` mit
`--clr-field-bg-solid` (opake Variante von `--clr-field-bg` in `tokens.css`, da der Shadow keine
Transparenz durchlässt) + `-webkit-text-fill-color`. Die absurd lange `transition`-Dauer ist
bewusst roh (kein `--transition`-Token) — sie verhindert, dass Chrome die native Füllung beim
periodischen Neuanwenden aufblitzen lässt.
- **Versand ausschließlich über `send_mail()`** (`helpers.php`) — die einzige Versandstelle der Seite,
PHPMailer über All-Inkl-SMTP (`w01ca75d.kasserver.com:587`, STARTTLS, Postfach
`noreply@tsv08kulmbach.de`), Timeout 10 s (der Versand hängt im Request-Pfad!), Fehler nach
`storage/logs/mail.log`. Nie einen eigenen PHPMailer in einer Action aufbauen.
Verbindung/Login ohne Mailversand testen: `php bin/smtp-test.php`.
- Spam-Schutz ohne externe Dienste — mehrschichtig in jeder `app/actions/*`-Action:
(1) Honeypot-Feld `website`, (2) HMAC-signierter Timestamp / Time-Trap (`ft`-Token via
`form_token()`/`form_token_valid()`, `app_secret`), (3) serverseitige Whitelist-Validierung,
(4) **Rate-Limiting** via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`:
pro IP+Route (5/10 min), globaler Tages-Cap (100/Tag) und Token-Replay-Sperre (3×/Token),
(5) Link-Count-Check im Freitext. Abgewiesene Versuche → `log_spam()` nach `storage/logs/spam.log`.
Immer POST-Redirect-GET; `form.js` macht optional fetch.
- Logs unter `storage/logs/`: `mail.log` (Mailversand-Fehler), `spam.log` (abgewiesene Form-Versuche),
`instagram.log` / `matchcenter.log` (Sync-Läufe), `php-errors.log`. Fehler nie an Besucher leaken.
(1) `config_problems()` als Not-Aus (ohne echte `config.php` liefe die Seite auf der Vorlage,
deren `app_secret` öffentlich im Repo steht), (2) Honeypot-Feld `company_url`, (3) HMAC-signierter
Timestamp / Time-Trap (`ft`-Token via `form_token()`/`form_token_valid()`, `app_secret`),
(4) serverseitige Whitelist-Validierung, (5) Link-Count-Check im Freitext, (6) **Rate-Limiting**
via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`: pro IP+Route (5/10 min),
Token-Replay-Sperre (3× pro Token+IP) und globaler Tages-Cap (200/Tag über alle Formulare).
Abgewiesene Versuche → `log_spam()` nach `storage/logs/spam.log`.
- **Reihenfolge und Ton der Abweisungen sind Absicht:** Bot-Signale (Honeypot, Token, Link-Flut)
antworten mit einem stillen „OK“ (kein Feedback-Kanal für Bots). Volumen-Grenzen antworten
**ehrlich** (`ratelimit` / `busy`) — ein stilles „Danke“ ohne Versand verschluckt sonst echte
Anfragen. Die Volumen-Grenzen stehen **nach** der Validierung, damit Tippfehler kein Kontingent
verbrauchen.
- **Vor jedem Deploy `php bin/preflight.php`** — prüft Config/Secrets, `env`, Schreibrechte,
`vendor/`, PHP-Version und die Cron-Datenstände. Exit 1 = noch nicht live gehen.
Datenschutzerklärung (`app/pages/datenschutz.php`) mitziehen, wenn ein Formular neue Felder
bekommt — sie beschreibt die vier Formulare einzeln.
## Logging
- **Geschrieben wird ausschließlich über `log_write($kanal, $level, $meldung)`** (`helpers.php`) —
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`
(`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
nicht ladbar, Lock belegt, Cache-Fallback) · **ERROR** Funktion ausgefallen (Mailversand,
Sync-Abbruch, unvollständige Config).
- Format `[ISO-Zeit] LEVEL Meldung`, **ein Ereignis = eine Zeile** (Umbrüche werden ersetzt).
Debug-Dumps gehören auf stdout hinter ein Flag, nicht ins Betriebslog.
- **ERROR läuft zusätzlich in `error.log`** — die eine Datei, die „ist gerade etwas kaputt?"
beantwortet. Die Kanal-Logs behalten den vollen Verlauf.
- **Nie hineinschreiben:** Formularinhalte, Klartext-IPs, Mailadressen, Secrets. IP nur als
gesalzener HMAC-Kurzhash (`log_spam()`). Fehlerdetails nie an Besucher ausgeben.
- Betrieb: `php bin/logs.php` (Übersicht: Fehler der letzten 7 Tage, je Kanal Größe/Alter/
Level-Zähler, letzte Zeilen; Exit 1 wenn Fehler vorliegen) · `php bin/log-rotate.php`
per Cron (>2 MB → `.log.1`, Einträge älter als 90 Tage werden entfernt).
## Sprache & Ton (gilt für jeden sichtbaren Text)
Betrifft alle `data/*.json`, Texte in `app/pages/*.php` und Alt-Texte. Zwei Vorgaben von Felix
(27.07.2026), beide nicht verhandelbar:
1. **Keine Gedankenstriche.** Kein `` als Satzzeichen. Was mit Gedankenstrich klingt, wird ein
eigener Satz, ein Komma oder ein Doppelpunkt. Auch **optionale Binde­striche in Komposita**
auflösen: „Zeit in der Kreisliga" statt „Kreisliga-Zeit", „Derby in der Relegation" statt
„Relegations-Derby", „unser bester Torjäger" statt „Top-Torjäger". **Ausnahmen** sind nur
orthografisch zwingende Bindestriche: Eigennamen (`Hans-Rausch-Sportanlage`, `Eltern-Kind-Turnen`,
`Step-Aerobic`), Liganamen (`B-Klasse`) und feste Schreibweisen (`E-Mail`, `Co-Trainer`).
2. **Wir-Form, nicht die dritte Person.** Der Verein spricht als „wir"/„unser", nicht als „der
TSV 08 Kulmbach". Also „…aus der wir aufgestiegen waren", nicht „…aus der der TSV 08 aufgestiegen
war". Gilt auch für **Alt-Texte** („Unsere Frauenmannschaft feiert…"). **Ausnahmen**, bei denen der
Vereinsname ausgeschrieben bleibt: FAQ-**Fragen** (die stellt der Besucher: „Welche Abteilungen hat
der TSV 08 Kulmbach?"), `/partner-werden` (spricht Firmen mit „Sie" an), juristische Stellen
(Impressum, Datenschutz, `club.legal_name`) und der Titel-Suffix „| TSV 08 Kulmbach", den
`layout.php` anhängt.
Umgesetzt: `/historie` komplett (`data/historie.json`, `$meta` in `app/pages/historie.php`) sowie
`club.description` (steht als Footer-Claim und als Organization-Beschreibung im JSON-LD auf **jeder**
Seite) und das Brand-`aria-label` in `app/components/header.php`. Die übrigen `data/*.json` sind noch
nicht durchgezogen.
## Design (aus der alten Seite extrahiert, verifiziert)
@@ -118,10 +262,51 @@ Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt
- Headings: Coolvetica, uppercase, line-height ~0.85. Body: Abel. Beide self-hosted als woff2.
- **Coolvetica nie kleiner als `--fs-650` (28px)** — der schmale Condensed-Schnitt wird in Uppercase
darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen
Abel verwenden, ggf. mit `letter-spacing` + uppercase.
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`**.
- Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
- 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)
Keine rohen Werte in `base/layout/components/utilities.css` — es gibt für alles eine Stufe:
| 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 |
| `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` | |
| Fokus | `--focus-ring` / `--focus-offset` | |
| Trefferfläche | `--tap-min` (44px) | |
**Ausnahmen, bewusst roh:** `em`-Werte, die mit der `font-size` skalieren sollen (Button-Padding
`--btn-pad-*`, Badge-Innenabstände) — die gehören nicht auf ein festes px-Raster.
### Buttons & Trefferflächen
- Varianten in `utilities.css`: `.btn` (primary) · `.btn--outline` · `.btn--ghost` (tertiär, ohne Fläche)
· `.btn--icon` (quadratisch, nur Icon + `aria-label`) · `.btn--sm` / `.btn--lg`.
`.btn` ist `inline-flex` mit `gap` — Icon + Label brauchen **kein** Zusatz-Markup und keinen eigenen Abstand.
- Tabs (Filterleiste wie Panel-Tabs) laufen über die gemeinsame **`.tab`**-Basis in `components.css`;
aktiv wird über `aria-pressed` **oder** `aria-selected` erkannt. Nie eine dritte Tab-Optik bauen.
- **Jedes Control ≥ `--tap-min` (44px, WCAG 2.5.5).** Soll es optisch kleiner bleiben, streckt
`.tap-target` die Trefferfläche unsichtbar per `::after` (bei `.btn--sm` automatisch). Voraussetzung:
kein `overflow: hidden`, `::after` frei. In Reihen (Nav, Footer) stattdessen `min-height` verwenden —
überlappende Pseudo-Flächen würden sonst den Nachbarn verdecken.
- Ausnahme: die Formular-Checkbox bleibt bei 24px (WCAG 2.5.8). `<input>` ist ein Replaced Element,
`::after` greift dort nicht zuverlässig; das klickbare `<label>` liefert die große Fläche.
- **Komponenten-Deltas zu `.btn` brauchen (0,2,0)** — z. B. `.btn.ad-banner__nav-btn`. `utilities.css`
lädt nach `components.css`, bei gleicher Spezifität gewinnt sonst die `.btn`-Basis.
### Fokus
`base.css` setzt **eine** globale `:focus-visible`-Regel mit `--focus-ring` (2px weiß, 15,9:1 auf
`--clr-bg`). Komponenten überschreiben sie **nur** mit Begründung — zulässig sind ein negativer
`outline-offset` (Ring soll inset liegen, z. B. `.tab`) und zusätzliche Effekte ohne eigene `outline`.
**Nie `outline: none`** und nie Akzentrot als alleiniger Indikator: Rot erreicht auf der Feldfläche nur
2,8:1 und verfehlt WCAG 1.4.11 (≥3:1).
## Icons
- **Bootstrap Icons** (MIT), lokal & self-hosted unter `public/assets/icons/<name>.svg` — die erlaubte Icon-Quelle. Kein Icon-Font, kein CDN, keine gezeichneten CSS-/Unicode-Icons.
@@ -135,6 +320,8 @@ Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt
- [ ] Alt-Texte für Bilder; dekorative Bilder `alt=""`
- [ ] Tastatur-bedienbar, `:focus-visible` sichtbar, sinnvolle Tab-Reihenfolge
- [ ] Kontrast ≥ 4.5:1 (Fließtext) / 3:1 (große Schrift, UI)
- [ ] Keine rohen Werte: Spacing, `font-size`, `letter-spacing`, Fokus und Trefferflächen über die
Token aus `tokens.css` (siehe „Token-Disziplin"). Interaktive Elemente ≥ `--tap-min`
- [ ] `prefers-reduced-motion` respektiert (globaler Kill-Switch in reset.css)
- [ ] `$meta` gesetzt: title, description (+ og_image falls abweichend)
- [ ] Bilder: `srcset`/`sizes`, `width`/`height`, `loading="lazy"` (above-the-fold: eager + fetchpriority).
@@ -142,6 +329,48 @@ Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt
- [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
- [ ] `php -l` sauber; Seite lokal geprüft
## Cron-Endpoint `public/cron.php` (Ausnahme 3b)
**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.
**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
Syncs" kann. Kleinerer Radius bei einem Leak, und es braucht kein Git-Remote und keine Upload-Logik
für Bilder. **Besucher-getriggerter Sync wurde verworfen** (siehe „nie im Request-Pfad"): ohne
Besucher keine Aktualisierung, und Fire-and-Forget ist auf Shared Hosting unzuverlässig.
**Die Grenzen sind der Punkt — keine davon entfernen:**
- **Job-Whitelist** im Endpoint, nie ein Skriptname aus der URL.
- **Not-Aus wie in den Actions:** leerer Schlüssel, `CHANGE_ME` oder < 24 Zeichen → 403. Ohne das wäre
der Endpoint auf `config.example.php` offen, deren Werte im Repo stehen.
- **`cron.min_interval`** (Default 240 s, Marker unter `storage/cache/cron-<job>.last`). Das eigentliche
Risiko eines geleakten Links ist nicht Datenverlust — die Syncs schreiben atomar und behalten bei
Fehlern den letzten guten Cache — sondern dass BFV oder Instagram unsere **Server-IP sperren**.
Der Marker wird bei jedem angenommenen Versuch gesetzt, nicht erst bei Erfolg.
- **Antwort immer leer.** 204 = gelaufen oder wegen Mindestabstand übersprungen, 403 = Schlüssel/Job
falsch. Der 403 ist Absicht statt eines stillen 204: ein falsch eingerichteter Cron-Dienst fällt
sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
- `ob_start()` + Shutdown-Handler verwerfen jede Skriptausgabe — die Syncs beenden sich per `exit()`,
ohne das könnten Debug-Zeilen oder PHP-Warnungen im HTTP-Body landen.
**Zwei Eingriffe in den Sync-Skripten**, beide minimal: der CLI-Riegel lautet jetzt
`PHP_SAPI !== 'cli' && !defined('CRON_HTTP')`, und der Bootstrap-Include ist `require_once` —
`app/bootstrap.php` definiert Konstanten und lädt `helpers.php`, ein zweiter Durchlauf wäre ein Fatal.
`$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.
**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
dort blockt, hilft auch GitHub Actions nicht (noch strenger gesperrt) — dann bleibt ein Lauf von einem
privaten Anschluss oder der „Aktuelles"-Block wird redaktionell gepflegt.
## Instagram-Feed (Juicer-Ersatz)
`bin/instagram-sync.php` (nur CLI) scraped das öffentliche Profil @tsv08kulmbach, schreibt
@@ -167,6 +396,576 @@ Genau **drei Schriftrollen**, nicht mehr: Meta-Zeile „Mannschaft · Liga · He
uppercase, gesperrt) · Vereinsnamen (Coolvetica, einziges Heading-Element) · Anstoßzeile mit Countdown
via `match_when()` (Abel). Keine Badges, Icons oder Buttons — die Wirkung kommt aus der Größe, Rot nur
als Oberkante des Bandes. Die eigene Mannschaft wird über Helligkeit markiert, nie über `font-weight`
(Coolvetica hat nur einen Schnitt → sonst Fake-Bold). Kein Slider —
`assets/js/carousel.js` treibt nur noch den Banner-Slider der Startseite.
(Coolvetica hat nur einen Schnitt → sonst Fake-Bold). **Im Band selbst kein Slider** — der generische
`assets/js/carousel.js` treibt nur den Banner-Slider und den Termin-Aufmacher im Startseiten-Hero
(`hero-upcoming`, s. Hero-Regel), der `match-feature` als eine seiner beiden Innenvarianten nutzt.
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,
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
gebraucht, wofür es sonst nirgends im Projekt einen Präzedenzfall gibt (nur GD, siehe
`bin/img-resize.php`) — mit Bildern aus Canva entfällt das komplett.
**Workflow (Chat-Arbeit, kein Cron):** Ordner mit den Seiten-Bildern von Felix bekommen, erwarteter
Inhalt:
```
<ordner>/vorwort.* optional, Fallback-Bild — nur genutzt, solange für diese Ausgabe
noch kein Vorwort-Text hinterlegt ist (siehe unten)
<ordner>/rueckseite.* optional
<ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz) fürs
automatische Cover — wird auf den Bildinhalt zugeschnitten
(crop_transparent_png_to_content(), Alpha-Bounding-Box + kleiner
Rand: Freistellungs-Exporte lassen oft viel leeren, transparenten
Rand stehen, sonst wirkt der Spieler auf dem Cover kleiner als nötig)
<ordner>/verfasser.* optional, Porträtfoto für die Signatur im automatischen Vorwort
```
**Keine Anzeigen im Ablieferordner** (seit 31.07.2026): die kommen aus dem dauerhaften Bestand
`data/anzeigen.json`, gepflegt mit `php bin/anzeige-add.php <bild-oder-ordner>` (Dateiname = Slug,
`eldorado.jpg` → `img/anzeigen/eldorado`). Ein noch vorhandener `anzeigen/`-Unterordner wird ignoriert,
das Skript sagt es aber. Vorher lag jede Anzeige als Kopie unter jeder Ausgabe (19 × 3 Breiten pro
Heft) und ihr Alt-Text musste jedes Mal neu geschrieben werden; er steht jetzt einmal beim Sponsor.
Kein `cover.*` mehr — die Titelseite wird automatisch generiert (siehe unten). Dann
`php bin/stadionzeitung-add.php <ordner> "<Gegner>" <YYYY-MM-DD> <team-key> [slug]` —
`<team-key>` (z. B. `erste-mannschaft`, `damen`, siehe `data/matchcenter.json`) bestimmt, welche
Mannschaft die Ausgabe „trägt" (Cover-Team/Liga, Termine-Bezug) — Tabellen bekommen seit der
Mehr-Tabellen-Vorlage ALLE Mannschaften aus `matchcenter.json`, je eine Seite. Das Skript
erzeugt responsive Breiten je Seite (`resize_image_variants()`, dieselbe Logik wie
`bin/img-resize.php`) und schreibt `data/stadionzeitung.json` atomar. Erneuter Aufruf mit demselben
Slug ersetzt die Ausgabe vollständig (Korrekturen). Kein Original-PDF wird öffentlich angeboten,
kein Download-Link — nur die Web-Ansicht. Der Ablauf pro Heimspiel ist damit:
**anlegen (Entwurf) → gemeinsam prüfen (direkte URL, Live-Daten) → `php bin/stadionzeitung-publish.php <slug>`
(friert ein, macht sichtbar) → Druckfassung `?druck=1` drucken → nie mehr anfassen.**
**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 ⌷
[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) ⌷
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).
**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
dafür eine Anzeige zwischen einer Lücke vor und einer nach dem Tabellen-Block und protokolliert es.
Früher war das Handarbeit („notfalls eine Anzeige verschieben") und wurde vergessen. Bei ungerader
Tabellenzahl (drei Mannschaften) bilden Erste und Zweite die Doppelseite, die dritte steht neben der
Folgeseite — paarweise geht bei drei Tabellen arithmetisch nicht.
**Anzeigen-Verteilung (Beschluss 31.07.2026):** Der Inhalt ist eine Liste von **Blöcken**, die
Anzeigen werden **gleichmäßig in die Lücken dazwischen** verteilt, **nie mehr als zwei
hintereinander** (vorher: vier Blöcke à 5, das las sich wie „Heft zu Ende"). Verteilt wird gestreut,
nicht vorne gebündelt. Drei Zonen bleiben anzeigenfrei: **nach dem Cover und im Aufschlag 2/3**
(Vorwort + Interview sind EIN Block), **zwischen zwei Tabellenseiten** (sonst zerreißt die
Doppelseite) und **zwischen Impressum und Rückseite**. Reichen die Lücken nicht (mehr als 2 × Lücken
Anzeigen), warnt das Skript statt still Dreier-Blöcke zu bauen.
**Ehrliche Grenze:** „Inhalt, Anzeige, Inhalt" im Wechsel geht nicht auf — bei 19 Anzeigen und 12
Lücken bekommen 7 Lücken zwei. Zwei hintereinander ist das Optimum, nicht eins. Und weil ein
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
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.
**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
die Live-Seiten (Tabellen/News/Ergebnisse/Vorschau) bei jedem Aufruf frisch aus denselben Quellen wie
`/fussball`/`/news`/Matchcenter. **`php bin/stadionzeitung-publish.php <slug>`** friert genau diesen
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
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
veraltete Nummern konservieren).
**Termine (automatisch generiert, eingefroren):** schlichte weiße Live-Seite wie Tabelle/News, im
selben Aufbau wie das Vorwort (Kicker + Überschrift über einblendendem Vereinsfoto, hier oben statt
unten) — `app/components/stadionzeitung-termine.php`. Zeigt die **fünf zum Spieltag nächsten
Termine** (Spiele + Vereins-Events gemischt, `termine_build_lists()`), aber bewusst **kein
Live-Abruf**: `bin/stadionzeitung-add.php` ruft `termine_build_lists($date)` mit dem Spieltag als
Bezugsdatum auf und schreibt das Ergebnis einmalig ins `termine_snapshot`-Feld der Ausgabe. Eine
gedruckte Publikation zeigt dauerhaft, was am Spieltag anstand — nicht, was von „heute" aus gesehen
(dem Tag, an dem jemand die alte Ausgabe später aufruft) ansteht. `termine_build_lists()` akzeptiert
dafür ein optionales `$asOf`-Datum (Default: leer = echtes Heute, für `/termine` unverändert);
gesetzt filtert es zusätzlich `$mc['upcoming']` auf Spiele ab diesem Datum, weil die Matchcenter-Daten
selbst nur den aktuellen Sync-Stand kennen. Icon-Liste (rundes Badge, Akzentfarbe getönt,
`calendar-event.svg`) statt des Datums-Chips von `/termine` — bewusst andere Optik für die
Stadionzeitung, gleiche Datumshelfer (`german_date_range()`) und dieselbe Merge-Logik dahinter.
**Vorwort (automatisch generiert, sobald Text vorliegt):** schlichte weiße Live-Seite wie
Tabelle/Termine/News — `app/components/stadionzeitung-vorwort.php`. Der Text selbst ist **kein
Skript-Input**: er wird wie `data/events.json`/`news.json` im Chat gepflegt — Felix schickt
Überschrift/Absätze/Grußformel/Unterzeichner, Claude trägt sie manuell ins `vorwort`-Feld der
Ausgabe in `data/stadionzeitung.json` ein (`heading`, `paragraphs[]`, `closing`, `signee_name`,
`signee_role`). Damit ein erneuter `bin/stadionzeitung-add.php`-Lauf (z. B. um eine Anzeige zu
korrigieren) dieses Feld nicht überschreibt, liest das Skript den bisherigen Wert vor dem Ersetzen
aus und schreibt ihn unverändert zurück. Solange noch kein `vorwort`-Feld existiert, rendert die
Ausgabe stattdessen das gelieferte `vorwort.*`-Bild (Fallback, wie bisher). Porträtfoto optional
über `verfasser.*` (normales Foto, kein Alpha — anders als `spieler.*` einfach per
`resize_image_variants()` verkleinert), Pfad landet script-verwaltet im `verfasser_photo`-Feld.
**Zusatzschriften nur hier (Stadionzeitung, sonst nirgends):** neben Coolvetica/Abel gibt es
genau zwei weitere Schnitte, beide OFL und self-hosted — `--ff-serif` (PT Serif) für redaktionellen
Fließtext (Vorwort, Interview-Antworten, Cover-Teaser) und `--ff-signature` (Mr Dafoe) ausschließlich
für die Unterschrift des Vorwort-Unterzeichners (Signatur-Optik wie die Canva-Vorlage; keine
Versalien, kein letter-spacing — das zerreißt die verbundenen Buchstaben). **Dafont-Schriften vorher
immer auf die Lizenz prüfen:** „Free for personal use" reicht für die Stadionzeitung NICHT
(öffentliche Vereinspublikation mit Sponsorenanzeigen + Webfont) — deshalb wurde die gewünschte
dafont-„Signature" durch das OFL-Pendant Mr Dafoe ersetzt (Felix' Wahl aus sechs Kandidaten,
29.07.2026). Fließtext läuft über `--ff-serif`
(PT Serif, `public/assets/fonts/pt-serif.woff2` + `pt-serif-italic.woff2`, self-hosted wie
Coolvetica/Abel, OFL-Lizenz) statt `--ff-body` — ein Vorwort ist ein redaktioneller Brief, keine
UI-Fläche, eine Lese-Serif passt hier besser. Die Überschrift bleibt bewusst **nicht** in
Versalien (anders als der globale `h1``h6`-Standard aus `base.css`) — dafür lokal
`text-transform: none` auf `.stadionzeitung-vorwort__heading`.
**Interview (chat-gepflegt wie das Vorwort):** die Seite hinter dem Vorwort, auf die die
Teaser-Zeile des Covers verweist („Im Heft: Unser Trainer … über die Neuzugänge") —
`app/components/stadionzeitung-interview.php`. Der Inhalt ist KEIN Skript-Input: Felix schickt
Fragen/Antworten, Claude trägt sie ins `interview`-Feld der Ausgabe ein (`teaser` = Coverzeile,
`heading`, `person_name`, `person_role`, `intro?`, `fragen[]` mit `frage`/`antwort`, `photo?`).
`bin/stadionzeitung-add.php` trägt das Feld über erneute Läufe weiter (wie `vorwort`) und plant die
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).
**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
**freigestellte Spielerfoto der Star ist** (großformatig, randabfallend unten-mittig — ohne
geliefertes Foto trägt der Verlauf allein die Seite, kein leeres Loch). Riesiger
Zeitschriften-Masthead „Stadionzeitung" + Vereinslogo oben, darunter die Begegnung als **große freie
Typografie direkt auf dem Verlauf** (kein Kasten/keine Karte — Wappen beider Teams, Vereinsnamen,
Team/Liga/Datum), Fußzeile mit Slogan (`data/club.json` → `slogan`) + QR-Code zur digitalen Ausgabe
(`qr_image`) + Linktext. Wappen: eigenes + Gegner über einen Anstoß-Tag-Abgleich in
`data/matchcenter.json` next_matches/last_results — kein Treffer (z. B. bei einer BFV-fremden
Spielgemeinschaft) → Initialen-Fallback der `crest`-Komponente greift automatisch. Beide Felder
(`player_image`/`qr_image`) sind pro Ausgabe optional in `data/stadionzeitung.json` und werden von
`bin/stadionzeitung-add.php` gesetzt (QR immer, Spielerfoto nur wenn `spieler.*` geliefert wurde).
**Cover-Kopf (Front-Matter-Anmutung):** kleine rote Kicker-Zeile ganz oben (Saison + Ausgabedatum,
gesperrte Versalien) darüber, dann Vereinslogo und der riesige Masthead „Stadionzeitung" **auf
gleicher Höhe nebeneinander** (Lockup, nicht gestapelt), darunter eine dezente kursive Tagline.
Vereins-Hero-Bild (`data/home.json`, dieselbe Quelle wie die Startseite) als Hintergrund mit
Abdunklungs-Verlauf, das freigestellte Spielerfoto sitzt **sehr großformatig rechts randabfallend**
und reicht fast bis auf Masthead-Höhe. Unten links gestapelt statt zentriert: Begegnung (Wappen +
Vereinsnamen + Team/Liga), darunter optional die Teaser-Zeile „Im Heft" (Serif kursiv mit rotem
Balken, erscheint automatisch bei gesetztem `interview`-Feld), dann minimaler QR (ohne Vereins-Slogan und ohne Trennzeichen zwischen den Wappen — beide 29.07.2026 bewusst entfernt, ruhigeres Cover) (kleiner
Code + eine Zeile — die frühere QR-Plakette und die rote Trennlinie sind bewusst raus, Felix
29.07.2026: ruhiger, aufgeräumter). Alles läuft in einer schmalen linken Spalte, damit es dem rechts
randabfallenden Spielerfoto nie in die Quere kommt (zweispaltiges Cover-Layout statt Überlappung).
Vereinsfarbe Rot bewusst als wiederkehrendes Gestaltungsmittel (Kicker, Teaser-Balken),
nicht nur im Logo. Dünne Passepartout-Rahmenlinie + Passermarken
(Schnittmarken) in den vier Ecken geben den „druckfertig"-Effekt eines echten Zeitschriftencovers.
**Selbst-Containment (ALLE Stadionzeitungs-Seiten, seit 29.07.2026):** alle Innenmaße der
Live-Seiten laufen über CSS Container Queries — `.stadionzeitung-page` ist der Container
(`container-type: inline-size`), Schriftgrößen/Abstände in `cqi` (Referenz: 480px Seitenbreite,
1cqi = 4,8px), NICHT über die Website-Schriftgrößen-Tokens aus `tokens.css`. Eine Seite sieht
dadurch in jedem Kontext (Karten-Vorschau ~280px, Swipe-Viewer ~480px, Druck-Blattvorschau,
bedrucktes Blatt ~140mm) proportional exakt gleich aus — mit rem-Maßen lief der Inhalt je nach
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
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
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
Druck-Export nachweislich als soliden Kasten statt eines weichen Schattens (reproduzierbar geprüft)
— ein Foto-Hintergrund hätte diesen Trick gebraucht, der flache Verlauf braucht ihn erst gar nicht.
**QR-Code-Vendoring:** kein Composer-Paket (Regel: nur `phpmailer/phpmailer`), kein Laufzeit-Dienst
(Regel 1) — stattdessen `vendor-manual/phpqrcode/phpqrcode.php`, 1:1 vendort von
[t0k4rt/phpqrcode](https://github.com/t0k4rt/phpqrcode) (LGPL-3, GD-basiert, kein neues PHP-Modul).
Bewusst `vendor-manual/` statt `vendor/` (das bleibt Composer/phpmailer vorbehalten). Helper
`generate_qr_png()` (`app/helpers.php`) ist die einzige Einbindestelle, fängt die
Parameter-Reihenfolge-Deprecations der Bibliothek (alter PHP-5-Code) lokal per `error_reporting()`
ab — dasselbe Muster wie bei der vendorten Files-Gallery (`_filesconfig.php` → `display_errors = 0`).
Gibt `false` bei Fehlern zurück statt zu werfen: QR ist ein „nice to have" aufs Cover, kein
Show-Stopper für die ganze Ausgabe. Dritter Parameter `$scale` = Pixel pro Modul (Default 6, gut
200px — passend für die A5-Seiten, viel zu klein für Druck).
**Kurz-URL `/stadionzeitung/aktuell` für gedruckte QR-Codes.** Reservierter Slug in der
`stadionzeitung/`-Prefix-Route: leitet auf die neueste **veröffentlichte** Ausgabe weiter
(`stadionzeitung_latest()`), ohne veröffentlichte Ausgabe aufs Archiv. Damit braucht ein Plakat oder
Aushang nur EINEN Code, der über Jahre gilt. Kollision unmöglich, Ausgaben-Slugs beginnen immer mit
dem Datum. Drei Dinge sind dabei load-bearing und dürfen nicht „vereinfacht" werden:
**302, nie 301** (ein 301 wird dauerhaft gecacht — das Plakat zeigte für immer auf die erste Ausgabe,
unreparierbar auf jedem Gerät, das ihn einmal gesehen hat) · `Cache-Control: no-store` ·
`exit` statt `return` (sonst hängt `layout.php` einen HTML-Body an die Weiterleitung).
Nicht in der Sitemap: es ist eine Weiterleitung, keine Seite.
**Gedruckte Adressen: `club.website` statt `base_url`** (seit 31.07.2026). `base_url` ist bewusst
umgebungsabhängig, weil Canonical, OG, Sitemap und JSON-LD zur ausliefernden Adresse passen müssen.
**Gedrucktes kennt kein „lokal":** QR-Codes, die Internet-Zeile im Heft-Impressum, die Cover-Zeile
neben dem QR und die Linkzeilen unter den CTA-QRs der Live-Seiten laufen deshalb über
`club_url()`/`club_domain()` (Quelle: `club.website` in `data/club.json`). Vorher trugen sie
`localhost:8000` ins Druckmaterial und hätten nach dem Produktivgang alle neu erzeugt werden müssen.
Die Domain ist Vereinsidentität, keine Umgebungseinstellung — deshalb `club.json` und nicht `config`.
Fehlt das Feld, fällt `club_url()` auf `abs_url()` zurück (nie ein kaputter Link).
**QR-Codes für Drucksachen: `php bin/qr.php <pfad|url> [ziel.png] [--scale=24]`** (Default nach
`storage/qr/`, gitignored). Getrennt von den Stadionzeitungs-Codes, die
`bin/stadionzeitung-add.php` selbst klein für die A5-Seiten erzeugt; interne Pfade laufen über
`club_url()`, sind also unabhängig von der Umgebung schon richtig.
**Jeden Druck-QR vor der Freigabe einmal mit dem Handy scannen.** Das ist nicht Vorsicht, sondern die
einzige Prüfmöglichkeit: die vendorte Bibliothek arbeitet **nicht reproduzierbar** (dieselbe URL
ergibt unterschiedliche Pixel — anderes Maskenmuster, beides gültig), ein Vergleich zweier PNGs
beweist deshalb nichts. Nachweisbar ist nur der Code-Pfad, nicht der Bildinhalt.
**Live-Seiten wiederverwendbar:** `termine_build_lists()` und `news_build_feed()` (`app/helpers.php`)
sind die einzige Merge-Logik für Termine bzw. News — `/termine`, `/news` UND die
Stadionzeitungs-Live-Seiten rufen dieselbe Funktion auf, nie eine zweite Implementierung.
**Swipe-Viewer** (`app/components/stadionzeitung-viewer.php` +
`assets/js/stadionzeitung-viewer.js`): ohne JS liegen alle Seiten normal gestapelt da (voll lesbar,
sequentiell). Mit JS wird der Track zum horizontalen Swiper — **natives CSS-Scroll-Snap, kein
eigener Touch-Gesten-Code** (im Projekt gibt es dafür kein Vorbild und braucht auch keins). JS liefert
nur Seitenzähler, Pfeiltasten und Vor-/Zurück-Buttons obendrauf.
**Kein Dark Mode auf Stadionzeitungs-Seiten:** jede Seite ist ein bedrucktes A5-Blatt und bleibt
hell — digital wie gedruckt identisch, unabhängig vom sonst dunklen Website-Theme (Ausnahme, nicht
die Regel). `.stadionzeitung-page` (`components.css`) überschreibt dafür lokal dieselben
Custom Properties aus `tokens.css`, die league-table/termine-list/news-feed ohnehin verwenden
(Token-Disziplin) — kein Einzel-Override pro Komponente. **Das Cover ist die eine begründete
Ausnahme von der Ausnahme:** ein fotografisches Magazin-Cover (Verlauf in Vereinsfarben + optional
das freigestellte Spielerfoto), keine weiße Content-Seite — eigene Klasse `.stadionzeitung-cover`,
nicht `.stadionzeitung-page` (Details siehe unten). Bild-, Live- wie Cover-Seiten laufen im Swipe-Viewer auf
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` —
`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
Blattgrenzen umsortieren. Die Seitenzahl braucht dafür ein **Vielfaches von 4** —
`stadionzeitung-add.php` warnt bei Lücken (Regel: beim Erzeugen gemeinsam sauber lösen), die
Druckfassung füllt notfalls mit weißen Leerseiten VOR der Rückseite auf. Auf dem Bildschirm zeigt
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`
(`: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
auftauchen können (Tabelle, Termine-Liste, News-Feed), die Druckvorschau neu prüfen.
## Veranstaltungen (Feste mit eigener Detailseite)
Für jedes Fest eine eigenständige Seite, die über die Zeit wächst: erst nur das Plakat mit der
Ankündigung, dann das Programm, nach dem Fest die Bilder. Übersicht `/veranstaltungen`
(„Was ansteht" oben, „Rückblick" darunter), pro Fest `/veranstaltungen/<slug>` (dynamische
Prefix-Route, siehe „Architektur-Muster"). Quelle ist `data/veranstaltungen.json`.
Bewusst generisch, nicht „Sportfest": Frühschoppen, Weihnachtsfeier und das Jubiläum 2028 laufen
über denselben Mechanismus.
**Abgrenzung zu `/termine` (die eine Regel, die nicht verhandelbar ist):** ein Termin **ohne** eigene
Seite lebt in `data/events.json`, ein Termin **mit** Seite in `data/veranstaltungen.json` —
**niemals in beiden**, sonst steht er doppelt in der Liste und `/termine` baut zwei Event-Knoten für
dasselbe Fest. `bin/preflight.php` prüft Slug-Kollisionen, doppelte Slugs und Pflichtfelder.
`termine_build_lists()` mergt seit dem Umbau **drei** Quellen (Spiele, Events, Veranstaltungen);
`veranstaltungen_load()` ist die einzige Ladestelle und filtert Entwürfe zentral heraus — die
Detailseite lädt bewusst ungefiltert.
**Feldnamen sind Absicht:** `title`, `date_start`, `date_end`, `time`, `location`, `department`,
`text` heißen **exakt wie in `events.json`**. Dadurch laufen `event_schema()` und
`app/components/event-feature.php` (Termin-Karte im Startseiten-Hero) unverändert auf einer
Veranstaltung — kein zweiter Normalizer, kein zweiter Schema-Bauer. `text` bleibt ein **String**
(Teaser für Karte, Meta-Description und Event-Beschreibung), die Absätze stehen in
`ankuendigung[]`/`rueckblick[]`. Nur Veranstaltungen bekommen zusätzlich `@id` und `url` in den
Event-Knoten (`event_schema($v, 'veranstaltungen/<slug>')`), damit `/termine`, die Übersicht und die
Detailseite nachweislich **dasselbe** Event beschreiben.
**Lebenszyklus über das Datum, kein Feld von Hand:** `termin_is_upcoming()` entscheidet
Ankündigung vs. Rückblick — und dieselbe Funktion trennt auf `/termine` kommend von vergangen. Eine
Stelle, deshalb können die Seiten nicht widersprechen. Maßgeblich ist der **letzte** Tag: ein
dreitägiges Fest ist am Samstag noch „kommend" (vorher verglich `/termine` nur `date_start`, ein
laufendes Fest galt ab Tag zwei als vergangen).
**Die Seite bleibt bewusst mager (Beschluss 30.07.2026).** Der Inhalt steht auf den Plakaten, die
Seite erzählt ihn nicht daneben nochmal. Feste Reihenfolge, und **jeder Block erscheint nur, wenn
sein Feld gepflegt ist**:
```
Kopf (Titel, Zeitraum, Ort)
Plakate (Überschrift visually-hidden — Plakate erklären sich selbst)
Galerie (nach dem Fest steht sie VOR den Plakaten, die dann klein als Andenken folgen)
Infoblock veranstaltung-info: links Zeitraum/Beginn/Adresse, rechts die Karte —
NUR solange das Fest aussteht. Hinterher wäre es Doppelung: Datum und
Ort stehen im Kopf, und eine Anfahrtskarte zu einem Fest von letztem
Jahr hilft niemandem. Eine Rückblick-Seite endet mit den Plakaten.
optional abschnitte[] · programm · helfer · partner
```
`ankuendigung`/`rueckblick` sind optional: ohne Text gibt es keine Einleitungssektion.
`abschnitte[].phase` (`ankuendigung` | `rueckblick` | `immer`, Default `immer`) steuert, was wann
steht. Das **Programm als Liste ist die Ausnahme, nicht die Regel** — normalerweise trägt es das
Programmplakat; gepflegt lohnt es nur, wenn die Zeiten auch für Screenreader und Suchmaschinen
lesbar sein sollen (im Bild sind sie es nicht).
**Entwurf/Veröffentlicht** wie bei der Stadionzeitung (`veranstaltung_is_published()`, fehlendes
`status` = veröffentlicht): ein Entwurf fehlt in Übersicht, Termine-Liste, Startseiten-Hero und
Sitemap und trägt `noindex`, ist aber unter seiner direkten URL erreichbar (Gegenlesen ohne
Login-System, „Entwurf"-Badge im Kopf). Der Filter sitzt in `veranstaltungen_load()`, damit auch der
**Stadionzeitungs-Snapshot** keinen unveröffentlichten Termin einfriert — der ist die einzige
Stelle, die sich nicht mehr zurücknehmen lässt.
**Kein Hero auf der Detailseite** (wie bei der Stadionzeitungs-Ausgabe, begründete Ausnahme von der
Hero-Regel): ein Archiv-Eintrag hat kein eigenes Titelbild, und das einzige vorhandene Bild ist ein
Plakat im Hochformat, das als cover-beschnittener Hero-Hintergrund zerstört würde. Stattdessen ein
schmaler Kopf (`.veranstaltung-head`) mit Rückweg, `h1`, Datum/Ort und Entwurf-Badge. Die
**Übersichtsseite** behält den regulären Unterseiten-Hero.
**Plakate sind keine Fotos.** `plakate[]` (Ankündigung, Programm, …) tragen Information und stehen
deshalb weit vorne, im Hochformat, unbeschnitten und zum Vergrößern (`veranstaltung-plakate`).
`galerie[]` sind die Fotos vom Fest und stehen im Rückblick oben (`veranstaltung-galerie`). Das
**erste** Plakat ist das Hauptplakat: es liefert das Kartenbild, das `og_image` und das
`image` im Event-Knoten.
**Karte: statisches Bild, KEIN Leaflet** (geprüft 30.07.2026). Leaflet selbst wäre unkritisch (BSD,
self-hostbar), aber es lädt seine **Kacheln zur Laufzeit von einem fremden Tile-Server** — genau das
verbietet Regel 1, und `img-src 'self' data:` in der CSP würde sie blocken, die Karte bliebe grau.
Stattdessen liefert `app/components/veranstaltung-info.php` das selbst gehostete OSM-Rendering
`img/pages/sportheim-map.jpg` aus, mit **Namensnennung „Kartendaten © OpenStreetMap-Mitwirkende"**
(die ODbL verlangt sie) und einem Klick nach Google Maps — ein **ausgehender Link**, keine
Einbettung: der Browser des Besuchers erreicht Google erst, wenn jemand klickt. Wer echtes Zoomen
will, muss die Kacheln vorproduzieren und selbst ausliefern (Zoom 14 bis 17, ~40 bis 80 PNG); OSM
verbietet dabei das systematische Abziehen ihrer eigenen Kacheln. Zwei Felder steuern den Block:
`karte: "club"` (auf unserem Gelände → Adresse kommt aus `data/club.json`, Karte wird gezeigt; fehlt
das Feld, steht nur der Ortsname und es gibt keine Karte — ein Fest woanders hätte sonst eine falsche
Karte daneben) und `anfahrt[]` (kurze Absätze zum Parken). **Die Adresse nie in die Texte tippen**,
sie kommt aus `club.json`.
**Wiederverwendete Komponenten, keine Neubauten:** `section` (die freien `abschnitte[]`, gleiche Form
wie `home.json` → `sections[]`; ohne Bild läuft sie jetzt einspaltig als `split--plain`) ·
`cta-band` (Helfer-Aufruf, `helfer` wird 1:1 als `band` übergeben) · `partner-grid` (neue Props
`only` = Auswahl über den Partnernamen und `anchor`; passt kein Name, stehen bewusst wieder alle da
statt einer leeren Logo-Wand) · `img`. Neu sind `veranstaltungen-cards`, `veranstaltung-info`,
`veranstaltung-plakate`, `veranstaltung-programm`, `veranstaltung-galerie` und `lightbox-overlay`
(das gemeinsame Overlay-Markup, damit Plakate und Galerie nicht zwei Fassungen davon haben).
**Bilder ausschließlich über `php bin/veranstaltung-bilder.php <slug> <ordner>`** (CLI, Kanal
`veranstaltungen`, Lock, atomarer Schreibvorgang). Erwarteter Ordnerinhalt: `plakate/*` und
`bilder/*` (jeweils natsort = Reihenfolge, das erste Plakat ist das Hauptplakat). Es rendert über
`resize_image_variants()` nach `public/assets/img/veranstaltungen/<slug>/` und schreibt **nur**
`plakate` und `galerie`. **Plakate und Fotos werden dabei unterschiedlich behandelt:** Plakate
`[640, 1000, 1600]` bei Qualität 82, weil sie Text tragen und man in der Großansicht Uhrzeiten lesen
will; Fotos nur `[640, 1200]` bei 78, weil die dritte Stufe bei 40 Fotos über die Hälfte des
Bildvolumens kostet und dort nichts bringt (gemessen an Sportfest 2025: 23 MB gegen 9 MB).
Drei Punkte, die dabei tragen:
- **Der Slug muss schon in `data/veranstaltungen.json` stehen.** Umgekehrt zur Stadionzeitung: der
Eintrag entsteht im Chat, das Skript reichert ihn an. Genau daraus folgt, dass chat-gepflegte
Felder nie verloren gehen — es ändert zwei Felder und rührt den Rest nicht an, auch `status` nicht
(nachgelieferte Fotos dürfen keine veröffentlichte Seite offline nehmen).
- **Der Ordner ist immer der komplette Bestand.** Ein erneuter Lauf ersetzt Plakate und Galerie
vollständig und löscht die alten Dateien. Wer drei Fotos nachliefert, verliert die anderen zwanzig.
Absicht: nur so lassen sich Bilder auch entfernen und umsortieren.
- **Alt-Texte kommen aus dem Chat** (Wir-Form, siehe „Sprache & Ton") und hängen an `source`, dem
Originaldateinamen. Ein neu eingeschobenes Foto verschiebt die Nummerierung, die Alt-Texte folgen
trotzdem ihrem Bild. Neue Fotos bekommen den Veranstaltungstitel als Platzhalter; das Skript nennt
sie am Ende namentlich, damit die Texte in derselben Chat-Runde entstehen.
**Lightbox** (`public/assets/js/lightbox.js`): generischer `[data-lightbox-gallery]`-Treiber, das
dritte JS-Muster neben `carousel.js` und `stadionzeitung-viewer.js`. Ohne JS ist jedes Vorschaubild
ein normaler Link auf die größte Variante der Datei; das Overlay liefert PHP
(`component('lightbox-overlay', …)`, gehört in denselben `[data-lightbox-gallery]`-Container wie die
Links) und ist `hidden`, bis JS es übernimmt. Fokus wandert ins Overlay und kehrt beim Schließen auf
das **zuletzt gezeigte** Vorschaubild zurück, Fokusfalle über die Knöpfe, `Esc` und Pfeiltasten,
`body.lightbox-open` hält den Hintergrund fest. Bei nur einem Bild entfallen Zähler und Vor/Zurück.
Die Großansicht erbt ihr `srcset` vom angeklickten Vorschaubild — so gibt es nur eine Bildliste im
Markup und das Handy lädt die kleine Variante. Scrim-Farbe: `--clr-lightbox-bg`. Sichtbare
Bildunterschriften gibt es bewusst nicht (Beschluss 30.07.2026), der Alt-Text trägt die Beschreibung.
**Falle:** `.lightbox[hidden]` braucht `display: none` mit (0,2,0) — `[hidden]` aus `reset.css` hat
dieselbe Spezifität wie `.lightbox` und verliert, weil `components.css` später lädt; ohne die Regel
liegt das Overlay beim Laden offen über der Seite (einmal passiert).
## News-Artikel (optionale Detailseiten)
Längere Meldungen bekommen eine eigene Seite `/news/<slug>` in Blog-Optik, kurze bleiben eine
Feed-Zeile auf `/news`. Quelle ist weiterhin `data/news.json`, chat-gepflegt, kein Bin-Skript,
kein Cron.
**Der Inhalt entscheidet, nicht der Slug.** `news_has_article()` (`app/helpers.php`) ist die
**einzige** Stelle, die „hat Detailseite" beantwortet (`slug` **und** `artikel` gepflegt) — Feed,
Archiv, Sitemap und `bin/preflight.php` fragen ausschließlich dort. Bewusst nicht am `slug`
festgemacht: den schreibt man beim Anlegen reflexartig mit, sonst entstünden versehentlich Seiten
mit zwei Sätzen Inhalt. Eine Meldung ohne `artikel` liefert unter ihrer URL **404**, nicht eine
leere Seite.
**Felder:** `text` bleibt der Teaser (Feed-Zeile, Meta-Description, `Article.description`) — nicht
in `teaser` umbenennen, `events.json` und `veranstaltungen.json` benutzen dasselbe Wort für dieselbe
Rolle. `artikel[]` ist eine Block-Liste, jeder Block trägt `typ`:
`text` (`absaetze[]`) · `zwischentitel` (`text` → `h2`, die `h1` steht im Kopf) ·
`bild` (`base`/`widths`/`alt`/`caption?`) · `zitat` (`text`/`quelle?`). Ein unbekannter `typ` wird
beim Rendern übersprungen und von `bin/preflight.php` gemeldet — ein Tippfehler soll vor dem Deploy
auffallen, nicht durch einen fehlenden Absatz auf der Seite. Fließtext läuft durch
`inline_links_html()`, interne Deeplinks `[Label](slug)` also erlaubt, rohes HTML nicht.
**Entwurf/Veröffentlicht** wie bei Veranstaltungen und Stadionzeitung
(`news_is_published()`, fehlendes `status` = veröffentlicht): ein Entwurf fehlt in Feed, Archiv,
Article-Schema und Sitemap und trägt `noindex`, ist aber unter seiner direkten URL mit „Entwurf"-Badge
lesbar. Der Filter sitzt in `news_load()` (einzige gefilterte Ladestelle), die Detailseite lädt
ungefiltert über `news_find()`. **Das gilt absichtlich auch für den Stadionzeitungs-Snapshot:** der
zieht seine News aus `news_build_feed()` und friert sie beim Veröffentlichen ein, ist also die eine
Stelle, die sich nicht zurücknehmen lässt.
**Meldungen werden nicht gekappt.** `config('news.max_items')` begrenzt in `news_build_feed()` nur
Instagram und Ergebnisse — die wachsen von allein (Sync 2×/Tag, jedes Wochenende neue Spiele), und
davor schützt die Grenze. Redaktionelle Meldungen stehen immer alle im Feed, sonst wäre ein älterer
Artikel irgendwann von der Website aus nicht mehr verlinkt. **Es gibt bewusst keinen zweiten
Archiv-Abschnitt** dafür (30.07.2026 gebaut und am selben Tag verworfen): bei wenigen Artikeln pro
Jahr listete er nur ein zweites Mal, was oben schon stand.
**Weiterlesen-Zeile im Feed:** `news_build_feed()` liefert `link_text`, `news-feed.php` rendert es —
die Komponente leitet **nichts** aus `source` ab, sie soll die Quellen nicht kennen (gleiche Prop wie
in `upcoming_highlights()`). Sonst brauchte `news-feed.php` keinen Eingriff: die `<a>`-Variante samt
Hover gab es für Instagram und Ergebnisse schon.
**Zwei geteilte Bausteine, entstanden mit dieser Seite:**
`app/components/detail-head.php` — der schmale Kopf (Rückweg, `h1`, Entwurf-Badge, Datum/Ort) für
Veranstaltungs- **und** News-Detailseite, Klassen `.detail-head*`. Der Kopf der
Stadionzeitungs-Ausgabe bleibt bewusst eine eigene Fassung (Viewer-Chrome: Drei-Zonen-Grid, Titel als
`<p>`, Druck-Knopf, eigene `@media print`-Regeln).
`.prose` in `components.css` — die Lesespalte (72ch) samt Typografie für langen Fließtext, genutzt
vom Artikelkörper **und** von `.legal__body` (Impressum, Datenschutz tragen die Klasse zusätzlich).
**Bilder** über `php bin/img-resize.php` wie überall, Zielordner `public/assets/img/news/`. Für neue
Artikel `[640, 1000, 1600]` erzeugen (das Bestandsbild `neue-website` hat nur 640/1000, weil das
Original nicht mehr vorliegt und `resize_image_variants()` nie hochskaliert). Kein
`bin/news-bilder.php`: News-Bilder sind Einzelstücke, kein Ordnerbestand wie Plakate und
Festgalerien.
## Dateiablage /dateien (Files Gallery)
Damit der Verein Fotos und Videos von Events selbst abliefern kann: `https://…/dateien/` ist eine
vendorte Fremd-App (**Files Gallery**, Vollversion-Lizenz, `public/dateien/index.php`) — die benannte
Ausnahme zu Regel 3. Zwei Zugänge: **`verein`** (hochladen, Ordner anlegen, herunterladen — bewusst
kein Löschen/Umbenennen, damit ein Fehlklick am gemeinsamen Konto nicht die Fotos anderer trifft) und
**`admin`** (alles außer `allow_settings`). Nicht in der Navigation, nicht in der Sitemap,
`Disallow` in `robots.txt`, `noindex` per Header und Meta. Das Einarbeiten der Bilder in die Seite
bleibt Chat-Arbeit (`bin/img-resize.php`).
**Drei Entscheidungen tragen den Aufbau — keine davon anfassen, ohne den Grund zu kennen:**
1. **`root` und `storage_path` liegen außerhalb des Docroots** (`storage/dateien/uploads` bzw.
`…/system`), dazu `load_files_proxy_php`. Die App lässt per Default **jede** Dateiendung zum Upload
zu (keine Blacklist, `index.php:4078-4092`); liegt das Verzeichnis nicht im Web-Baum, ist eine
hochgeladene `.php` prinzipiell nicht ausführbar und alles läuft durch PHP (`index.php:1607`).
`upload_allowed_file_types` ist die zweite Schicht, nicht die erste.
2. **`public/dateien/_filesconfig.php` ist der einzige Integrationspunkt.** Sie wird als erstes geladen
(`index.php:142`), gewinnt gegen die selbst generierte Storage-Config (`index.php:239`) und läuft
noch vor `session_start()` und Login — deshalb stehen dort auch Session-Härtung, das Login-Rate-Limit
(`rate_limit_ok()`, Log nach `spam.log`) und `display_errors = 0` (die App ist PHP-7-Code; eine
Deprecation-Notice landet sonst mitten im Bild-Stream und zerstört Thumbnails).
**Das Vereins-Konto muss das globale `username`/`password` der App bleiben:** ohne globales Passwort
sind Konten unter `system/users/` nur *zusätzliche* Logins und die Ablage wäre offen lesbar
(`index.php:328-337`).
3. **Assets self-hosted** unter `public/assets/filesgallery/` (Regel 1 — die App lädt sonst CSS und 14
Scripts von jsDelivr). Die Pfade enthalten die Version, ein Update muss sie also mitziehen.
**Uploadgrenze an zwei Stellen, es gilt die kleinere:** `upload_max_filesize` in
`_filesconfig.php` (250 MB, auf Videoclips gerechnet) und die PHP-Grenzen aus
`public/dateien/.user.ini`. `.user.ini` statt `.htaccess`, weil All-Inkl PHP als FastCGI/FPM fährt
(`php_value` wäre dort wirkungslos). Lokal übernehmen das die `-d`-Schalter im Dev-Kommando.
`bin/preflight.php` vergleicht beide Werte und warnt, wenn PHP niedriger steht.
Erlaubt sind Bilder (inkl. HEIC), `mp4/mov/m4v/webm`, PDF und `svg`. Video-Vorschaubilder brauchen
`ffmpeg` auf dem Server — fehlt es, funktionieren Upload und Wiedergabe trotzdem, es gibt nur ein Icon.
SVG hat keine Vorschau (GD kann sie nicht rastern), wird aber im Listing geführt.
**SVG ist der einzige erlaubte Typ, der Code tragen kann** (`<script>`, `onload`) — und die App liefert
Dateien inline aus (`index.php:781`). Deshalb zwei Schichten, beide gegen echtes Apache 2.4 bzw. über
die App getestet: (1) `_filesconfig.php` prüft jeden SVG-Upload auf Skript, Event-Attribute,
`<!ENTITY>`, `<foreignObject>` und Verweise auf fremde Hosts und weist ihn ab — ein Inkscape-Export
mit Metadaten, internem `<use href="#…">` und eingebettetem `data:`-Bild geht durch; (2)
`public/dateien/.htaccess` setzt für SVG-Antworten `Content-Security-Policy: sandbox` (nur für SVG,
damit die PDF-Vorschau des Browsers weiter funktioniert). Wer die Endungsliste ändert, muss beides
mitziehen.
**Ordnerstruktur: Jahr → Abteilung** (`2026/Fußball`, `…/Jugend`, `…/Turnen`,
`…/Feste und Aktionen`), angelegt mit `php bin/dateien-init.php [Jahr]` — idempotent, im Januar für
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).
**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
`php bin/filesgallery-update.php`** (holt App + passende Assets, setzt den Patch, `--check` prüft nur);
`php bin/preflight.php` prüft dasselbe vor jedem Deploy. `index.php` **niemals** in `install.php`
umbenennen (erzwingt `allow_settings`, und das Settings-UI schreibt und includet geliefertes PHP).
Optik über die beiden Hooks der App: `storage/dateien/system/include/head.html` (lädt `tokens.css` +
`public/assets/css/dateien.css`) und `…/include/footer.html`. Die `css/custom.css`-Hooks der App
funktionieren hier **nicht** — sie brauchen eine öffentliche URL, unser `storage_path` hat keine
(`index.php:717`). `dateien.css` setzt fast nur Variablen der App (`--hue`, `--hsl-primary-*`,
`--font-family`), damit ein Update nichts zerreißt. Die eigene CSP für diesen Pfad steht in
`public/dateien/.htaccess` (braucht `'unsafe-inline'` für Scripts/Styles: die App interpoliert
Inline-Code per PHP und kennt keine Nonces) — sie gilt nur dort, die Seiten behalten die strikte.
## Chronik 2028 (Materialsammlung fürs Jubiläum)
Sammelt Fundstücke für die Vereinschronik zum 120-jährigen Bestehen. **Reine Recherche-Werkzeuge,
kein Website-Code** — nichts davon läuft im Request-Pfad, die Seite kennt die Chronik nicht.
Der **öffentliche Zulauf** dazu ist die Sektion `contribute` auf `/historie` (Bild/Text-Sektion nach
der Timeline) mit einem `mailto:`-CTA auf `club.email_historie`. Bewusst kein Formular: das Material
sind Dateien, und Anhänge kann keines unserer Formulare annehmen. Was per Mail eintrifft, wird mit
`php bin/chronik-add.php --datei=<pfad>` aufgenommen — die Freigabe zur Veröffentlichung fragt die
Sektion mit ab, `app/pages/datenschutz.php` beschreibt Archivierung und Veröffentlichung getrennt.
**Der Rückweg vom Register in die Timeline:** belegte Ereignisse dürfen als Station in
`data/historie.json` — `image` ist im `timeline`-Component optional, Stationen ohne Foto rendern als
Notiz. Genau dafür ist das da: Von Presse-Fundstücken sind die **Fakten** frei (Jahr, Liga, Ergebnis,
Torschütze), das **Foto** nicht — es gehört dem Verlag und darf nicht mit. Vor dem Eintragen die
Jahreszahl im Register gegenprüfen: der Aufstieg in die Kreisliga war **2019**, 2018 ist das Jahr der
verlorenen Relegation (0:3 gegen Kirchahorn) — beides lag im Verein schon vertauscht in Erinnerung.
| Was | Wo |
|---|---|
| Fundstücke (Bilder, Textbelege, Scans) | `storage/dateien/uploads/Vereinshistorie/` (gitignored) |
| Register — eine Zeile pro Fundstück | `docs/chronik/register.csv` (im Repo, braucht Versionsgeschichte) |
| Rechercheplan: was ist durchsucht, was fehlt | `docs/chronik/quellenplan.md` |
| Gemeinsame Logik der Skripte | `bin/chronik-lib.php` (CLI-only, bewusst **nicht** in `app/helpers.php`) |
- **Ordner anlegen:** `php bin/chronik-init.php` — Jahrzehnt-Bündel (`1908-1929` … `2010-2028`),
`Festschriften-und-Dokumente/`, `Undatiert/`, dazu `00_LIESMICH.txt` und den Entwurf der
Verlagsanfrage. Idempotent, muss wie `dateien-init.php` **auf dem Server** laufen (NFD/NFC).
- **Fundstück aufnehmen:** `php bin/chronik-add.php <URL>` bzw. `--liste=urls.txt` (Zeilen
`URL | Datum | Titel`) oder `--datei=<pfad>` für Scans und schon vorhandene Dateien.
`--bericht` zeigt Bestand und Lücken pro Zeitraum, `--rechte` schreibt die Rechteliste neu.
- **Der Dateiname trägt die Metadaten**, weil die Files Gallery keine Felder dafür hat:
`1972-07-15_Einweihung-Sportheim_BR.jpg`. Datum vorn → alphabetisch = chronologisch,
unbekannte Teile als `00`. Kollisionen bekommen `-2`, `-3` (sonst überschreibt Fund zwei Fund eins).
- **Die Rechtelage entscheidet, wie viel Text bleibt** (`chronik_rights()` ordnet sie dem Host zu):
eigenes Vereinsmaterial wird im Volltext gesichert — die früheren Vereinsseiten
`tsv1908kulmbach.de` und `turneninkulmbach.info` sind offline, ihre Inhalte existieren nur noch
im Internet Archive. Fremde Presseartikel nur als Nachweis (Überschrift, Datum, Anriss, URL).
Zu jedem Fund entsteht eine `.quelle.txt`; was freigegeben werden muss, sammelt
`Vereinshistorie/00_RECHTE-ZU-KLAEREN.txt` (generiert, nicht von Hand pflegen — stattdessen
Spalte `klaeren` im Register auf `nein` setzen und `--rechte` laufen lassen).
- Neue Quelle → Eintrag in `chronik_rights()` **und** `chronik_source_tag()` in `chronik-lib.php`.
Ohne Eintrag landet der Fund auf „Ungeklärt".
- **Verwechslungsgefahr:** In Kulmbach gibt es auch **ATS Kulmbach** (Bayernliga 1981/82) und
**1. KSC Kulmbach**. Wikipedia-Bayernliga-Treffer betreffen den ATS, nicht uns. Jeder Fund muss
den Vereinsnamen wörtlich enthalten.

View File

@@ -3,13 +3,18 @@
declare(strict_types=1);
/**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden.
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…)
* oder JSON bei fetch (form.js). Das Formular kommt von / wie auch von /kontakt.
* POST /kontakt-senden — Kontaktformular validieren und über All-Inkl-SMTP versenden.
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…),
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
* Das Formular kommt von / wie auch von /kontakt und /turnen.
*
* Eine Action für alle Varianten: `department` (Hidden-Feld) wählt über
* contact_variant() Auswahlliste, Feldregeln und Empfänger. Eine zweite Action
* für die Turnabteilung hätte den kompletten Spam-Schutz verdoppelt — und damit
* eine zweite Stelle, die man bei jeder Änderung mitziehen müsste.
*/
use PHPMailer\PHPMailer\PHPMailer;
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
// Rücksprungziel gegen die Slug-Whitelist (Single Source of Truth) prüfen — kein Open-Redirect.
@@ -34,6 +39,12 @@ $respond = static function (bool $ok, string $error = '') use ($wantsJson, $retu
$route = 'kontakt';
$token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (!config_ready($route)) {
$respond(false, 'mail');
}
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') {
log_spam($route, 'honeypot');
@@ -44,87 +55,112 @@ if (!form_token_valid($token)) {
$respond(true);
}
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Validierung ---
// --- Eingaben ---
$name = $field('name');
$email = $field('email');
$phone = $field('phone');
$interest = $field('interest');
$subject = $field('subject');
$message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$valid = $name !== '' && mb_strlen($name) <= 200
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& mb_strlen($phone) <= 50
&& in_array($interest, $interests, true)
&& $subject !== '' && mb_strlen($subject) <= 200
&& $message !== '' && mb_strlen($message) <= 5000
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $name . $subject);
if (!$valid) {
$respond(false, 'validation');
// Variante bestimmen (unbekannter Wert fällt auf die allgemeine zurück, siehe
// contact_variant()) — dieselbe Quelle, aus der die Komponente ihr Formular baut.
$variant = contact_variant($field('department'));
$interests = $variant['interests'];
if (!$variant['interest_required']) {
$interests[] = ''; // die allgemeine Variante darf das Feld leer lassen
}
if (!$variant['ask_subject']) {
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
}
// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) ---
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
if ($name === '' || mb_strlen($name) > 200 || preg_match('/[\r\n]/', $name)) {
$errors['contact-name'] = 'Bitte gib deinen Namen an (max. 200 Zeichen).';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['contact-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if (mb_strlen($phone) > 50) {
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
}
if (!in_array($interest, $interests, true)) {
$errors['contact-interest'] = $variant['interest_required']
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
: 'Bitte wähle einen der angebotenen Bereiche.';
}
if ($variant['ask_subject'] && ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject))) {
$errors['contact-subject'] = 'Bitte gib einen Betreff an (max. 200 Zeichen, einzeilig).';
}
if (($variant['message_required'] && $message === '') || mb_strlen($message) > 5000) {
$errors['contact-message'] = 'Bitte schreib uns eine Nachricht (max. 5000 Zeichen).';
}
if (!$privacy) {
$errors['contact-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir die Anfrage nicht entgegennehmen.';
}
if ($errors !== []) {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
exit;
}
// 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' : '',
], $errors);
}
// --- Inhalts-Heuristik (still abweisen, Bot-typisch) ---
if (preg_match_all('~https?://~i', $subject . ' ' . $message) > 2) {
log_spam($route, 'links');
$respond(true);
}
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) {
log_spam($route, 'daily-cap');
$respond(true);
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
log_spam($route, 'replay');
$respond(true);
$respond(false, 'ratelimit');
}
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
if (!rate_limit_ok('daily', 200, 86400)) {
log_spam($route, 'daily-cap');
$respond(false, 'busy');
}
// --- Versand ---
$smtp = config('smtp');
// Ohne Betreff-Feld trägt die Auswahl den Mail-Betreff.
$mailSubject = $variant['subject_prefix'] . ': ' . ($subject !== '' ? $subject : $interest);
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
$body = $variant['context'] . " über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Name: {$name}\n"
. "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
. "Betreff: {$subject}\n"
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
. str_repeat('-', 40) . "\n\n"
. $message . "\n\n"
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
. str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$sent = send_mail([
'context' => $variant['context'],
'subject' => $mailSubject,
'body' => $body,
'reply_to' => ['email' => $email, 'name' => $name],
// Leer heißt: Standardempfänger aus config('smtp.to') (send_mail()).
'to' => $variant['to'] !== '' ? $variant['to'] : null,
]);
$mail->setFrom($smtp['from'], $smtp['from_name']);
$mail->addAddress($smtp['to']);
$mail->addReplyTo($email, $name);
$mail->Subject = 'Kontaktanfrage: ' . $subject;
$mail->Body = $body;
$mail->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail');
}
$respond(true);
$respond($sent, $sent ? '' : 'mail');

View File

@@ -6,12 +6,11 @@ declare(strict_types=1);
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…)
* oder JSON bei fetch (form.js).
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…),
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
*/
use PHPMailer\PHPMailer\PHPMailer;
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
@@ -29,6 +28,12 @@ $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
$route = 'mitglied-werden';
$token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (!config_ready($route)) {
$respond(false, 'mail');
}
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') {
log_spam($route, 'honeypot');
@@ -39,12 +44,6 @@ if (!form_token_valid($token)) {
$respond(true);
}
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Eingaben ---
$firstName = $field('first_name');
$lastName = $field('last_name');
@@ -55,6 +54,8 @@ $zip = $field('zip');
$city = $field('city');
$email = $field('email');
$feeClass = $field('fee_class');
$statute = ($_POST['statute'] ?? '') === '1';
$privacy = ($_POST['privacy'] ?? '') === '1';
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
$data = json_load('mitglied-werden');
@@ -63,39 +64,84 @@ $feeOptions = array_map(
$data['fussball']['fees']['rows'] ?? []
);
// --- Validierung (Whitelist-Prinzip) ---
$valid = $firstName !== '' && mb_strlen($firstName) <= 100
&& $lastName !== '' && mb_strlen($lastName) <= 100
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1
&& $phone !== '' && mb_strlen($phone) <= 50
&& $street !== '' && mb_strlen($street) <= 200
&& $zip !== '' && mb_strlen($zip) <= 10
&& $city !== '' && mb_strlen($city) <= 100
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& in_array($feeClass, $feeOptions, true)
&& ($_POST['statute'] ?? '') === '1'
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $firstName . $lastName . $city . $street);
if (!$valid) {
$respond(false, 'validation');
// Geburtsdatum: Format, echter Kalendertag und plausibler Zeitraum.
$birthValid = false;
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthDate, $m) === 1) {
$birthValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
&& $birthDate <= date('Y-m-d')
&& (int) $m[1] >= (int) date('Y') - 120;
}
// --- Aufkommens-Heuristiken (still abweisen) ---
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) {
log_spam($route, 'daily-cap');
$respond(true);
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
if ($firstName === '' || mb_strlen($firstName) > 100 || preg_match('/[\r\n]/', $firstName)) {
$errors['mm-first-name'] = 'Bitte gib den Vornamen an (max. 100 Zeichen).';
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
if ($lastName === '' || mb_strlen($lastName) > 100 || preg_match('/[\r\n]/', $lastName)) {
$errors['mm-last-name'] = 'Bitte gib den Nachnamen an (max. 100 Zeichen).';
}
if (!$birthValid) {
$errors['mm-birth-date'] = 'Bitte gib ein gültiges Geburtsdatum an.';
}
if ($phone === '' || mb_strlen($phone) > 50) {
$errors['mm-phone'] = 'Bitte gib eine Telefonnummer an (max. 50 Zeichen).';
}
if ($street === '' || mb_strlen($street) > 200 || preg_match('/[\r\n]/', $street)) {
$errors['mm-street'] = 'Bitte gib Straße und Hausnummer an.';
}
if ($zip === '' || mb_strlen($zip) > 10 || preg_match('/[^\p{L}\d \-]/u', $zip)) {
$errors['mm-zip'] = 'Bitte gib eine gültige Postleitzahl an.';
}
if ($city === '' || mb_strlen($city) > 100 || preg_match('/[\r\n]/', $city)) {
$errors['mm-city'] = 'Bitte gib den Wohnort an (max. 100 Zeichen).';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['mm-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if (!in_array($feeClass, $feeOptions, true)) {
$errors['mm-fee-class'] = 'Bitte wähle eine Beitragsklasse aus der Liste.';
}
if (!$statute) {
$errors['mm-statute'] = 'Ohne Anerkennung der Satzung ist keine Mitgliedschaft möglich.';
}
if (!$privacy) {
$errors['mm-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir den Antrag nicht entgegennehmen.';
}
if ($errors !== []) {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
exit;
}
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
render_form_invalid($route, [
'first_name' => $firstName, 'last_name' => $lastName, 'birth_date' => $birthDate,
'phone' => $phone, 'street' => $street, 'zip' => $zip, 'city' => $city,
'email' => $email, 'fee_class' => $feeClass,
'statute' => $statute ? '1' : '', 'privacy' => $privacy ? '1' : '',
], $errors);
}
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anträge. ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
log_spam($route, 'replay');
$respond(true);
$respond(false, 'ratelimit');
}
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
if (!rate_limit_ok('daily', 200, 86400)) {
log_spam($route, 'daily-cap');
$respond(false, 'busy');
}
// --- Versand ---
$smtp = config('smtp');
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Vorname: {$firstName}\n"
@@ -109,49 +155,31 @@ $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. "Satzung anerkannt: ja\n"
. "Datenschutz: ja\n"
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
. "Hinweis: Bankdaten werden online nicht erhoben Beitrittserklärung\n"
. "Hinweis: Bankdaten werden online nicht erhoben. Beitrittserklärung\n"
. "per Post zur Unterschrift zusenden.\n";
$buildMailer = static function () use ($smtp): PHPMailer {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
return $mail;
};
// Mail 1 (intern) — erfolgskritisch.
try {
$mail = $buildMailer();
$mail->setFrom($smtp['from'], $smtp['from_name']);
$mail->addAddress($smtp['to']);
$mail->addReplyTo($email, $firstName . ' ' . $lastName);
$mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName;
$mail->Body = $body;
$mail->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$sent = send_mail([
'context' => 'Mitgliedsantrag',
'subject' => 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName,
'body' => $body,
'reply_to' => ['email' => $email, 'name' => $firstName . ' ' . $lastName],
]);
if (!$sent) {
$respond(false, 'mail');
}
// Mail 2 (Eingangsbestätigung an Antragsteller) — optional, Fehler nicht an Besucher leaken.
// Mail 2 (Eingangsbestätigung an den Antragsteller) — optional, Fehler nur ins Log.
$welcome = $data['welcome_mail'] ?? null;
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
try {
$confirm = $buildMailer();
$confirm->setFrom($smtp['from'], $smtp['from_name']);
$confirm->addAddress($email, $firstName . ' ' . $lastName);
$confirm->Subject = $welcome['subject'];
$confirm->Body = $welcome['body'];
$confirm->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
}
send_mail([
'context' => 'Bestätigungsmail Mitgliedsantrag',
'to' => $email,
'to_name' => $firstName . ' ' . $lastName,
'subject' => (string) $welcome['subject'],
'body' => (string) $welcome['body'],
]);
}
$respond(true);

View File

@@ -3,12 +3,13 @@
declare(strict_types=1);
/**
* POST /sportheimbuchung-senden — Sportheim-Buchungsanfrage validieren und via Brevo SMTP versenden.
* Antwort: PRG-Redirect zu /sportheimbuchung#buchung (?sent=1 | ?error=…).
* POST /sportheimbuchung-senden — Sportheim-Buchungsanfrage validieren und über
* All-Inkl-SMTP versenden.
* Antwort: PRG-Redirect zu /sportheimbuchung#buchung (?sent=1 | ?error=…),
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
*/
use PHPMailer\PHPMailer\PHPMailer;
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
@@ -26,6 +27,12 @@ $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
$route = 'sportheimbuchung';
$token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (!config_ready($route)) {
$respond(false, 'mail');
}
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') {
log_spam($route, 'honeypot');
@@ -36,13 +43,7 @@ if (!form_token_valid($token)) {
$respond(true);
}
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Validierung ---
// --- Eingaben ---
$name = $field('name');
$email = $field('email');
$phone = $field('phone');
@@ -50,43 +51,86 @@ $eventType = $field('event_type');
$eventDate = $field('event_date');
$attendees = $field('attendees');
$message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
$validTypes = ['', 'Geburtstagsfeier', 'Party', 'Vereinsveranstaltung', 'Weihnachtsfeier', 'Firmenveranstaltung', 'Sonstiges'];
// Veranstaltungsarten aus derselben Quelle wie das Formular (data/sportheimbuchung.json).
$validTypes = json_load('sportheimbuchung')['form']['event_types'] ?? [];
$valid = $name !== '' && mb_strlen($name) <= 200
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& mb_strlen($phone) <= 50
&& in_array($eventType, $validTypes, true)
&& ($eventDate === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $eventDate))
&& ($attendees === '' || (ctype_digit($attendees) && (int) $attendees >= 1 && (int) $attendees <= 500))
&& mb_strlen($message) <= 3000
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $name);
if (!$valid) {
$respond(false, 'validation');
// Wunschdatum: echter Kalendertag, nicht in der Vergangenheit, höchstens 3 Jahre voraus.
$dateValid = false;
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $eventDate, $m) === 1) {
$dateValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
&& $eventDate >= date('Y-m-d')
&& $eventDate <= date('Y-m-d', strtotime('+3 years'));
}
// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) ---
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
if ($name === '' || mb_strlen($name) > 200 || preg_match('/[\r\n]/', $name)) {
$errors['buch-name'] = 'Bitte gib deinen Namen an (max. 200 Zeichen).';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['buch-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if (mb_strlen($phone) > 50) {
$errors['buch-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
}
if ($eventType !== '' && !in_array($eventType, $validTypes, true)) {
$errors['buch-type'] = 'Bitte wähle eine der angebotenen Veranstaltungsarten.';
}
if (!$dateValid) {
$errors['buch-date'] = 'Bitte gib ein Wunschdatum an (heute oder später).';
}
if (!ctype_digit($attendees) || (int) $attendees < 1 || (int) $attendees > 500) {
$errors['buch-attendees'] = 'Bitte gib die ungefähre Personenzahl an (1 bis 500).';
}
if (mb_strlen($message) > 3000) {
$errors['buch-message'] = 'Der Text ist zu lang (max. 3000 Zeichen).';
}
if (!$privacy) {
$errors['buch-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir die Anfrage nicht entgegennehmen.';
}
if ($errors !== []) {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
exit;
}
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
render_form_invalid($route, [
'name' => $name, 'email' => $email, 'phone' => $phone, 'event_type' => $eventType,
'event_date' => $eventDate, 'attendees' => $attendees, 'message' => $message,
'privacy' => $privacy ? '1' : '',
], $errors);
}
// --- Inhalts-Heuristik (still abweisen, Bot-typisch) ---
if (preg_match_all('~https?://~i', $message) > 2) {
log_spam($route, 'links');
$respond(true);
}
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) {
log_spam($route, 'daily-cap');
$respond(true);
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
log_spam($route, 'replay');
$respond(true);
$respond(false, 'ratelimit');
}
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
if (!rate_limit_ok('daily', 200, 86400)) {
log_spam($route, 'daily-cap');
$respond(false, 'busy');
}
// --- Versand ---
$smtp = config('smtp');
$dateFormatted = $eventDate !== '' ? date('d.m.Y', strtotime($eventDate)) : '';
$dateFormatted = date('d.m.Y', (int) strtotime($eventDate));
$body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
@@ -95,33 +139,17 @@ $body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($eventType !== '' ? "Veranstaltungstyp: {$eventType}\n" : '')
. "Gewünschtes Datum: {$dateFormatted}\n"
. ($attendees !== '' ? "Personenzahl: {$attendees}\n" : '')
. "Personenzahl: {$attendees}\n"
. str_repeat('-', 40) . "\n\n"
. ($message !== '' ? $message . "\n\n" : '')
. str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$sent = send_mail([
'context' => 'Sportheimbuchung',
'subject' => 'Sportheimbuchung: ' . ($eventType !== '' ? $eventType : 'Anfrage') . ', ' . $dateFormatted,
'body' => $body,
'reply_to' => ['email' => $email, 'name' => $name],
]);
$mail->setFrom($smtp['from'], $smtp['from_name']);
$mail->addAddress($smtp['to']);
$mail->addReplyTo($email, $name);
$mail->Subject = 'Sportheimbuchung: ' . ($eventType !== '' ? $eventType : 'Anfrage') . ', ' . $dateFormatted;
$mail->Body = $body;
$mail->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Sportheimbuchung-Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail');
}
$respond(true);
$respond($sent, $sent ? '' : 'mail');

View File

@@ -98,7 +98,7 @@ $total = count($banners);
<?php if ($total > 1): ?>
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
<div class="ad-banner__controls" data-carousel-controls hidden>
<button class="ad-banner__nav-btn" type="button" data-carousel-prev aria-label="Vorherige Anzeige">
<button class="btn btn--icon ad-banner__nav-btn" type="button" data-carousel-prev aria-label="Vorherige Anzeige">
<?= icon('chevron-left') ?>
</button>
<div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen">
@@ -108,11 +108,11 @@ $total = count($banners);
</button>
<?php endforeach; ?>
</div>
<button class="ad-banner__nav-btn" type="button" data-carousel-next aria-label="Nächste Anzeige">
<button class="btn btn--icon ad-banner__nav-btn" type="button" data-carousel-next aria-label="Nächste Anzeige">
<?= icon('chevron-right') ?>
</button>
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
<button class="ad-banner__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
<button class="btn btn--icon ad-banner__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
<?= icon('pause-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--pause') ?>
<?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>

View File

@@ -9,6 +9,11 @@ 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.
* $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
* Betreff, weil die Disziplin-Auswahl ihn ersetzt. Bewusst dieselbe Komponente
* und dieselbe Action: ein zweites Formular hieße ein zweiter Spam-Schutz.
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
@@ -17,23 +22,29 @@ $club = json_load('club');
$return = $return ?? '';
$intro = $intro ?? null;
$interests = ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$variant = contact_variant((string) ($department ?? ''));
$interests = $variant['interests'];
// Sichtbare Direkt-Adresse: bei einer Abteilungsanfrage die der Abteilung.
$directMail = $variant['to'] !== '' ? $variant['to'] : $club['email'];
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
// serverseitigen Feldfehler aus der Action.
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $directMail . '.',
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $directMail . '.',
];
$f = [
'name' => ['type' => 'text', 'name' => 'name', 'id' => 'contact-name', 'label' => 'Name', 'required' => true, 'autocomplete' => 'name', 'maxlength' => 200],
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests],
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
'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' => true, 'maxlength' => 5000, 'rows' => 6],
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
'privacy' => [
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true,
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
@@ -47,11 +58,12 @@ $f = [
<div class="contact__intro">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
</div>
<?php endif; ?>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
<?php if ($sent): ?>
<p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p>
<?php elseif ($error !== null): ?>
@@ -69,7 +81,11 @@ $f = [
<?php component('form-field', ['field' => $f['interest']]); ?>
</div>
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
worum es geht, und die Action baut den Mail-Betreff daraus. */ ?>
<?php if ($variant['ask_subject']): ?>
<?php component('form-field', ['field' => $f['subject']]); ?>
<?php endif; ?>
<?php component('form-field', ['field' => $f['message']]); ?>
<?php component('form-field', ['field' => $f['privacy']]); ?>
@@ -80,6 +96,7 @@ $f = [
</div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<input type="hidden" name="return" value="<?= e($return) ?>">
<input type="hidden" name="department" value="<?= e($variant['department']) ?>">
<p class="form__submit">
<button class="btn" type="submit">Nachricht senden</button>

View File

@@ -5,11 +5,16 @@ declare(strict_types=1);
/**
* CTA-Button. Props: $cta array{label: string, slug: string,
* style?: 'primary'|'outline', size?: 'sm'|'lg'}
* Alternativ zu 'slug': 'href' für nicht-Seiten-Ziele (mailto:, tel:) — gewinnt
* gegen slug/hash. Externe http-Ziele gehören hier nicht rein (siehe Regel 1).
*/
$style = $cta['style'] ?? 'primary';
$size = $cta['size'] ?? null;
$class = 'btn'
. ($style === 'outline' ? ' btn--outline' : '')
. ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : ''));
$href = !empty($cta['href'])
? $cta['href']
: url($cta['slug']) . (!empty($cta['hash']) ? '#' . $cta['hash'] : '');
?>
<a class="<?= e($class) ?>" href="<?= e(url($cta['slug'])) . (!empty($cta['hash']) ? '#' . e($cta['hash']) : '') ?>"><?= e($cta['label']) ?></a>
<a class="<?= e($class) ?>" href="<?= e($href) ?>"><?= e($cta['label']) ?></a>

View File

@@ -5,12 +5,16 @@ declare(strict_types=1);
/**
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
* Props:
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor}]}
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor, image?}]}
* $anchor string (optional, Sektions-ID)
* $title_hidden bool — Titel nur für Screenreader ausgeben (hält die Heading-Kette
* h1 → h2 → h3 intakt, wenn die Karten optisch für sich sprechen)
* $item['image'] optional: {base, widths, alt} — Bildband oben, wie team-cards.
* Bewusst eigenes <a href="#…">-Markup (nicht die cta-Komponente): cta() schickt
* jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen.
*/
$anchor = $anchor ?? null;
$title_hidden = !empty($title_hidden);
if (empty($choice['items'])) {
return;
}
@@ -18,7 +22,7 @@ if (empty($choice['items'])) {
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
<div class="container">
<?php if (!empty($choice['title'])): ?>
<h2 class="department-choice__title"><?= e($choice['title']) ?></h2>
<h2 class="<?= $title_hidden ? 'visually-hidden' : 'department-choice__title' ?>"><?= e($choice['title']) ?></h2>
<?php endif; ?>
<?php if (!empty($choice['intro'])): ?>
<p class="department-choice__intro"><?= e($choice['intro']) ?></p>
@@ -26,12 +30,19 @@ if (empty($choice['items'])) {
<ul class="department-choice__grid" role="list">
<?php foreach ($choice['items'] as $item): ?>
<li class="department-choice__card">
<?php if (!empty($item['image']['base'])): ?>
<div class="department-choice__media">
<?php component('img', ['image' => $item['image'], 'sizes' => '(max-width: 600px) 100vw, 50vw', 'class' => 'department-choice__img']); ?>
</div>
<?php endif; ?>
<div class="department-choice__body">
<h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
<?php if (!empty($item['subtitle'])): ?>
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
<?php endif; ?>
<p class="department-choice__text"><?= e($item['text']) ?></p>
<a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
</div>
</li>
<?php endforeach; ?>
</ul>

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* Schmaler Kopf einer Detailseite (statt hero): Rückweg, h1, optionaler
* Entwurf-Badge, Meta-Zeile. Props:
* $back array{href, label} — Rückweg auf die Übersicht (Pflicht)
* $title string — die h1 der Seite (Pflicht)
* $badge string (optional) — Auszeichnung neben dem Titel, z. B. 'Entwurf'
* $date_iso string (optional) — für <time datetime>
* $date_text string (optional) — sichtbares Datum (german_date()/german_date_range())
* $location string (optional) — Ort, erscheint mit Nadel-Icon hinter dem Datum
*
* Gemeinsam genutzt von der Veranstaltungs- und der News-Artikel-Seite. Beide
* haben bewusst KEINEN hero: ein Archiv-Eintrag hat kein eigenes Titelbild, und
* beim News-Artikel trägt der Aufmacher im Artikelkörper das Bild (Blog-Optik)
* statt eines cover-beschnittenen Hintergrunds.
*
* Der Kopf der Stadionzeitungs-Ausgabe (.stadionzeitung-issue-head) bleibt
* bewusst eine eigene Fassung und wird hier NICHT mitbedient: er ist
* Viewer-Chrome, kein Artikelkopf — Drei-Zonen-Grid, Titel als <p> (die
* Seitenbilder tragen die Ausgabe), Druck-Knopf, zwei Badge-Varianten und zwei
* eigene @media-print-Regeln. Ihn in dieselbe Komponente zu zwingen, hieße eine
* Prop-Matrix für zwei Layouts zu bauen.
*/
$back = $back ?? [];
$title = (string) ($title ?? '');
$badge = (string) ($badge ?? '');
$dateIso = (string) ($date_iso ?? '');
$dateText = (string) ($date_text ?? '');
$location = (string) ($location ?? '');
$hasMeta = $dateText !== '' || $location !== '';
?>
<header class="detail-head">
<div class="container detail-head__inner">
<?php if (!empty($back['href'])): ?>
<a class="detail-head__back" href="<?= e($back['href']) ?>">
<?= icon('chevron-left') ?> <?= e($back['label'] ?? 'Zurück') ?>
</a>
<?php endif; ?>
<h1 class="detail-head__title">
<?= e($title) ?>
<?php if ($badge !== ''): ?>
<span class="detail-head__badge"><?= e($badge) ?></span>
<?php endif; ?>
</h1>
<?php if ($hasMeta): ?>
<p class="detail-head__meta">
<?php if ($dateText !== ''): ?>
<time<?= $dateIso !== '' ? ' datetime="' . e($dateIso) . '"' : '' ?>><?= e($dateText) ?></time>
<?php endif; ?>
<?php if ($location !== ''): ?>
<?php if ($dateText !== ''): ?>
<span class="detail-head__sep" aria-hidden="true">·</span>
<?php endif; ?>
<?php /* Icon und Ort in EINEM span, sonst bricht die Zeile am Handy zwischen
Nadel und Ortsname um. */ ?>
<span class="detail-head__ort"><?= icon('geo-alt') ?> <?= e($location) ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</header>

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* Aufmacher für einen Vereins-Termin — das Gegenstück zu match-feature für
* alles, was kein Spiel ist (data/events.json). Gleiches Bauprinzip: drei
* Schriftrollen (Meta/Ort in Abel · Titel in Coolvetica · Zeitzeile in Abel),
* keine Badges, keine Icons, die Wirkung kommt aus der Größe. Statt des
* Fixtures mit Wappen steht links das Datums-Chip (Tag über Monat, dieselbe
* Optik wie in der termine-list).
* Props:
* $event Eintrag aus data/events.json (title, date_start, date_end?, time?, location?)
*/
$e = $event;
$dateStart = (string) ($e['date_start'] ?? '');
$dateEnd = $e['date_end'] ?? null;
$time = (string) ($e['time'] ?? '');
$chip = german_date_chip($dateStart);
$dateText = german_date_range($dateStart, $dateEnd);
// Ganztägige Events (ohne time) sollen im Countdown nicht „heute, 00:00 Uhr"
// behaupten — deshalb der zweite Parameter.
$when = match_when($dateStart !== '' ? $dateStart . 'T' . ($time !== '' ? $time : '00:00') : '', $time !== '');
$dateLine = trim($dateText . ($time !== '' ? ' · ' . $time . ' Uhr' : ''));
?>
<article class="event-feature">
<div class="event-feature__head">
<?php if ($chip['day'] !== ''): ?>
<span class="event-feature__chip" aria-hidden="true">
<span class="event-feature__chip-day"><?= e($chip['day']) ?></span>
<span class="event-feature__chip-month"><?= e($chip['month']) ?></span>
</span>
<?php endif; ?>
<span class="event-feature__body">
<span class="event-feature__title"><?= e($e['title'] ?? '') ?></span>
<?php if (!empty($e['location'])): ?>
<span class="event-feature__where"><?= e($e['location']) ?></span>
<?php endif; ?>
</span>
</div>
<p class="event-feature__when">
<?php if ($dateStart !== ''): ?>
<time datetime="<?= e($dateStart) ?>"><?= e($dateLine) ?></time>
<?php else: ?>
<span><?= e($dateLine) ?></span>
<?php endif; ?>
<?php if ($when['countdown'] !== ''): ?>
<span class="event-feature__sep" aria-hidden="true">·</span>
<span class="event-feature__countdown" data-countdown="<?= e($when['iso']) ?>"><?= e($when['countdown']) ?></span>
<?php endif; ?>
</p>
</article>

View File

@@ -41,7 +41,7 @@ if ($faq === [] && !$hasPerson) {
<?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><?= e($item['a']) ?></p></div>
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
</details>
<?php endforeach; ?>
</div>
@@ -62,7 +62,7 @@ if ($faq === [] && !$hasPerson) {
</ul>
<div class="contact-slider__dots">
<?php foreach ($persons as $i => $p): ?>
<button type="button" class="contact-slider__dot" data-contact-dot<?= $i === 0 ? ' aria-current="true"' : '' ?> aria-label="Ansprechpartner <?= $i + 1 ?>: <?= e($p['name']) ?>"></button>
<button type="button" class="contact-slider__dot tap-target" data-contact-dot<?= $i === 0 ? ' aria-current="true"' : '' ?> aria-label="Ansprechpartner <?= $i + 1 ?>: <?= e($p['name']) ?>"></button>
<?php endforeach; ?>
</div>
</div>

View File

@@ -27,7 +27,7 @@ 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><?= e($item['a']) ?></p></div>
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
</details>
<?php endforeach; ?>
</div>

View File

@@ -16,8 +16,10 @@ declare(strict_types=1);
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
* (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben!
*
* Jedes Feld bekommt ein leeres Fehler-Element (#<id>-error), das form.js bei
* ungültiger Eingabe mit der Browser-Validierungsmeldung füllt (aria-describedby).
* Jedes Feld bekommt ein Fehler-Element (#<id>-error, aria-describedby): serverseitig
* gefüllt beim 422-Re-Render (form_field_error()), sonst von form.js mit der
* Browser-Validierungsmeldung. Eingaben aus einem abgewiesenen POST schreibt
* form_old() zurück — deshalb gehen Werte auch ohne JS nie verloren.
*/
$type = $field['type'] ?? 'text';
$name = $field['name'];
@@ -26,6 +28,8 @@ $required = !empty($field['required']);
$errorId = $id . '-error';
$hintId = $id . '-hint';
$describedBy = (!empty($field['hint']) ? $hintId . ' ' : '') . $errorId;
$old = form_old($name);
$fieldError = form_field_error($id);
// Sichtbarer roter Stern (aria-hidden) + pro Feld erklärendes Pflicht-Wort für
// Screenreader — ersetzt die frühere globale „Pflichtfelder mit *"-Hinweiszeile.
@@ -39,13 +43,14 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
. (!empty($field['autocomplete']) ? ' autocomplete="' . e($field['autocomplete']) . '"' : '')
. (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '')
. (isset($field['min']) ? ' min="' . e((string) $field['min']) . '"' : '')
. (isset($field['max']) ? ' max="' . e((string) $field['max']) . '"' : '');
. (isset($field['max']) ? ' max="' . e((string) $field['max']) . '"' : '')
. ($fieldError !== '' ? ' aria-invalid="true"' : '');
?>
<?php if ($type === 'checkbox'): ?>
<div class="form__field form__field--checkbox">
<input type="checkbox" <?= $commonAttrs ?> value="1">
<input type="checkbox" <?= $commonAttrs ?> value="1"<?= $old === '1' ? ' checked' : '' ?>>
<label for="<?= e($id) ?>"><?= $field['label_html'] ?? e($field['label']) ?><?= $requiredMark ?></label>
<p class="form__error-msg" id="<?= e($errorId) ?>" hidden></p>
<p class="form__error-msg" id="<?= e($errorId) ?>"<?= $fieldError === '' ? ' hidden' : '' ?>><?= e($fieldError) ?></p>
</div>
<?php else: ?>
<div class="form__field">
@@ -54,17 +59,17 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
<select <?= $commonAttrs ?>>
<option value=""><?= e($field['placeholder'] ?? 'Bitte wählen') ?></option>
<?php foreach ($field['options'] as $option): ?>
<option value="<?= e($option) ?>"><?= e($option) ?></option>
<option value="<?= e($option) ?>"<?= $old !== '' && $old === (string) $option ? ' selected' : '' ?>><?= e($option) ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($type === 'textarea'): ?>
<textarea <?= $commonAttrs ?> rows="<?= e($field['rows'] ?? 6) ?>" placeholder=" "></textarea>
<textarea <?= $commonAttrs ?> rows="<?= e($field['rows'] ?? 6) ?>" placeholder=" "><?= e($old) ?></textarea>
<?php else: ?>
<input type="<?= e($type) ?>" <?= $commonAttrs ?> placeholder=" ">
<input type="<?= e($type) ?>" <?= $commonAttrs ?> value="<?= e($old) ?>" placeholder=" ">
<?php endif; ?>
<?php if (!empty($field['hint'])): ?>
<p class="form__hint" id="<?= e($hintId) ?>"><?= e($field['hint']) ?></p>
<?php endif; ?>
<p class="form__error-msg" id="<?= e($errorId) ?>" hidden></p>
<p class="form__error-msg" id="<?= e($errorId) ?>"<?= $fieldError === '' ? ' hidden' : '' ?>><?= e($fieldError) ?></p>
</div>
<?php endif; ?>

View File

@@ -139,7 +139,7 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
<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="group-tabs__tab" role="tab" type="button"
<button class="tab group-tabs__tab" role="tab" type="button"
id="gtab-<?= $i ?>"
aria-controls="gpanel-<?= $i ?>"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"

View File

@@ -10,7 +10,7 @@ $nav = json_load('navigation');
?>
<header class="site-header">
<div class="container site-header__inner">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> Startseite">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?>, Startseite">
<img class="site-header__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<span class="site-header__name" aria-hidden="true"><?= e($club['name']) ?></span>
</a>

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* Glas-Karte im Startseiten-Hero: die nächsten Termine des Vereins als
* automatisch wechselnder Slider. Props:
* $items aus upcoming_highlights() (app/helpers.php) — Spiele und
* Vereins-Events chronologisch gemischt, je Eintrag type/label/href/
* link_text plus die rohen Daten für die Innenvariante
*
* Zwei Innenvarianten, eine Karte: 'spiel' rendert match-feature (Heim über
* Gast mit Wappen, wie im Matchcenter-Band), 'event' rendert event-feature
* (Datums-Chip + Titel + Ort). Die Beschriftung („Nächstes Spiel · 1. Mannschaft")
* kommt fertig aus dem Helper; die Meta-Zeile von match-feature bleibt in der
* Karte per CSS aus, damit Mannschaft/Liga nicht zweimal dastehen.
*
* Antrieb ist der generische assets/js/carousel.js (dieselben data-Hooks wie
* banner-slider): Crossfade, Fortschrittsring im aktiven Indikator, Pause-Taste
* (WCAG 2.2.2), Pause bei Hover/Fokus, kein Autoplay bei prefers-reduced-motion.
* Ohne JS ist nur der nächste Termin sichtbar und verlinkt (die weiteren Folien
* tragen hidden, die Steuerung bleibt verborgen, weil sie funktionslos wäre).
*
* Die Karten-Wurzel ist bewusst ein div und nicht selbst der Link: die
* Steuer-Buttons liegen in der Karte, verschachtelte interaktive Elemente wären
* ungültig. Verlinkt ist deshalb der Inhalt jeder Folie.
*/
$items = $items ?? [];
if ($items === []) {
return;
}
$total = count($items);
?>
<div class="hero__upcoming"<?= $total > 1 ? ' data-carousel aria-roledescription="Karussell" aria-label="Nächste Termine"' : '' ?>>
<ul class="hero__upcoming-track" id="hero-upcoming-track" data-carousel-track aria-live="off">
<?php foreach ($items as $i => $item): ?>
<li class="hero__upcoming-slide"<?= $total > 1 ? ' data-carousel-slide role="group" aria-roledescription="Folie" aria-label="' . ($i + 1) . ' von ' . $total . '"' : '' ?><?= $i === 0 ? '' : ' hidden' ?>>
<a class="hero__upcoming-link" href="<?= e($item['href']) ?>">
<p class="hero__upcoming-label"><?= e($item['label']) ?></p>
<?php if ($item['type'] === 'spiel'): ?>
<?php component('match-feature', [
'match' => $item['match'],
'team_name' => $item['team_name'],
'league' => $item['league'],
]); ?>
<?php else: ?>
<?php component('event-feature', ['event' => $item['event']]); ?>
<?php endif; ?>
<span class="visually-hidden"><?= e($item['link_text']) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php if ($total > 1): ?>
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
<div class="hero__upcoming-controls" data-carousel-controls hidden>
<div class="hero__upcoming-indicators" role="group" aria-label="Termin wählen">
<?php foreach ($items as $i => $item): ?>
<button class="hero__upcoming-dot" type="button" data-carousel-dot aria-controls="hero-upcoming-track" aria-label="Termin <?= ($i + 1) ?> von <?= $total ?>: <?= e($item['label']) ?>">
<span class="hero__upcoming-progress" data-carousel-progress aria-hidden="true"></span>
</button>
<?php endforeach; ?>
</div>
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
<?php /* Kein btn--sm: dessen 44px kommen über ::after, die Fläche würde
in dieser Reihe den letzten Indikator überlappen (siehe CLAUDE.md). */ ?>
<button class="btn btn--icon hero__upcoming-toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="hero-upcoming-track" hidden>
<?= icon('pause-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--pause') ?>
<?= icon('play-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
</button>
</div>
<?php endif; ?>
</div>

View File

@@ -5,21 +5,30 @@ declare(strict_types=1);
/**
* Vollflächiger Hero mit Hintergrundbild/-video, Titel, Intro und CTAs.
* Props: $hero (array aus home.json: title, text, image, video?, ctas, align?, compact?,
* display?, kicker?, scroll_target?)
* align-Varianten: 'center' (mittig zentriert), 'bottom-left' (Default).
* display?, kicker?, scroll_target?), $upcoming (optional, s. home.php:
* die nächsten Termine aus upcoming_highlights() — Spiele und Vereins-Events
* gemischt, gerendert über die hero-upcoming-Komponente)
* align-Varianten: 'center' (mittig zentriert, Konvention für alle Unterseiten seit 27.07.2026),
* 'bottom-left' (ältere Variante, technischer Fallback — für neue Seiten nicht mehr verwenden).
* compact: true → niedrigere Variante für Unterseiten-Header.
* display: true → großer Editorial-Look (Riesen-Titel, Outline-Tagline, Kicker, Scroll-Cue).
*
* KONVENTION (siehe CLAUDE.md → Hero-Regel): display + kicker + tagline + video sind der
* Startseite vorbehalten. Unterseiten-Heros sind einheitlich „Überschrift + Teaser":
* nur title + text, dazu compact: true und align: 'bottom-left'. Kein kicker/tagline,
* nur title + text, dazu compact: true und align: 'center'. Kein kicker/tagline,
* ctas nur als Ausnahme.
*
* Liegt mindestens ein Termin in $upcoming, läuft der Display-Hero zweispaltig statt
* zentriert („hero--duo"): links Titel/Text/CTAs linksbündig, rechts die Termin-Karte als
* Live-Aufhänger. Ohne Termine (Sommerpause) bleibt es beim zentrierten Hero ohne Karte.
*/
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'bottom-left';
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'center';
$compact = !empty($hero['compact']);
$display = !empty($hero['display']);
$upcoming = $upcoming ?? [];
$duo = $display && $upcoming !== [];
?>
<section class="hero<?= $align === 'center' ? ' hero--center' : '' ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?>">
<section class="hero<?= $duo ? '' : ($align === 'center' ? ' hero--center' : '') ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?><?= $duo ? ' hero--duo' : '' ?>">
<div class="hero__media" aria-hidden="true">
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
<?php if (!empty($hero['video'])): ?>
@@ -27,7 +36,9 @@ $display = !empty($hero['display']);
<video class="hero__video" src="<?= e(asset($hero['video'])) ?>" autoplay muted loop playsinline preload="none" tabindex="-1"></video>
<?php endif; ?>
</div>
<div class="container hero__content">
<div class="container hero__content<?= $duo ? ' hero__content--duo' : '' ?>">
<?php if ($duo): ?>
<div class="hero__primary">
<?php if (!empty($hero['kicker'])): ?>
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
<?php endif; ?>
@@ -41,6 +52,24 @@ $display = !empty($hero['display']);
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
<?php else: ?>
<?php if (!empty($hero['kicker'])): ?>
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
<?php endif; ?>
<h1 class="hero__title"><?= e($hero['title']) ?><?php if (!empty($hero['tagline'])): ?> <span class="hero__tagline"><?= e($hero['tagline']) ?></span><?php endif; ?></h1>
<?php if (!empty($hero['text'])): ?>
<p class="hero__text"><?= e($hero['text']) ?></p>
<?php endif; ?>
<?php if (!empty($hero['ctas'])): ?>
<div class="hero__actions">
<?php foreach ($hero['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($display): ?>

View File

@@ -16,7 +16,7 @@ $maxPosts = (int) config('instagram.max_posts', 9);
<div class="container">
<h2>Aktuelles aus dem Fußball</h2>
<?php if ($posts !== []): ?>
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?> folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?>. Folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
<ul class="instagram__grid">
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
<li class="instagram__item">
@@ -37,5 +37,6 @@ $maxPosts = (int) config('instagram.max_posts', 9);
<p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
</div>
<?php endif; ?>
<p class="instagram__more"><a class="btn btn--outline" href="<?= e(url('news')) ?>">Alle News ansehen</a></p>
</div>
</section>

View File

@@ -22,9 +22,11 @@ $hasReleg = in_array('relegation', $zones, true);
// Vor dem 1. Spieltag liefert der BFV für JEDE Mannschaft Platz 1 und dieselbe
// Auf-/Abstiegszone. Beides ist dann bedeutungslos und lässt die Tabelle kaputt
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken und
// stattdessen sagen, dass noch nichts gewertet ist. Testspiele zählen hier nicht
// mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also zu Recht nicht.
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken.
// Ein erklärender Hinweissatz stand hier mal und ist bewusst raus (Felix,
// 29.07.2026): eine Tabelle mit 0 Spielen erklärt sich selbst. Testspiele
// zählen nicht mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also
// zu Recht nicht.
$notStarted = array_sum(array_map(
static fn (array $r): int => (int) ($r['played'] ?? 0),
$table
@@ -33,9 +35,6 @@ if ($notStarted) {
$hasPromo = $hasPromoPlayoff = $hasRelegPlayoff = $hasReleg = false;
}
?>
<?php if ($notStarted): ?>
<p class="league-table__note">Die Liga hat noch nicht begonnen gewertet wird ab dem 1. Spieltag.</p>
<?php endif; ?>
<div class="table-scroll-frame">
<div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0">
<table class="league-table">

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Overlay für die Bild-Großansicht. Props:
* $label string Bezeichnung des Dialogs (Screenreader)
* $count int Anzahl der Bilder — bei genau einem gibt es weder
* Zähler noch Vor/Zurück (wäre beides sinnlos)
*
* Gehört IMMER in denselben [data-lightbox-gallery]-Container wie die
* zugehörigen Bildlinks (assets/js/lightbox.js sucht von dort aus). Steht
* `hidden` im Markup, bis JS es übernimmt — ohne JS bleiben die
* Vorschaubilder normale Links auf die Bilddatei.
*/
$label = $label ?? 'Bild in Großansicht';
$count = $count ?? 0;
$mehrere = $count > 1;
?>
<div class="lightbox" role="dialog" aria-modal="true" aria-label="<?= e($label) ?>" data-lightbox-dialog hidden>
<?php /* Keine sichtbare Bildunterschrift (Beschluss 30.07.2026): der Alt-Text
trägt die Beschreibung. sizes steht statisch, das srcset erbt das Bild
vom angeklickten Vorschaubild (lightbox.js). */ ?>
<img class="lightbox__img" src="" alt="" sizes="100vw" data-lightbox-img>
<?php if ($mehrere): ?>
<p class="lightbox__counter" aria-live="polite">
<span data-lightbox-current>1</span> von <?= (int) $count ?>
</p>
<?php endif; ?>
<button class="btn btn--icon lightbox__btn lightbox__close" type="button" data-lightbox-close aria-label="Ansicht schließen">
<?= icon('x-lg') ?>
</button>
<?php if ($mehrere): ?>
<button class="btn btn--icon lightbox__btn lightbox__prev" type="button" data-lightbox-prev aria-label="Vorheriges Bild">
<?= icon('chevron-left') ?>
</button>
<button class="btn btn--icon lightbox__btn lightbox__next" type="button" data-lightbox-next aria-label="Nächstes Bild">
<?= icon('chevron-right') ?>
</button>
<?php endif; ?>
</div>

View File

@@ -50,10 +50,10 @@ $tabCur = $season !== '' ? $season : 'Aktuelle Saison';
?>
<div class="matchcenter-team__seasons group-tabs" data-group-tabs>
<div class="group-tabs__bar matchcenter-team__seasons-bar" role="tablist" aria-label="Saison wählen <?= e($name) ?>">
<button class="group-tabs__tab" role="tab" type="button"
<button class="tab group-tabs__tab" role="tab" type="button"
id="mc-tab-<?= e($key) ?>-cur" aria-controls="mc-panel-<?= e($key) ?>-cur" aria-selected="true"
><?= e($tabCur) ?></button>
<button class="group-tabs__tab" role="tab" type="button"
<button class="tab group-tabs__tab" role="tab" type="button"
id="mc-tab-<?= e($key) ?>-prev" aria-controls="mc-panel-<?= e($key) ?>-prev" aria-selected="false"
><?= e($tabPrev) ?></button>
</div>

View File

@@ -13,11 +13,14 @@ declare(strict_types=1);
$club = json_load('club');
$fee_options = $fee_options ?? [];
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
// serverseitigen Feldfehler aus der Action.
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
'mail' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
];
@@ -44,9 +47,10 @@ $f = [
];
?>
<form class="form" method="post" action="<?= e(url('mitglied-werden-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
<?php if ($sent): ?>
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen. Wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
<?php elseif ($error !== null): ?>
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
<?php endif; ?>

View File

@@ -22,7 +22,10 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>">
<?php if ($current !== '404'): ?>
<?php /* noindex: 404-Seiten und alles, was eine Page ausdrücklich anfordert
($meta['noindex'], z. B. Stadionzeitungs-Entwürfe) — dann auch kein
Self-Canonical, das wäre ein Widerspruch zur Nicht-Indexierung. */ ?>
<?php if ($current !== '404' && empty($meta['noindex'])): ?>
<link rel="canonical" href="<?= e($canonical) ?>">
<?php else: ?>
<meta name="robots" content="noindex">
@@ -39,9 +42,12 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="<?= e(asset('img/logo.svg')) ?>" type="image/svg+xml">
<?php /* Preload ohne ?v= (asset(..., false)): base.css fordert dieselbe Datei per
@font-face ohne Query an, mit Version wären es für den Browser zwei URLs
und der Preload bliebe ungenutzt. */ ?>
<?php foreach (['fonts/coolvetica.woff2', 'fonts/abel.woff2'] as $font): ?>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $font)): ?>
<link rel="preload" href="<?= e(asset($font)) ?>" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="<?= e(asset($font, false)) ?>" as="font" type="font/woff2" crossorigin>
<?php endif; ?>
<?php endforeach; ?>
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>

View File

@@ -7,7 +7,10 @@ declare(strict_types=1);
* Burger-Toggle + Dropdowns übernimmt assets/js/nav.js.
* Dropdowns funktionieren auch ohne JS (CSS :hover / :focus-within); JS ergänzt
* nur aria-expanded, Escape und Klick-außerhalb-schließt.
* CTAs rendert der Header (site-header__actions) — nicht hier.
* CTAs: am Desktop rendert sie der Header (site-header__actions), im
* Burger-Modus stehen sie am Ende des Menü-Overlays (site-nav__ctas) —
* dieselbe Quelle (navigation.json → ctas), CSS blendet je Breakpoint
* genau eine der beiden Stellen ein.
*/
$nav = json_load('navigation');
@@ -50,5 +53,12 @@ $href = static function (string $slug): string {
</li>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!empty($nav['ctas'])): ?>
<li class="site-nav__ctas">
<?php foreach ($nav['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</li>
<?php endif; ?>
</ul>
</nav>

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* Artikelkörper einer News-Detailseite. Props:
* $image array (optional) — Aufmacher {base, widths, alt}, above the fold
* $blocks array — data/news.json → artikel[], Blöcke mit `typ`:
* text {absaetze: string[]} Fließtext
* zwischentitel {text} h2 (h1 steht im detail-head)
* bild {base, widths, alt, caption?}
* zitat {text, quelle?}
*
* Ein unbekannter `typ` wird stillschweigend übersprungen; gemeldet wird er von
* bin/preflight.php, damit ein Tippfehler vor dem Deploy auffällt und nicht erst
* durch einen fehlenden Absatz auf der Seite.
*
* Fließtext läuft durch inline_links_html(): interne Deeplinks [Label](slug)
* werden zu <a>, alles andere escaped. Kein rohes HTML in der JSON.
*
* Bilder sind wie bei den Veranstaltungen ohne JS normale Links auf die größte
* Variante; mit JS fängt assets/js/lightbox.js den Klick ab. Das Overlay kommt
* nur dazu, wenn der Artikel überhaupt Bilder hat.
*/
$image = $image ?? null;
$blocks = $blocks ?? [];
$bilder = array_values(array_filter(
$blocks,
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild' && !empty($b['base']) && !empty($b['widths'])
));
$bildAnzahl = count($bilder);
$bildIndex = 0;
?>
<article class="section news-article"<?= $bildAnzahl > 0 ? ' data-lightbox-gallery' : '' ?>>
<div class="container">
<?php if (!empty($image['base'])): ?>
<figure class="news-article__lead">
<?php component('img', [
'image' => $image,
'sizes' => '(max-width: 768px) 100vw, 72ch',
'class' => 'news-article__lead-img',
'eager' => true,
]); ?>
</figure>
<?php endif; ?>
<div class="news-article__body prose">
<?php foreach ($blocks as $block): ?>
<?php $typ = $block['typ'] ?? ''; ?>
<?php if ($typ === 'text'): ?>
<?php foreach ($block['absaetze'] ?? [] as $absatz): ?>
<p><?= inline_links_html((string) $absatz) ?></p>
<?php endforeach; ?>
<?php elseif ($typ === 'zwischentitel' && !empty($block['text'])): ?>
<h2 class="news-article__heading"><?= e($block['text']) ?></h2>
<?php elseif ($typ === 'bild' && !empty($block['base']) && !empty($block['widths'])): ?>
<figure class="news-article__figure">
<a class="news-article__figure-link" href="<?= e(asset($block['base'] . '-' . max($block['widths']) . '.jpg')) ?>" data-lightbox="<?= $bildIndex++ ?>">
<?php component('img', [
'image' => $block,
'sizes' => '(max-width: 768px) 100vw, 72ch',
'class' => 'news-article__img',
]); ?>
<span class="visually-hidden">(größer ansehen)</span>
</a>
<?php if (!empty($block['caption'])): ?>
<figcaption class="news-article__caption"><?= e($block['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<?php elseif ($typ === 'zitat' && !empty($block['text'])): ?>
<figure class="news-article__quote">
<blockquote><p><?= e($block['text']) ?></p></blockquote>
<?php if (!empty($block['quelle'])): ?>
<figcaption class="news-article__quote-source"><?= e($block['quelle']) ?></figcaption>
<?php endif; ?>
</figure>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php if ($bildAnzahl > 0): ?>
<?php component('lightbox-overlay', ['label' => 'Bild aus dem Artikel', 'count' => $bildAnzahl]); ?>
<?php endif; ?>
</article>

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
/**
* Zeilen-Liste für die News-Seite: mischt redaktionelle Meldungen, Instagram-
* Posts und Ergebnisse. Props:
* $items array{date, title, text, image?, href?, link_text?, source}
* `link_text` (optional) ist die sichtbare Weiterlesen-Zeile —
* gesetzt vom Normalizer, nicht hier abgeleitet: die Komponente
* soll die Quellen nicht kennen (gleiche Prop wie in
* upcoming_highlights()/event-feature)
* $empty_text string (optional)
* $featured bool (optional) — neuester Eintrag als großer Aufmacher über
* der Liste (gleiche Hierarchie-Idee wie das „Nächste Spiele"-
* Band: ein Aufmacher, darunter Zeilen)
* $filterable bool (optional) — Filter-Leiste nach Quelle (Alle/Meldungen/
* Ergebnisse/Instagram), gleiche Bausteine wie der Stellen-Filter
* auf /mitmachen (.filter-bar + .tab, assets/js/news-filter.js).
* Ohne JS sind alle Einträge sichtbar — reine Progressive
* Enhancement. Die Seite muss news-filter.js laden.
* Kennt nichts von den drei Quellstrukturen — bekommt nur die normalisierte
* Form (app/pages/news.php baut sie).
*
* Bewusst eine Zeilen-Liste statt eines Karten-Grids (wie termine-list.php):
* die Einträge sind zu unterschiedlich hoch (hohe Bild-Posts neben kurzen
* Text-Ergebnissen) — ein Grid reißt dabei Löcher, weil kurze Karten den
* Platz neben einer hohen Karte nicht auffüllen können. Eine einspaltige
* Liste hat dieses Problem nicht, jede Zeile ist einfach so hoch wie ihr
* eigener Inhalt.
*/
$items = $items ?? [];
$emptyText = $empty_text ?? 'Aktuell keine News.';
$featured = !empty($featured);
$filterable = !empty($filterable);
$sourceLabels = [
'news' => 'Meldung',
'instagram' => 'Instagram',
'ergebnis' => 'Ergebnis',
];
$filters = [
'all' => 'Alle',
'news' => 'Meldungen',
'ergebnis' => 'Ergebnisse',
'instagram' => 'Instagram',
];
?>
<?php if ($items === []): ?>
<p class="text-muted"><?= e($emptyText) ?></p>
<?php else: ?>
<?php if ($filterable): ?>
<div class="filter-bar" role="group" aria-label="News nach Quelle filtern" data-news-filter>
<?php $i = 0; foreach ($filters as $value => $label): ?>
<button type="button" class="tab filter-bar__tab" data-filter="<?= e($value) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($featured):
$feature = array_shift($items);
$featureTag = !empty($feature['href']) ? 'a' : 'div';
$featureExternal = $feature['source'] === 'instagram';
$featureAttrs = !empty($feature['href'])
? ' href="' . e($feature['href']) . '"' . ($featureExternal ? ' target="_blank" rel="noopener"' : '')
: '';
$featureHasMedia = !empty($feature['image']) || !empty($feature['crest']);
?>
<div class="news-feature" data-source="<?= e($feature['source']) ?>">
<<?= $featureTag ?> class="news-feature__link<?= $featureHasMedia ? ' news-feature__link--media' : '' ?>"<?= $featureAttrs ?>>
<?php if (!empty($feature['image'])): ?>
<div class="news-feature__media">
<?php component('img', ['image' => $feature['image'], 'sizes' => '(max-width: 768px) 100vw, 40vw', 'class' => 'news-feature__img', 'eager' => true]); ?>
</div>
<?php elseif (!empty($feature['crest'])): ?>
<div class="news-feature__media news-feature__media--crest">
<?php component('crest', ['src' => $feature['crest']['src'], 'name' => $feature['crest']['name']]); ?>
</div>
<?php endif; ?>
<div class="news-feature__body">
<p class="news-row__meta">
<span class="news-row__badge news-row__badge--<?= e($feature['source']) ?>"><?= e($sourceLabels[$feature['source']] ?? $feature['source']) ?></span>
<?php if ($feature['date'] !== ''): ?>
<time datetime="<?= e($feature['date']) ?>"><?= e(german_date($feature['date'])) ?></time>
<?php endif; ?>
<?php if ($featureExternal): ?>
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
<?php endif; ?>
</p>
<p class="news-feature__title"><?= e($feature['title']) ?></p>
<?php if (!empty($feature['text']) && $feature['text'] !== $feature['title']): ?>
<p class="news-feature__text"><?= e($feature['text']) ?></p>
<?php endif; ?>
<?php if (!empty($feature['link_text'])): ?>
<p class="news-feature__more"><?= e($feature['link_text']) ?> <?= icon('arrow-right') ?></p>
<?php endif; ?>
</div>
</<?= $featureTag ?>>
</div>
<?php endif; ?>
<ul class="news-list">
<?php foreach ($items as $item): ?>
<?php
$tag = !empty($item['href']) ? 'a' : 'div';
$external = $item['source'] === 'instagram';
$attrs = '';
if (!empty($item['href'])) {
$attrs = ' href="' . e($item['href']) . '"' . ($external ? ' target="_blank" rel="noopener"' : '');
}
?>
<li class="news-row" data-source="<?= e($item['source']) ?>">
<<?= $tag ?> class="news-row__link"<?= $attrs ?>>
<?php if (!empty($item['image'])): ?>
<div class="news-row__media">
<?php component('img', ['image' => $item['image'], 'sizes' => '96px', 'class' => 'news-row__img']); ?>
</div>
<?php elseif (!empty($item['crest'])): ?>
<div class="news-row__media news-row__media--crest">
<?php component('crest', ['src' => $item['crest']['src'], 'name' => $item['crest']['name']]); ?>
</div>
<?php endif; ?>
<div class="news-row__body">
<p class="news-row__meta">
<span class="news-row__badge news-row__badge--<?= e($item['source']) ?>"><?= e($sourceLabels[$item['source']] ?? $item['source']) ?></span>
<?php if ($item['date'] !== ''): ?>
<time datetime="<?= e($item['date']) ?>"><?= e(german_date($item['date'])) ?></time>
<?php endif; ?>
<?php if ($external): ?>
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
<?php endif; ?>
</p>
<p class="news-row__title"><?= e($item['title']) ?></p>
<?php if (!empty($item['text']) && $item['text'] !== $item['title']): ?>
<p class="news-row__text"><?= e($item['text']) ?></p>
<?php endif; ?>
<?php if (!empty($item['link_text'])): ?>
<p class="news-row__more"><?= e($item['link_text']) ?> <?= icon('arrow-right') ?></p>
<?php endif; ?>
</div>
</<?= $tag ?>>
</li>
<?php endforeach; ?>
</ul>
<?php if ($filterable): ?>
<p class="text-muted" data-news-empty hidden>In dieser Kategorie gibt es gerade nichts Neues.</p>
<?php endif; ?>
<?php endif; ?>

View File

@@ -4,12 +4,31 @@ declare(strict_types=1);
/**
* Sponsoren/Partner-Logogrid. Props:
* $intro array{title, text, cta?} (aus home.json)
* $intro array{title, text, cta?, only?} (aus home.json bzw. dem
* partner-Block einer Veranstaltung)
* $anchor string (optional, Default 'partner') — Sektions-ID
* Logos kommen aus data/partners.json (Single Source of Truth).
*
* only: Liste von Partnernamen (exakt wie in partners.json) — für Seiten, die
* nur einen Teil der Partner zeigen, etwa die Unterstützer eines einzelnen
* Fests. Fehlt only, stehen alle da (Verhalten der Startseite). Passt kein
* Name (Tippfehler, umbenannter Partner), stehen bewusst wieder ALLE da statt
* einer leeren Logo-Wand.
*/
$anchor = $anchor ?? 'partner';
$partners = json_load('partners')['partners'] ?? [];
if (!empty($intro['only'])) {
$auswahl = array_values(array_filter(
$partners,
static fn (array $p): bool => in_array($p['name'] ?? '', $intro['only'], true)
));
if ($auswahl !== []) {
$partners = $auswahl;
}
}
?>
<section class="section partners" id="partner">
<section class="section partners" id="<?= e($anchor) ?>">
<div class="container">
<h2><?= e($intro['title']) ?></h2>
<p class="partners__intro text-muted"><?= e($intro['text']) ?></p>

View File

@@ -5,54 +5,83 @@ declare(strict_types=1);
/**
* Visitenkarte für eine Person. Props:
* $person array{department, name, phone?, email?, image?, whatsapp?}
* $level string — Überschriften-Ebene des department-Headings (Default 'h3').
* $level string — Überschriften-Ebene des NAMENS (Default 'h3'; der Name ist
* das Heading der Karte, die Abteilung nur eine Kicker-Zeile darüber).
* Eingebettet (z. B. in position-card unter einem h3) auf 'h4' setzen,
* damit die Heading-Outline nicht abflacht. Styling bleibt klassenbasiert.
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg).
* Kontakt-Link: phone → tel:, sonst email → mailto:.
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person).
* $hide_department bool — Kicker ausblenden, wenn der Kontext die Abteilung
* schon trägt (person-grid mit group_by_department).
* Ohne image rendert ein Initialen-Avatar im Vereins-Look (dunkle Fläche,
* große Initialen — dasselbe Fallback-Prinzip wie crest.php), kein generisches
* Silhouetten-Bild.
* Kontaktwege als Buttons (mobil sofort tippbar): phone → Anrufen (+ WhatsApp
* bei whatsapp=true), sonst email → E-Mail. Nummer bzw. Adresse stehen
* zusätzlich als Textzeile (ablesbar).
* Es sind reine Icon-Knöpfe im globalen .btn--icon-Format (44px, rund) und sie
* sitzen rechts NEBEN dem Text, nicht darunter — das spart pro Karte eine ganze
* Button-Zeile Höhe. Weil das Label wegfällt, trägt jeder Knopf den
* Personennamen im aria-label: im Ansprechpartner-Grid stünden sonst zwei
* Dutzend Links namens „Anrufen" untereinander.
*/
$level = $level ?? 'h3';
if (!in_array($level, ['h2', 'h3', 'h4', 'h5'], true)) {
$level = 'h3';
}
$hideDepartment = !empty($hide_department);
$image = $person['image'] ?? null;
$src = $image ?: 'img/persons/platzhalter.jpg';
if (!empty($person['phone'])) {
$contactHref = 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']);
$contactLabel = $person['phone'];
$contactIcon = 'telephone';
} elseif (!empty($person['email'])) {
$contactHref = 'mailto:' . $person['email'];
$contactLabel = $person['email'];
$contactIcon = 'envelope';
} else {
$contactHref = null;
$initials = '';
foreach (preg_split('/[\s.-]+/', trim((string) ($person['name'] ?? ''))) ?: [] as $word) {
if ($word !== '') {
$initials .= mb_substr($word, 0, 1);
}
if (mb_strlen($initials) >= 2) {
break;
}
}
$initials = mb_strtoupper($initials !== '' ? $initials : '?');
$telHref = !empty($person['phone']) ? 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']) : null;
$mailHref = ($telHref === null && !empty($person['email'])) ? 'mailto:' . $person['email'] : null;
$waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
: null;
$detail = $person['phone'] ?? $person['email'] ?? '';
?>
<article class="person-card">
<?php if ($image): ?>
<div class="person-card__media">
<img src="<?= e(asset($src)) ?>"
alt="<?= $image ? e($person['name']) : '' ?>"
<img src="<?= e(asset($image)) ?>"
alt="<?= e($person['name']) ?>"
width="480" height="600" loading="lazy" decoding="async">
</div>
<?php else: ?>
<div class="person-card__media person-card__media--initials" aria-hidden="true">
<span class="person-card__initials"><?= e($initials) ?></span>
</div>
<?php endif; ?>
<div class="person-card__body">
<<?= $level ?> class="person-card__department"><?= e($person['department']) ?></<?= $level ?>>
<p class="person-card__name"><?= e($person['name']) ?></p>
<?php if ($contactHref !== null): ?>
<a class="person-card__contact" href="<?= e($contactHref) ?>">
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span>
</a>
<div class="person-card__text">
<?php if (!$hideDepartment): ?>
<p class="person-card__department"><?= e($person['department']) ?></p>
<?php endif; ?>
<<?= $level ?> class="person-card__name"><?= e($person['name']) ?></<?= $level ?>>
<?php if ($detail !== ''): ?>
<p class="person-card__detail"><?= e($detail) ?></p>
<?php endif; ?>
</div>
<?php if ($telHref !== null || $mailHref !== null): ?>
<div class="person-card__actions">
<?php if ($telHref !== null): ?>
<a class="btn btn--icon" href="<?= e($telHref) ?>" aria-label="<?= e($person['name']) ?> anrufen"><?= icon('telephone') ?></a>
<?php endif; ?>
<?php if ($waHref !== null): ?>
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener">
<?= icon('whatsapp') ?><span>WhatsApp</span>
</a>
<a class="btn btn--outline btn--icon" href="<?= e($waHref) ?>" target="_blank" rel="noopener" aria-label="<?= e($person['name']) ?> per WhatsApp schreiben"><?= icon('whatsapp') ?></a>
<?php endif; ?>
<?php if ($mailHref !== null): ?>
<a class="btn btn--icon" href="<?= e($mailHref) ?>" aria-label="<?= e($person['name']) ?> eine E-Mail schreiben"><?= icon('envelope') ?></a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</article>

View File

@@ -11,12 +11,18 @@ declare(strict_types=1);
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
* $anchor string — Sektions-ID (Default 'ansprechpartner').
* $tinted bool — aufgehelltes Vollbreite-Band (Default true).
* $columns int — Spaltenanzahl auf Desktop (null = automatisch bis 5).
* $group_by_department bool — Karten nach Abteilung untergruppieren
* (Zwischenüberschrift je Abteilung, Karten dann ohne eigenen
* Abteilungs-Kicker; löst die Waisen-Optik bei 5 Karten im
* 4er-Raster). Reihenfolge = JSON-Reihenfolge.
* $embedded bool — nur Panel (div + h2 + Grid) statt eigener section,
* für Seiten, die mehrere Grids in EINER Sektion bündeln
* (/ansprechpartner mit Bereichs-Tabs). tinted/anchor der
* Sektion liefert dann die Seite.
*/
$data = json_load('ansprechpartner');
$anchor = $anchor ?? 'ansprechpartner';
$tinted = $tinted ?? true;
$columns = $columns ?? null;
$title = $title ?? $data['title'];
$intro = $intro ?? ($data['intro'] ?? '');
@@ -37,14 +43,11 @@ if (!empty($names)) {
if ($persons === []) {
return;
}
?>
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
<div class="container">
if (!empty($embedded)): ?>
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
<h2><?= e($title) ?></h2>
<?php if ($intro !== ''): ?>
<p class="persons__intro"><?= e($intro) ?></p>
<?php endif; ?>
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>">
<ul class="persons__grid">
<?php foreach ($persons as $person): ?>
<li>
<?php component('person-card', ['person' => $person]); ?>
@@ -52,4 +55,38 @@ if ($persons === []) {
<?php endforeach; ?>
</ul>
</div>
<?php return; endif; ?>
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
<div class="container">
<h2><?= e($title) ?></h2>
<?php if ($intro !== ''): ?>
<p class="persons__intro"><?= e($intro) ?></p>
<?php endif; ?>
<?php if (!empty($group_by_department)): ?>
<?php
$byDepartment = [];
foreach ($persons as $person) {
$byDepartment[$person['department'] ?? ''][] = $person;
}
?>
<?php foreach ($byDepartment as $departmentLabel => $departmentPersons): ?>
<h3 class="persons__group-title"><?= e($departmentLabel) ?></h3>
<ul class="persons__grid persons__grid--grouped">
<?php foreach ($departmentPersons as $person): ?>
<li>
<?php component('person-card', ['person' => $person, 'level' => 'h4', 'hide_department' => true]); ?>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
<?php else: ?>
<ul class="persons__grid">
<?php foreach ($persons as $person): ?>
<li>
<?php component('person-card', ['person' => $person]); ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</section>

View File

@@ -4,25 +4,68 @@ declare(strict_types=1);
/**
* Alternierende Bild/Text-Sektion. Props:
* $section array{id, title, text, image, flip?, tinted?, cta?} (aus home.json o.ä.)
* $section array{id, title, text, image?, gallery?, flip?, tinted?, polaroid?, cta?} (aus home.json o.ä.)
* $level int Überschriften-Ebene (default 2)
* tinted: sanft aufgehelltes Panel hinter dem Inhalt (Abgrenzung vom Seitenhintergrund).
* polaroid: Bild als Polaroid (dicker heller Rahmen, leicht gekippt; flip kippt gegenläufig).
* Trägt ein Bild ein optionales caption-Feld, erscheint es als Bildunterschrift
* auf dem weißen Unterrand des Rahmens (Abel-Versalien, wie Historie-Captions).
* gallery: Array von Bildern statt image — erstes Bild groß, weitere darunter
* halbbreit nebeneinander (Collage). Mit polaroid kippen die Rahmen abwechselnd.
* text: String oder Liste von Absätzen (mehr als zwei sollten hier nicht stehen —
* dann ist es keine Bild/Text-Sektion mehr).
*/
$level = $level ?? 2;
$flip = !empty($section['flip']);
$tinted = !empty($section['tinted']);
$polaroid = !empty($section['polaroid']);
// Ohne image UND ohne gallery läuft die Sektion einspaltig („split--plain") —
// für Textabschnitte, für die es (noch) kein Foto gibt, z. B. „Anfahrt und
// Parken" auf einer Veranstaltungs-Seite. Ohne diesen Zweig liefe img.php
// unten auf ein fehlendes Bild.
$hasMedia = !empty($section['image']) || !empty($section['gallery']);
$paragraphs = is_array($section['text']) ? $section['text'] : [$section['text']];
?>
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($section['id']) ?>">
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?><?= $polaroid ? ' split--polaroid' : '' ?><?= $hasMedia ? '' : ' split--plain' ?>" id="<?= e($section['id']) ?>">
<div class="container split__inner">
<div class="split__text">
<h<?= $level ?>><?= e($section['title']) ?></h<?= $level ?>>
<p><?= e($section['text']) ?></p>
<?php foreach ($paragraphs as $paragraph): ?>
<p><?= e($paragraph) ?></p>
<?php endforeach; ?>
<?php if (!empty($section['cta'])): ?>
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
<?php endif; ?>
</div>
<?php if ($hasMedia): ?>
<div class="split__media">
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
<?php if (!empty($section['gallery'])): ?>
<div class="split__gallery">
<?php foreach ($section['gallery'] as $i => $galleryImage): ?>
<?php $sizes = $i === 0 ? '(max-width: 992px) 100vw, 50vw' : '(max-width: 992px) 50vw, 25vw'; ?>
<?php if ($polaroid): ?>
<figure class="polaroid">
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
<?php if (!empty($galleryImage['caption'])): ?>
<figcaption class="polaroid__caption"><?= e($galleryImage['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<?php else: ?>
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php elseif ($polaroid): ?>
<figure class="polaroid">
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
<?php if (!empty($section['image']['caption'])): ?>
<figcaption class="polaroid__caption"><?= e($section['image']['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<?php else: ?>
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</section>

View File

@@ -10,11 +10,14 @@ declare(strict_types=1);
*/
$club = json_load('club');
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
// serverseitigen Feldfehler aus der Action.
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
'mail' => 'Deine Anfrage konnte gerade nicht gesendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
];
@@ -43,7 +46,8 @@ $f = [
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
</div>
<form class="form" method="post" action="<?= e(url('sportheimbuchung-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
<?php if ($sent): ?>
<p class="form__success">Danke für deine Anfrage! Wir melden uns kurzfristig bei dir.</p>
<?php elseif ($error !== null): ?>

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* Ausgaben-Archiv der Stadionzeitung, ganze Karte verlinkt auf die Ausgabe.
* Props: $issues array aus data/stadionzeitung.json (bereits neueste zuerst).
* Leer → freundlicher Hinweis statt leerer Sektion (nie brechen).
*/
$issues = $issues ?? [];
?>
<section class="section stadionzeitung-cards">
<div class="container">
<h2 class="visually-hidden">Ausgaben der Stadionzeitung</h2>
<?php if ($issues === []): ?>
<p class="text-muted">Noch keine Ausgabe digital verfügbar — zur nächsten Heimspiel-Stadionzeitung schauen wir wieder vorbei.</p>
<?php else: ?>
<ul class="stadionzeitung-cards__grid">
<?php foreach ($issues as $issue): ?>
<li>
<a class="stadionzeitung-card" href="<?= e(url('stadionzeitung/' . $issue['slug'])) ?>">
<div class="stadionzeitung-card__media">
<?php $firstPage = $issue['images'][0] ?? null; ?>
<?php if ($firstPage !== null && ($firstPage['type'] ?? 'image') === 'live' && ($firstPage['page'] ?? '') === 'cover'): ?>
<?php /* Seit dem automatischen Cover (Phase 3) beginnt jede Ausgabe mit der
echten, live gerenderten Titelseite statt einem Canva-Bild — die
dient hier direkt als Karten-Vorschau, keine zweite Bildquelle. */ ?>
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
<?php elseif ($firstPage !== null): ?>
<?php component('img', ['image' => $firstPage, 'sizes' => '(max-width: 600px) 100vw, 360px', 'class' => 'stadionzeitung-card__img']); ?>
<?php endif; ?>
</div>
<div class="stadionzeitung-card__body">
<h3 class="stadionzeitung-card__name">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>
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
</div>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/**
* Automatisch generiertes Stadionzeitung-Cover (Phase 3) — ersetzt das bisherige
* Canva-Titelbild. Einzige fotografische Stadionzeitungs-Seite: Vereins-Hero-Bild
* als Hintergrund (dieselbe Quelle wie die Startseite, data/home.json — nie
* duplizieren) mit Abdunklungs-Verlauf, das freigestellte Spielerfoto ist der
* Star (sehr großformatig, rechts randabfallend). Kopf wie ein echtes
* Zeitschriften-Titelblatt: kleine rote Kicker-Zeile (Saison/Datum), riesiger
* Masthead mit Logo auf gleicher Höhe, dezente Tagline darunter. Unten links
* gestapelt: Begegnung als große freie Typografie (kein Kasten) über einer
* roten Trennlinie und Slogan + QR-Plakette. Dünne Passepartout-Rahmenlinie
* gibt den "druckfertig"-Effekt.
*
* Selbst-Containment: alle Maße hier laufen über CSS Container Queries (cqi,
* relativ zur eigenen Breite von .stadionzeitung-cover), NICHT über die
* Website-Schriftgrößen-Tokens aus tokens.css — das Cover sieht dadurch in
* jedem Kontext (Karten-Vorschau, Swipe-Viewer, Druck) proportional exakt
* gleich aus, unabhängig von Viewport/Root-Schriftgröße. Einzige Ausnahme
* ganz bewusst: die crest-Komponente (geteiltes Bauteil, bekommt ihre Größe
* über --crest-size lokal in cqi mitgegeben, keine eigene Kopie nötig).
*
* Props: $issue (Eintrag aus data/stadionzeitung.json: opponent, date, team_key,
* player_image?, qr_image?)
*
* Gegner-Wappen wird zur Laufzeit über den Anstoß-Tag in data/matchcenter.json
* gesucht (next_matches/last_results des Teams) — nie im JSON dupliziert. Kein
* Treffer (z. B. Freundschaftsspiel gegen eine Spielgemeinschaft, die der BFV
* nicht führt) → das eigene Wappen kommt trotzdem aus dem lokalen Vereinslogo,
* der Gegner läuft über den Initialen-Fallback der crest-Komponente.
*/
$issue = $issue ?? [];
$opponent = (string) ($issue['opponent'] ?? '');
$date = (string) ($issue['date'] ?? '');
$teamKey = (string) ($issue['team_key'] ?? '');
$playerImage = $issue['player_image'] ?? null;
$qrImage = $issue['qr_image'] ?? null;
$club = json_load('club');
$heroImage = json_load('home')['hero']['image'] ?? null;
$team = null;
foreach (json_load('matchcenter')['teams'] ?? [] as $t) {
if (($t['key'] ?? '') === $teamKey) {
$team = $t;
break;
}
}
$teamName = (string) ($team['name'] ?? '');
$league = (string) ($team['league'] ?? '');
$season = (string) ($team['season'] ?? '');
$match = null;
foreach (array_merge($team['next_matches'] ?? [], $team['last_results'] ?? []) as $candidate) {
if (str_starts_with((string) ($candidate['kickoff'] ?? ''), $date)) {
$match = $candidate;
break;
}
}
$ownCrest = 'img/logo.svg';
$opponentCrest = null;
if ($match !== null) {
$ownCrest = $match['is_home'] ? $match['home_crest'] : $match['away_crest'];
$opponentCrest = $match['is_home'] ? $match['away_crest'] : $match['home_crest'];
}
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
$linkText = club_domain();
$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : ''));
// 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'] ?? ''));
?>
<div class="stadionzeitung-cover">
<?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 endif; ?>
<div class="stadionzeitung-cover__inner">
<header class="stadionzeitung-cover__masthead">
<?php if ($kicker !== ''): ?>
<p class="stadionzeitung-cover__kicker"><?= e($kicker) ?></p>
<?php endif; ?>
<div class="stadionzeitung-cover__masthead-row">
<img class="stadionzeitung-cover__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<p class="stadionzeitung-cover__wordmark">Stadionzeitung</p>
</div>
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
</header>
<div class="stadionzeitung-cover__corner">
<div class="stadionzeitung-cover__headline">
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
ruhiger) — die beiden Logos sprechen für sich. */ ?>
<div class="stadionzeitung-cover__crests">
<?php component('crest', ['src' => $ownCrest, 'name' => $club['name'] ?? '']); ?>
<?php component('crest', ['src' => $opponentCrest, 'name' => $opponent]); ?>
</div>
<p class="stadionzeitung-cover__matchup">Heimspiel gegen <?= e($opponent) ?></p>
<?php if ($teamName !== '' || $league !== ''): ?>
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
<?php endif; ?>
</div>
<?php if ($teaser !== ''): ?>
<p class="stadionzeitung-cover__teaser">
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>
<?= e($teaser) ?>
</p>
<?php endif; ?>
<?php /* Bewusst ohne Vereins-Slogan (Felix, 29.07.2026: ruhiger) — der
Slogan bleibt Footer/JSON-LD der Website vorbehalten. */ ?>
<footer class="stadionzeitung-cover__footer">
<?php /* QR bewusst minimal (Felix, 29.07.2026): Code + eine Zeile,
keine Plakette/kein Rahmen mehr. */ ?>
<?php if (!empty($qrImage)): ?>
<div class="stadionzeitung-cover__digital">
<img class="stadionzeitung-cover__qr" src="<?= e(asset($qrImage)) ?>" alt="" width="60" height="60" loading="lazy">
<span class="stadionzeitung-cover__link">Digital lesen · <?= e($linkText) ?></span>
</div>
<?php endif; ?>
</footer>
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* Druckfassung einer Stadionzeitung-Ausgabe (Route ?druck=1, siehe
* app/pages/stadionzeitung-ausgabe.php) — die Seiten in BROSCHÜREN-Reihenfolge
* statt Lesereihenfolge: das Heft wird als A4 quer beidseitig gedruckt, die
* Blätter werden gestapelt und mittig gefaltet. Damit dabei vorn das Cover
* und innen die richtige Abfolge liegt, trägt Blatt 1 vorne [letzte Seite |
* Seite 1], hinten [Seite 2 | vorletzte Seite] usw. — die klassische
* Sattelheftungs-Montage. Deshalb liegt die Montage HIER im PHP (die
* DOM-Reihenfolge selbst ist die Druckreihenfolge), nicht in Druck-CSS:
* CSS kann Seiten nicht über Blattgrenzen hinweg umsortieren.
*
* Die Seitenzahl wird dafür auf ein Vielfaches von 4 gebracht: fehlende
* Seiten füllt eine weiße Leerseite VOR der Rückseite auf (die Rückseite
* bleibt immer außen). Die Regel fürs Heft bleibt trotzdem: Lücken beim
* Erzeugen gemeinsam sauber lösen, Leerseiten sind der Notnagel.
*
* Auf dem Bildschirm zeigt die Route eine Blatt-Vorschau (jedes Blatt als
* A4-quer-Fläche mit Beschriftung „Blatt 1 · Vorderseite") plus
* Druck-Anleitung; assets/js/stadionzeitung-druck.js liefert den
* „Jetzt drucken"-Button (progressive enhancement — ohne JS druckt der
* Browser-Dialog dieselbe Ansicht).
*
* Props: $issue Eintrag aus data/stadionzeitung.json
*/
$issue = $issue ?? [];
$pages = $issue['images'] ?? [];
if ($pages === []) {
return;
}
$hasLive = false;
foreach ($pages as $entry) {
if (($entry['type'] ?? 'image') === 'live') {
$hasLive = true;
break;
}
}
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
// Auf Vielfaches von 4 auffüllen — Leerseiten direkt vor der letzten Seite,
// damit die gelieferte Rückseite die äußere Rückseite des Hefts bleibt.
$fill = (4 - count($pages) % 4) % 4;
if ($fill > 0) {
array_splice($pages, count($pages) - 1, 0, array_fill(0, $fill, ['type' => 'leer']));
}
$n = count($pages);
// Sattelheftung: Blatt k (0-basiert) trägt vorne [n-1-2k | 2k] und hinten
// [2k+1 | n-2-2k] (0-basierte Seitenindizes). Beim beidseitigen Druck an der
// KURZEN Kante spiegeln, sonst steht jede Rückseite kopf.
$sides = [];
for ($k = 0; $k < intdiv($n, 4); $k++) {
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Vorderseite', 'pages' => [$n - 1 - 2 * $k, 2 * $k]];
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Rückseite', 'pages' => [2 * $k + 1, $n - 2 - 2 * $k]];
}
?>
<section class="stadionzeitung-druck">
<ol class="stadionzeitung-druck__blaetter">
<?php foreach ($sides as $side): ?>
<li class="stadionzeitung-druck__blatt">
<p class="stadionzeitung-druck__blatt-label" aria-hidden="true"><?= e($side['label']) ?></p>
<div class="stadionzeitung-druck__flaeche">
<?php foreach ($side['pages'] as $pageIndex): ?>
<div class="stadionzeitung-druck__slot">
<?php component('stadionzeitung-seite', [
'entry' => $pages[$pageIndex],
'issue' => $issue,
'data' => $liveData,
// In der Druckfassung wird JEDE Seite sofort geladen:
// lazy geladene Bilder, die nie im Sichtfeld waren,
// druckt Chrome sonst als leere Flächen.
'eager' => true,
]); ?>
</div>
<?php endforeach; ?>
</div>
</li>
<?php endforeach; ?>
</ol>
<?php /* Anleitung + Druck-Button bewusst UNTER den Blättern (Wunsch Felix,
29.07.2026): erst die Vorschau komplett durchsehen, ganz unten
drucken. */ ?>
<div class="stadionzeitung-druck__hilfe">
<h2 class="stadionzeitung-druck__hilfe-title">Drucken</h2>
<p>
<?= intdiv($n, 4) ?> Blätter A4 quer<?= $fill > 0 ? ', mit ' . $fill . ' weißen Füllseiten auf ' . $n . ' Seiten aufgefüllt' : ' (' . $n . ' Seiten)' ?>.
Beidseitig drucken und dabei <strong>an der kurzen Kante spiegeln</strong>,
danach alle Blätter aufeinanderlegen und mittig falten — fertig ist das Heft.
</p>
<button class="btn stadionzeitung-druck__print-btn" type="button" data-druck-print hidden>
<?= icon('printer') ?> Jetzt drucken
</button>
</div>
</section>

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* Ergebnis-Rückblick der Stadionzeitung (Live-Seite) — die letzten Ergebnisse
* aller Mannschaften als gestaltete Seite (Wappen + Ergebnis aus
* data/matchcenter.json), bewusst KEINE Instagram-Post-Bilder: die Seite
* funktioniert damit für jedes Spiel, auch ohne passenden Post.
*
* Zwei Zeilen pro Spiel (Heim über Gast) statt einer langen Einzelzeile —
* Vereinsnamen wie „(SG) 1.FC Michelau / 1.FC Fortuna Roth" passen auf der
* schmalen A5-Seite nie nebeneinander. Dasselbe Bauprinzip wie match-feature
* im „Nächste Spiele"-Band.
*
* Props: $groups stadionzeitung_live_data()['ergebnisse'] — je Team:
* team_name/league/matches[] (Matchcenter-Rohform inkl.
* goals_home/goals_away, nur Spiele mit erfasstem Ergebnis).
*/
$groups = $groups ?? [];
// Matchcenter-Ticket wohnt hier (nicht auf der volleren Vorschau-Seite):
// geteilter QR, von bin/stadionzeitung-add.php mit der aktuellen base_url
// erzeugt — fehlt er, erscheint nur der Text-Link.
$matchcenterQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-matchcenter.png')
? 'img/stadionzeitung/qr-matchcenter.png'
: null;
$matchcenterLink = club_domain('matchcenter');
?>
<div class="stadionzeitung-ergebnisse">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Rückblick</p>
<h2 class="stadionzeitung-page__heading">Letzte Ergebnisse</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($groups === []): ?>
<p class="text-muted">Aktuell keine Ergebnisse.</p>
<?php endif; ?>
<?php foreach ($groups as $group): ?>
<section class="stadionzeitung-ergebnisse__team">
<h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3>
<?php foreach ($group['matches'] ?? [] as $match): ?>
<div class="stadionzeitung-ergebnisse__match">
<p class="stadionzeitung-ergebnisse__meta">
<?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?>
</p>
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null],
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null]] as $side): ?>
<div class="stadionzeitung-ergebnisse__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__row--own' : '' ?>">
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
<span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span>
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $side['goals']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Alle Spiele, Ergebnisse und Tabellen im Matchcenter</p>
<p class="stadionzeitung-page__cta-link"><?= e($matchcenterLink) ?></p>
</div>
<?php if ($matchcenterQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($matchcenterQr)) ?>" 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,58 @@
<?php
declare(strict_types=1);
/**
* Historie-Seite der Stadionzeitung (Live-Seite) — teasert die neu auf der
* Website veröffentlichte Vereinsgeschichte (/historie) an und ruft dazu auf,
* Material an die Historie-Mailadresse zu schicken (Zulauf für die Chronik
* zum 120-jährigen Bestehen 2028, siehe CLAUDE.md → Chronik). Fester
* Baustein jeder Ausgabe (Felix, 29.07.2026).
*
* Bilder und Mailadresse kommen aus den geteilten Quellen (data/historie.json
* liegt dasselbe Luftbild zugrunde, club.json → email_historie) — nie
* duplizieren. QR: geteilter qr-historie.png (bin/stadionzeitung-add.php).
*/
$emailHistorie = (string) (json_load('club')['email_historie'] ?? '');
$historieQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-historie.png')
? 'img/stadionzeitung/qr-historie.png'
: null;
$historieLink = club_domain('historie');
?>
<div class="stadionzeitung-historie">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Historie</p>
<h2 class="stadionzeitung-page__heading">Unsere Geschichte lebt</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<p class="stadionzeitung-historie__text">Von der Gründung 1908 bis heute: Wir haben unsere Vereinsgeschichte neu aufgearbeitet und erzählen sie jetzt auf unserer Website, mit Fotos und Geschichten aus fast 120 Jahren.</p>
<?php /* Archiv-Anmutung passend zum Aufruf („Fotoalben und Schuhkartons"):
die Fotos als leicht gedrehte Polaroids, der Aufruf als Notizzettel. */ ?>
<div class="stadionzeitung-historie__bilder">
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--links">
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/luftbild-historisch.jpg')) ?>" alt="Historische Luftaufnahme unserer Sportanlage" loading="lazy" decoding="async">
<figcaption class="stadionzeitung-historie__caption">Unsere Sportanlage von oben</figcaption>
</figure>
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--rechts">
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/1972-fcbayern.png')) ?>" alt="Unser Freundschaftsspiel gegen den FC Bayern München 1972" loading="lazy" decoding="async">
<figcaption class="stadionzeitung-historie__caption">1972: Zu Gast bei uns, der FC Bayern</figcaption>
</figure>
</div>
<p class="stadionzeitung-historie__aufruf">
<strong>Und jetzt bist du dran:</strong> Ein großer Teil unserer Geschichte steckt noch in Fotoalben und Schuhkartons. Wenn du Fotos, Zeitungsausschnitte oder Erinnerungen hast, schreib uns an
<span class="stadionzeitung-historie__mail"><?= e($emailHistorie) ?></span>
</p>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Die ganze Geschichte auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($historieLink) ?></p>
</div>
<?php if ($historieQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($historieQr)) ?>" 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,59 @@
<?php
declare(strict_types=1);
/**
* Impressums-Seite der Stadionzeitung (Live-Seite) — ersetzt das bisherige
* Bild aus dem Canva-Bestand. Alle Angaben kommen aus data/club.json
* (legal_name, address, email, board — dieselbe Quelle wie das
* Website-Impressum, nie duplizieren): ändert sich der Vorstand, stimmt die
* nächste Ausgabe automatisch. Zentriertes Layout mit Logo und dünner
* Rahmenlinie, wie das Mitglied-Poster. Juristische Seite, deshalb der
* ausgeschriebene Vereinsname statt Wir-Form (Sprachregel-Ausnahme).
*/
$club = json_load('club');
$legalName = (string) ($club['legal_name'] ?? $club['name'] ?? '');
$address = $club['address'] ?? [];
$email = (string) ($club['email'] ?? '');
// club_domain() statt base_url: das Heft wird gedruckt, da darf nie
// „localhost:8000" stehen.
$domain = club_domain();
$board = $club['board']['members'] ?? [];
$boardNote = (string) ($club['board']['note'] ?? '');
?>
<div class="stadionzeitung-impressum">
<img class="stadionzeitung-impressum__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<p class="stadionzeitung-page__kicker">Impressum</p>
<h2 class="stadionzeitung-impressum__name"><?= e($legalName) ?></h2>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Herausgeber</h3>
<p><?= e($legalName) ?></p>
<p><?= e((string) ($address['street'] ?? '')) ?></p>
<p><?= e(trim(($address['zip'] ?? '') . ' ' . ($address['city'] ?? ''))) ?></p>
</div>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Kontakt</h3>
<p>E-Mail: <?= e($email) ?></p>
<p>Internet: <?= e($domain) ?></p>
</div>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Inhaltlich verantwortlich</h3>
<p><?= e($legalName) ?></p>
</div>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Design und Layout</h3>
<p>Felix Schneider</p>
</div>
<?php if ($board !== []): ?>
<div class="stadionzeitung-impressum__block">
<h3 class="stadionzeitung-impressum__label">Vorstand</h3>
<?php if ($boardNote !== ''): ?>
<p class="stadionzeitung-impressum__note"><?= e($boardNote) ?></p>
<?php endif; ?>
<p><?= e(implode(' · ', array_map(
static fn (array $m): string => trim((string) ($m['name'] ?? '') . (!empty($m['role']) ? ' (' . $m['role'] . ')' : '')),
$board
))) ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* Interview-Seite der Stadionzeitung (Live-Seite) — das Gespräch, auf das die
* Teaser-Zeile des Covers verweist (z. B. mit dem Trainer über die Neuzugänge).
* Der Inhalt ist KEIN Skript-Input: er wird wie das Vorwort im Chat gepflegt —
* Felix schickt Fragen/Antworten, Claude trägt sie ins `interview`-Feld der
* Ausgabe in data/stadionzeitung.json ein:
* teaser Coverzeile („Unser Trainer … über die Neuzugänge")
* heading Überschrift der Seite
* person_name / person_role wer spricht
* intro optionaler Vorspann (kursiv, Serif)
* fragen[] { frage, antwort }
* photo optional, Porträt — entweder ein direkter Asset-Pfad
* (z. B. "img/persons/ali-kilic.jpg", die geteilten
* Personenfotos haben keine Breiten-Varianten) oder
* base/widths wie verfasser_photo
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe hinweg weiter
* (wie `vorwort`) und plant die Seite nur ein, wenn das Feld existiert.
*
* Fragen in Abel fett, Antworten in der Lese-Serif (--ff-serif, PT Serif) —
* dieselbe Begründung wie beim Vorwort: redaktioneller Text, keine UI-Fläche.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json)
*/
$issue = $issue ?? [];
$interview = $issue['interview'] ?? null;
if (!is_array($interview)) {
return;
}
$heading = trim((string) ($interview['heading'] ?? $interview['teaser'] ?? 'Interview'));
$person = trim(implode(' · ', array_filter([
(string) ($interview['person_name'] ?? ''),
(string) ($interview['person_role'] ?? ''),
])));
$photo = $interview['photo'] ?? null;
$photoSrc = null;
if (is_string($photo) && $photo !== '') {
$photoSrc = $photo;
} elseif (is_array($photo) && !empty($photo['base']) && !empty($photo['widths'])) {
$photoSrc = $photo['base'] . '-' . min($photo['widths']) . '.jpg';
}
?>
<div class="stadionzeitung-interview">
<div class="stadionzeitung-interview__head-row">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Interview</p>
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
<?php if ($person !== ''): ?>
<p class="stadionzeitung-page__sub"><?= e($person) ?></p>
<?php endif; ?>
</header>
<?php if ($photoSrc !== null): ?>
<img class="stadionzeitung-interview__portrait" src="<?= e(asset($photoSrc)) ?>" alt="" loading="lazy" decoding="async">
<?php endif; ?>
</div>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if (!empty($interview['intro'])): ?>
<p class="stadionzeitung-interview__intro"><?= e((string) $interview['intro']) ?></p>
<?php endif; ?>
<?php foreach ($interview['fragen'] ?? [] as $qa): ?>
<div class="stadionzeitung-interview__qa">
<p class="stadionzeitung-interview__frage"><?= e((string) ($qa['frage'] ?? '')) ?></p>
<p class="stadionzeitung-interview__antwort"><?= e((string) ($qa['antwort'] ?? '')) ?></p>
</div>
<?php endforeach; ?>
</div>

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* Ansprechpartner-Seite der Stadionzeitung (Live-Seite) — die Fußball-
* Ansprechpartner aus data/ansprechpartner.json (geteilte Quelle mit
* /ansprechpartner, nie Namen/Nummern duplizieren), wie die „KONTAKT"-Seite
* der Canva-Vorlage: Abteilung + Name + Telefonnummer, rechts das runde
* Porträt. Keine Props — die Seite zeigt immer den aktuellen Stand der
* geteilten Quelle (Personen ändern sich zwischen Ausgaben praktisch nie;
* ein Snapshot würde hier nur veraltete Nummern konservieren).
*/
$persons = array_values(array_filter(
json_load('ansprechpartner')['persons'] ?? [],
static fn (array $p): bool => ($p['group'] ?? '') === 'Fußball'
));
// CTA unter der Liste: QR + Link auf /mitmachen (Wunsch Felix, 29.07.2026).
// Der QR wird von bin/stadionzeitung-add.php miterzeugt (qr-mitmachen.png,
// geteilt über alle Ausgaben — die Ziel-URL ist immer dieselbe); fehlt er
// (noch kein Lauf seit Einführung), erscheint nur der Text-Link.
$mitmachenQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitmachen.png')
? 'img/stadionzeitung/qr-mitmachen.png'
: null;
$mitmachenLink = club_domain('mitmachen');
?>
<div class="stadionzeitung-kontakt">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Kontakt</p>
<h2 class="stadionzeitung-page__heading">Ansprechpartner im Verein</h2>
</header>
<?php /* Bewusst ohne die Trennlinie der Geschwister-Seiten: die Liste + CTA
brauchen die volle Seitenhöhe (Felix, 29.07.2026). Die erste
Listenzeile bringt ihre eigene Unterkante mit, das trennt genug. */ ?>
<ul class="stadionzeitung-kontakt__list">
<?php foreach ($persons as $person): ?>
<li class="stadionzeitung-kontakt__item">
<div class="stadionzeitung-kontakt__body">
<p class="stadionzeitung-kontakt__department"><?= e((string) ($person['department'] ?? '')) ?></p>
<p class="stadionzeitung-kontakt__name"><?= e((string) ($person['name'] ?? '')) ?></p>
<?php if (!empty($person['phone'])): ?>
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['phone']) ?></p>
<?php elseif (!empty($person['email'])): ?>
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['email']) ?></p>
<?php endif; ?>
</div>
<?php if (!empty($person['image'])): ?>
<img class="stadionzeitung-kontakt__portrait" src="<?= e(asset((string) $person['image'])) ?>" alt="" width="56" height="56" loading="lazy" decoding="async">
<?php else: ?>
<span class="stadionzeitung-kontakt__portrait stadionzeitung-kontakt__portrait--icon" aria-hidden="true"><?= icon('person') ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Du möchtest selbst aktiv werden?</p>
<p class="stadionzeitung-page__cta-link"><?= e($mitmachenLink) ?></p>
</div>
<?php if ($mitmachenQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($mitmachenQr)) ?>" 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,43 @@
<?php
declare(strict_types=1);
/**
* Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste:
* riesige zentrierte Coolvetica-Headline („Werde Teil des Nullachters", das
* letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile,
* unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein
* jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den
* Ansprechpartnern ein) — Look bewusst weiß mit Rot-Akzent, damit die Seite
* heraussticht, ohne die helle Heft-Optik zu brechen (Felix, 29.07.2026).
*
* Keine Props — die Seite ist statisch bis auf den geteilten QR
* (qr-mitglied-werden.png, von bin/stadionzeitung-add.php mit der aktuellen
* base_url erzeugt; fehlt er, erscheint nur der Text-Link).
*/
$mitgliedQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitglied-werden.png')
? 'img/stadionzeitung/qr-mitglied-werden.png'
: null;
$mitgliedLink = club_domain('mitglied-werden');
?>
<div class="stadionzeitung-mitglied">
<div class="stadionzeitung-mitglied__poster">
<img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<h2 class="stadionzeitung-mitglied__headline">
<span class="stadionzeitung-mitglied__line">Werde Teil</span>
<span class="stadionzeitung-mitglied__line">des</span>
<span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Nullachters</span>
</h2>
<p class="stadionzeitung-mitglied__claim">Seit 1908 in Kulmbach zuhause. Egal ob auf dem Platz, in der Halle oder am Spielfeldrand: bei uns ist Platz für dich.</p>
<p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p>
<?php /* Bewusst KEIN generischer CTA-Fuß wie auf den Listen-Seiten: das
Poster endet mit dem großen zentrierten QR (Felix, 29.07.2026) —
die Link-Zeile darunter bleibt für alle, die nicht scannen. */ ?>
<div class="stadionzeitung-mitglied__qr-block">
<?php if ($mitgliedQr !== null): ?>
<img class="stadionzeitung-mitglied__qr" src="<?= e(asset($mitgliedQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
<?php endif; ?>
<span class="stadionzeitung-mitglied__link"><?= e($mitgliedLink) ?></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* Momente-Seite der Stadionzeitung (Live-Seite, Heft-Abschluss) — die vier
* neuesten Fotos aus unserem Instagram-Cache als 2×2-Galerie plus
* „Folge uns"-Ticket mit QR aufs Profil. Pflegt sich selbst: im Entwurf
* immer die aktuellsten Bilder, beim Veröffentlichen eingefroren
* (stadionzeitung_build_snapshot(), app/helpers.php).
*
* Props: $items stadionzeitung_live_data()['momente'] — image/alt je Foto.
* Profil-URL aus club.json → social.instagram (geteilte Quelle), QR:
* geteilter qr-instagram.png (bin/stadionzeitung-add.php).
*/
$items = $items ?? [];
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
$instagramHandle = '@' . trim((string) parse_url($instagramUrl, PHP_URL_PATH), '/');
$momenteQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')
? 'img/stadionzeitung/qr-instagram.png'
: null;
?>
<div class="stadionzeitung-momente">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Instagram</p>
<h2 class="stadionzeitung-page__heading">Aktuelles von Instagram</h2>
</header>
<?php /* Bewusst ohne Trennlinie und mit verdichteten Abständen: jeder
gewonnene Millimeter geht in die Fotogröße (Felix, 29.07.2026). */ ?>
<?php if ($items === []): ?>
<p class="text-muted">Folge uns auf Instagram für alle Momente rund um den Verein.</p>
<?php else: ?>
<div class="stadionzeitung-momente__galerie">
<?php foreach ($items as $item): ?>
<img class="stadionzeitung-momente__foto" src="<?= e(asset((string) $item['image'])) ?>" alt="<?= e((string) $item['alt']) ?>" loading="lazy" decoding="async">
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Folge uns auf Instagram</p>
<p class="stadionzeitung-page__cta-link"><?= e($instagramHandle) ?></p>
</div>
<?php if ($momenteQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($momenteQr)) ?>" 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,90 @@
<?php
declare(strict_types=1);
/**
* News-Seite der Stadionzeitung (Live-Seite) — eigene, einheitliche Liste im
* Kopf-Muster der Geschwister-Seiten statt des geteilten news-feed der
* /news-Seite: dort variieren die Medienspalten (Bild, Wappen, nichts) und
* die Titel springen in der Einrückung — auf der schmalen A5-Seite braucht
* jede Zeile dieselbe feste Medienspalte (Wappen, Foto-Thumbnail oder
* Icon-Badge als Fallback), damit alle Titel bündig stehen.
*
* Props: $items stadionzeitung_live_data()['news'] — normalisierte Einträge
* aus news_build_feed() (date/title/text/image/crest/source).
*/
$items = $items ?? [];
$sourceLabel = static fn (string $source): string => match ($source) {
'ergebnis' => 'Ergebnis',
'instagram' => 'Instagram',
default => 'Meldung',
};
// CTA unter der Liste: QR + Link auf /news (geteilter QR wie bei der
// Kontakt-Seite, von bin/stadionzeitung-add.php mit der aktuellen base_url
// erzeugt — fehlt er, erscheint nur der Text-Link).
$newsQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-news.png')
? 'img/stadionzeitung/qr-news.png'
: null;
$newsLink = club_domain('news');
?>
<div class="stadionzeitung-news">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">News</p>
<h2 class="stadionzeitung-page__heading">Aktuelles aus dem Katzbachtal</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($items === []): ?>
<p class="text-muted">Aktuell keine News.</p>
<?php endif; ?>
<ul class="stadionzeitung-news__list">
<?php foreach ($items as $item): ?>
<?php
$source = (string) ($item['source'] ?? 'news');
// Instagram-Titel ist die erste Zeile der Caption — der Text darunter
// wäre dieselbe Caption noch einmal, deshalb dort nur der Titel.
$text = $source === 'instagram' ? '' : trim((string) ($item['text'] ?? ''));
$image = $item['image'] ?? null;
$imageSrc = null;
if (is_array($image)) {
// Beide Bildformen aus news_build_feed(): Instagram liefert src,
// redaktionelle Meldungen base/widths (kleinste Variante reicht
// für das winzige Thumbnail).
$imageSrc = $image['src'] ?? (isset($image['base'], $image['widths']) ? $image['base'] . '-' . min($image['widths']) . '.jpg' : null);
}
?>
<li class="stadionzeitung-news__item">
<?php if (!empty($item['crest']['src']) || (!empty($item['crest']['name']) && $source === 'ergebnis')): ?>
<span class="stadionzeitung-news__media stadionzeitung-news__media--crest">
<?php component('crest', ['src' => $item['crest']['src'] ?? null, 'name' => (string) ($item['crest']['name'] ?? '')]); ?>
</span>
<?php elseif ($imageSrc !== null): ?>
<img class="stadionzeitung-news__media stadionzeitung-news__media--img" src="<?= e(asset($imageSrc)) ?>" alt="" loading="lazy" decoding="async">
<?php else: ?>
<span class="stadionzeitung-news__media stadionzeitung-news__media--icon" aria-hidden="true"><?= icon('newspaper') ?></span>
<?php endif; ?>
<div class="stadionzeitung-news__body">
<p class="stadionzeitung-news__meta"><?= e($sourceLabel($source)) ?><?php if (!empty($item['date'])): ?> · <time datetime="<?= e((string) $item['date']) ?>"><?= e(german_date((string) $item['date'])) ?></time><?php endif; ?></p>
<p class="stadionzeitung-news__title"><?= e((string) ($item['title'] ?? '')) ?></p>
<?php if ($text !== ''): ?>
<p class="stadionzeitung-news__text"><?= e($text) ?></p>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Mehr News findest du auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($newsLink) ?></p>
</div>
<?php if ($newsQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($newsQr)) ?>" 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,63 @@
<?php
declare(strict_types=1);
/**
* Partner-werden-Seite der Stadionzeitung (Live-Seite) — der Selbstbeweis:
* die Seite wirbt für Anzeigen in genau dem Heft, das der Leser gerade in
* der Hand hält. Fester Baustein jeder Ausgabe (Felix, 29.07.2026).
*
* Sie-Form mit Absicht: /partner-werden spricht Unternehmen an — die eine
* benannte Ausnahme der Wir/Du-Sprachregel (CLAUDE.md). Die drei Pakete
* kommen aus data/partner-werden.json (dieselbe Quelle wie die Website),
* der Sponsoring-Ansprechpartner (2. Vorsitzender) aus club.json → board.
* QR: geteilter qr-partner-werden.png (bin/stadionzeitung-add.php).
*/
$channels = json_load('partner-werden')['channels']['items'] ?? [];
$sponsoringKontakt = null;
foreach (json_load('club')['board']['members'] ?? [] as $member) {
if (($member['role'] ?? '') === '2. Vorsitzender') {
$sponsoringKontakt = (string) $member['name'];
break;
}
}
$partnerQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
? 'img/stadionzeitung/qr-partner-werden.png'
: null;
$partnerLink = club_domain('partner-werden');
?>
<div class="stadionzeitung-partner">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Partner werden</p>
<h2 class="stadionzeitung-page__heading">Hier könnte Ihre Anzeige stehen</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<p class="stadionzeitung-partner__intro">Die Stadionzeitung liegt bei jedem Heimspiel im Katzbachtal aus, unsere Website erreicht Kulmbach jeden Tag. Bringen Sie Ihr Unternehmen dorthin, wo die Region zuhause ist.</p>
<ul class="stadionzeitung-partner__pakete">
<?php foreach ($channels as $channel): ?>
<li class="stadionzeitung-partner__paket">
<span class="stadionzeitung-partner__icon" aria-hidden="true"><?= icon((string) ($channel['icon'] ?? 'geo-alt-fill')) ?></span>
<div class="stadionzeitung-partner__paket-body">
<p class="stadionzeitung-partner__paket-titel"><?= e((string) ($channel['title'] ?? '')) ?></p>
<p class="stadionzeitung-partner__paket-text"><?= e((string) ($channel['text'] ?? '')) ?></p>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php if ($sponsoringKontakt !== null): ?>
<p class="stadionzeitung-partner__kontakt">Ihr Ansprechpartner: <strong><?= e($sponsoringKontakt) ?></strong> · 2. Vorsitzender</p>
<?php endif; ?>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Alle Pakete auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($partnerLink) ?></p>
</div>
<?php if ($partnerQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($partnerQr)) ?>" 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,91 @@
<?php
declare(strict_types=1);
/**
* EINE Seite einer Stadionzeitung-Ausgabe — die gemeinsame Render-Stelle für
* den Swipe-Viewer (stadionzeitung-viewer.php) und die Druckfassung
* (stadionzeitung-druck.php): beide zeigen exakt dieselben Seiten, nur in
* 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 'leer' (weiße Füllseite, nur Druckfassung — hält die
* Seitenzahl auf einem Vielfachen von 4)
* $issue Eintrag aus data/stadionzeitung.json
* $data stadionzeitung_live_data($issue) — vom Aufrufer EINMAL berechnet
* und an jede Seite durchgereicht (nicht pro Seite neu laden)
* $eager bool, Bild ohne lazy-loading laden (erste Seite im Viewer)
*/
$entry = $entry ?? [];
$issue = $issue ?? [];
$data = $data ?? [];
$eager = $eager ?? false;
$type = $entry['type'] ?? 'image';
?>
<?php if ($type === 'leer'): ?>
<div class="stadionzeitung-page stadionzeitung-page--leer" aria-hidden="true"></div>
<?php elseif ($type === 'live' && ($entry['page'] ?? '') === 'cover'): ?>
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
<?php elseif ($type === 'live'): ?>
<div class="stadionzeitung-page">
<?php switch ($entry['page'] ?? ''):
case 'vorwort': ?>
<?php component('stadionzeitung-vorwort', ['issue' => $issue]); ?>
<?php break; ?>
<?php case 'tabelle': ?>
<?php
// Ohne team-Feld (Ausgaben vor der Mehr-Tabellen-Vorlage) zeigt die
// Seite wie bisher die Tabelle der Mannschaft der Ausgabe.
$tabelleTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
component('stadionzeitung-tabelle', ['info' => $data['tabellen'][$tabelleTeam] ?? null]);
?>
<?php break; ?>
<?php case 'interview': ?>
<?php component('stadionzeitung-interview', ['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 'vorschau': ?>
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
<?php break; ?>
<?php case 'kontakt': ?>
<?php component('stadionzeitung-kontakt', []); ?>
<?php break; ?>
<?php case 'mitglied': ?>
<?php component('stadionzeitung-mitglied', []); ?>
<?php break; ?>
<?php case 'historie': ?>
<?php component('stadionzeitung-historie', []); ?>
<?php break; ?>
<?php case 'sportheim': ?>
<?php component('stadionzeitung-sportheim', []); ?>
<?php break; ?>
<?php case 'partner': ?>
<?php component('stadionzeitung-partner', []); ?>
<?php break; ?>
<?php case 'momente': ?>
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
<?php break; ?>
<?php case 'impressum': ?>
<?php component('stadionzeitung-impressum', []); ?>
<?php break; ?>
<?php case 'news': ?>
<?php component('stadionzeitung-news', ['items' => $data['news'] ?? []]); ?>
<?php break; ?>
<?php endswitch; ?>
</div>
<?php else: ?>
<?php component('img', [
'image' => $entry,
'sizes' => '100vw',
'eager' => $eager,
'class' => 'stadionzeitung-page__img',
]); ?>
<?php endif; ?>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Sportheim-Seite der Stadionzeitung (Live-Seite) — Nachbau der gelungenen
* Canva-Seite „Feiern Sie Veranstaltungen im Katzbachtal": Kicker + große
* Headline, Lese-Serif-Text, unten das einblendende Gastraum-Foto mit
* QR-Karte darauf, rote Pinselstrich-Ecken als Gestaltungselement (hier als
* CSS-Näherung, keine Bild-Assets). Fester Baustein jeder Ausgabe
* (Felix, 29.07.2026).
*
* Text kommt aus data/sportheimbuchung.json (hero.text — dieselbe Quelle wie
* /sportheimbuchung, nie duplizieren), Foto aus dem geteilten Bestand,
* QR: geteilter qr-sportheimbuchung.png (bin/stadionzeitung-add.php).
*/
$heroText = (string) (json_load('sportheimbuchung')['hero']['text'] ?? '');
$sportheimQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-sportheimbuchung.png')
? 'img/stadionzeitung/qr-sportheimbuchung.png'
: null;
$sportheimLink = club_domain('sportheimbuchung');
?>
<?php /* Pinselstrich-Ecken bewusst entfernt (Felix, 29.07.2026) — kommen
ggf. später als saubere Bild-Assets zurück. */ ?>
<div class="stadionzeitung-sportheim">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Veranstaltung</p>
<h2 class="stadionzeitung-page__heading">Feiern im Katzbachtal</h2>
</header>
<p class="stadionzeitung-sportheim__text"><?= e($heroText) ?></p>
<div class="stadionzeitung-sportheim__foto-bereich">
<img class="stadionzeitung-sportheim__foto" src="<?= e(asset('img/pages/sportheim-1000.jpg')) ?>" alt="Der Gastraum unseres Sportheims" loading="lazy" decoding="async">
<div class="stadionzeitung-sportheim__foto-fade" aria-hidden="true"></div>
<div class="stadionzeitung-sportheim__qr-karte">
<?php if ($sportheimQr !== null): ?>
<img class="stadionzeitung-sportheim__qr" src="<?= e(asset($sportheimQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
<?php endif; ?>
<span class="stadionzeitung-sportheim__link"><?= e($sportheimLink) ?></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* Tabellen-Seite der Stadionzeitung (Live-Seite) — Kopf im Muster der
* Geschwister-Seiten (Kicker + Überschrift, siehe stadionzeitung-termine.php),
* darunter die geteilte league-table-Komponente (dieselbe wie /fussball,
* nie eine zweite Tabellen-Implementierung).
*
* Props: $info Eintrag aus stadionzeitung_live_data()['tabellen'][team]:
* name/league/season/table — null, wenn das Team (noch) nicht
* in data/matchcenter.json gepflegt ist.
*/
$info = $info ?? null;
?>
<div class="stadionzeitung-tabelle">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Tabelle</p>
<h2 class="stadionzeitung-page__heading"><?= e(trim((string) ($info['name'] ?? 'Tabelle'))) ?></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>
<?php if (!empty($info['table'])): ?>
<?php component('league-table', ['table' => $info['table'], 'caption' => 'Tabelle ' . (string) ($info['league'] ?? '')]); ?>
<?php else: ?>
<p class="text-muted">Tabelle aktuell nicht verfügbar.</p>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/**
* Termine-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Vorwort/News) —
* schlichte weiße Content-Seite mit Foto-Kopf, angelehnt ans Vorwort-Layout
* (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
* 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.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json: termine_snapshot?)
*/
$issue = $issue ?? [];
$items = $issue['termine_snapshot'] ?? [];
if ($items === []) {
return;
}
$heroImage = json_load('home')['hero']['image'] ?? null;
// CTA-Fuß wie News/Kontakt: geteilter QR auf /termine (bin/stadionzeitung-add.php
// erzeugt ihn mit der aktuellen base_url — fehlt er, nur der Text-Link).
$termineQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-termine.png')
? 'img/stadionzeitung/qr-termine.png'
: null;
$termineLink = club_domain('termine');
?>
<div class="stadionzeitung-termine">
<?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-termine__bg']); ?>
<div class="stadionzeitung-termine__bg-fade" aria-hidden="true"></div>
<?php endif; ?>
<div class="stadionzeitung-termine__inner">
<header class="stadionzeitung-termine__head">
<p class="stadionzeitung-termine__kicker">Aktuelles</p>
<h2 class="stadionzeitung-termine__heading">Kommende Termine</h2>
</header>
<span class="stadionzeitung-termine__rule" aria-hidden="true"></span>
<ul class="stadionzeitung-termine__list">
<?php foreach ($items as $item): ?>
<?php
$isMatch = ($item['type'] ?? '') === 'spiel';
$parts = [german_date_range((string) ($item['date'] ?? ''), $item['date_end'] ?? null)];
$time = '';
if ($isMatch && !empty($item['kickoff'])) {
$t = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $item['kickoff']);
$time = $t !== false ? $t->format('H:i') : '';
} elseif (!$isMatch) {
$time = (string) ($item['time'] ?? '');
}
if ($time !== '') {
$parts[] = $time . ' Uhr';
}
if ($isMatch && !empty($item['meta'])) {
$parts[] = (string) $item['meta'];
} elseif (!$isMatch && !empty($item['location'])) {
$parts[] = (string) $item['location'];
}
?>
<li class="stadionzeitung-termine__item">
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
Trophäe, Vereins-Termine den Kalender — macht die Liste auf einen
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>
</div>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-page__cta">
<div class="stadionzeitung-page__cta-text">
<p class="stadionzeitung-page__cta-label">Mehr online</p>
<p class="stadionzeitung-page__cta-title">Alle Termine findest du auf unserer Website</p>
<p class="stadionzeitung-page__cta-link"><?= e($termineLink) ?></p>
</div>
<?php if ($termineQr !== null): ?>
<div class="stadionzeitung-page__cta-abriss">
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($termineQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
</div>
<?php endif; ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<?php
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]}})
*
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
* voll lesbar, sequentiell auch per Screenreader. Mit JS
* (stadionzeitung-viewer.js) wird der Track zum horizontalen Scroll-Snap-
* Swiper, Zähler + Pfeil-Buttons kommen dazu (vorher [hidden], da ohne JS
* funktionslos).
*
* Das Rendern der einzelnen Seiten (Bild wie Live) liegt in
* stadionzeitung-seite.php — geteilt mit der Druckfassung
* (stadionzeitung-druck.php), damit Bildschirm und Druck nie
* auseinanderlaufen. Die Daten der Live-Seiten kommen EINMAL aus
* stadionzeitung_live_data() (app/helpers.php): Entwürfe rechnen live,
* veröffentlichte Ausgaben lesen ihren eingefrorenen snapshot.
*/
$issue = $issue ?? [];
$images = $issue['images'] ?? [];
$total = count($images);
if ($images === []) {
return;
}
$opponent = (string) ($issue['opponent'] ?? '');
// Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält.
$hasLive = false;
foreach ($images as $entry) {
if (($entry['type'] ?? 'image') === 'live') {
$hasLive = true;
break;
}
}
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
?>
<section class="stadionzeitung-viewer" data-stadionzeitung-viewer aria-roledescription="Bildergalerie" aria-label="<?= e('Stadionzeitung gegen ' . $opponent . ', Seite für Seite') ?>">
<ul class="stadionzeitung-viewer__track" data-viewer-track>
<?php foreach ($images as $i => $entry): ?>
<li class="stadionzeitung-viewer__page" data-viewer-page>
<?php component('stadionzeitung-seite', [
'entry' => $entry,
'issue' => $issue,
'data' => $liveData,
'eager' => $i === 0,
]); ?>
</li>
<?php endforeach; ?>
</ul>
<div class="stadionzeitung-viewer__controls" data-viewer-controls hidden>
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-prev aria-label="Vorherige Seite">
<?= icon('chevron-left') ?>
</button>
<p class="stadionzeitung-viewer__counter" data-viewer-counter aria-live="polite">
Seite <span data-viewer-current>1</span> von <?= $total ?>
</p>
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-next aria-label="Nächste Seite">
<?= icon('chevron-right') ?>
</button>
<?php /* Vollbild-Lesemodus: am Handy fressen Kopfleiste, Navigation und Fußzeile
mehr Platz als die Seite selbst. Beide Icons stehen im Markup, welches
sichtbar ist entscheidet CSS am Zustand — so muss das JS kein Markup
erzeugen. Zustand über aria-pressed, deshalb bleibt das aria-label
unverändert. */ ?>
<button class="btn btn--icon stadionzeitung-viewer__full-btn" type="button" data-viewer-full aria-pressed="false" aria-label="Vollbild">
<span class="stadionzeitung-viewer__icon--open"><?= icon('arrows-fullscreen') ?></span>
<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>
</section>

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Vorschau-Seite der Stadionzeitung (Live-Seite) — das nächste Spiel jeder
* Mannschaft NACH dem Spieltag der Ausgabe (berechnet bzw. eingefroren in
* stadionzeitung_build_snapshot(), app/helpers.php). Aufbau je Spiel wie die
* „VORSCHAU"-Seite der Canva-Vorlage: Meta-Zeile (Mannschaft · Liga), darunter
* die Begegnung mit Wappen, rechts Datum/Anstoß.
*
* Props: $items stadionzeitung_live_data()['vorschau'] — je Team:
* team_name/league/match (Matchcenter-Rohform).
*/
$items = $items ?? [];
// Bewusst OHNE CTA-Fuß (Felix, 29.07.2026): mit drei Mannschaften füllt die
// Liste die Seite — das Matchcenter-Ticket wohnt auf der Ergebnis-Seite.
?>
<div class="stadionzeitung-vorschau">
<header class="stadionzeitung-page__head">
<p class="stadionzeitung-page__kicker">Vorschau</p>
<h2 class="stadionzeitung-page__heading">Kommende Spiele</h2>
</header>
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<?php if ($items === []): ?>
<p class="text-muted">Aktuell stehen keine weiteren Spiele fest.</p>
<?php endif; ?>
<?php foreach ($items as $item): ?>
<?php
$match = $item['match'] ?? [];
$when = array_filter([
trim((string) ($match['date_display'] ?? '')),
trim((string) ($match['time_display'] ?? '')),
]);
$place = !empty($match['is_home']) ? 'im Katzbachtal' : 'auswärts';
?>
<section class="stadionzeitung-vorschau__match">
<p class="stadionzeitung-vorschau__meta"><?= e(trim(implode(' · ', array_filter([(string) ($item['team_name'] ?? ''), (string) ($item['league'] ?? '')])))) ?></p>
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null],
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null]] as $side): ?>
<div class="stadionzeitung-vorschau__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-vorschau__row--own' : '' ?>">
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
<span class="stadionzeitung-vorschau__club"><?= e((string) $side['club']) ?></span>
</div>
<?php endforeach; ?>
<p class="stadionzeitung-vorschau__when"><?= e(implode(' · ', $when)) ?><?= $when !== [] ? ' · ' : '' ?><?= e($place) ?></p>
</section>
<?php endforeach; ?>
</div>

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Vorwort-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Termine/News) —
* schlichte weiße Content-Seite, keine fotografische Ausnahme wie das Cover.
* Der Text selbst ist KEIN Skript-Input: er wird im Chat gepflegt (wie
* data/events.json/news.json) und lebt im `vorwort`-Feld der Ausgabe in
* data/stadionzeitung.json (siehe bin/stadionzeitung-add.php).
*
* Fließtext bewusst in einer Serifenschrift (--ff-serif, PT Serif) statt
* --ff-body — einzige Stelle im Projekt mit einer dritten Schriftart: ein
* Vorwort ist ein redaktioneller Brief, keine UI, eine Lese-Serif passt hier
* besser als die Website-Grotesk. Überschrift bewusst NICHT in Versalien
* (anders als der Cover-Masthead) — ein Begrüßungssatz in Großbuchstaben
* wirkt geschrien, hier soll es wie eine echte Zeitschriften-Headline lesen.
* Vereins-Hero-Bild (dieselbe Quelle wie Startseite/Cover) blendet unten ein,
* wie im ursprünglichen Canva-Vorwort — füllt die sonst leere Fläche unter
* dem Fließtext, ohne die Lesbarkeit oben zu stören. Läuft über einen weißen
* Gradient-Layer (__bg-fade) statt mask-image auf dem Foto selbst — druckt
* zuverlässig mit (siehe CSS-Kommentar), erscheint also auch im PDF.
*
* Props: $issue (Eintrag aus data/stadionzeitung.json: vorwort?, verfasser_photo?)
*/
$issue = $issue ?? [];
$vorwort = $issue['vorwort'] ?? null;
if (!$vorwort) {
return;
}
$heading = (string) ($vorwort['heading'] ?? '');
$paragraphs = $vorwort['paragraphs'] ?? [];
$closing = (string) ($vorwort['closing'] ?? '');
$signeeName = (string) ($vorwort['signee_name'] ?? '');
$signeeRole = (string) ($vorwort['signee_role'] ?? '');
$verfasserPhoto = $issue['verfasser_photo'] ?? null;
$heroImage = json_load('home')['hero']['image'] ?? null;
?>
<div class="stadionzeitung-vorwort">
<?php if ($heroImage): ?>
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-vorwort__bg']); ?>
<div class="stadionzeitung-vorwort__bg-fade" aria-hidden="true"></div>
<?php endif; ?>
<header class="stadionzeitung-vorwort__head">
<p class="stadionzeitung-vorwort__kicker">Vorwort</p>
<img class="stadionzeitung-vorwort__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
</header>
<?php if ($heading !== ''): ?>
<h2 class="stadionzeitung-vorwort__heading"><?= e($heading) ?></h2>
<?php endif; ?>
<?php foreach ($paragraphs as $paragraph): ?>
<p class="stadionzeitung-vorwort__text"><?= e($paragraph) ?></p>
<?php endforeach; ?>
<?php if ($signeeName !== ''): ?>
<footer class="stadionzeitung-vorwort__signature">
<?php if ($verfasserPhoto): ?>
<?php component('img', ['image' => $verfasserPhoto, 'sizes' => '80px', 'class' => 'stadionzeitung-vorwort__portrait']); ?>
<?php endif; ?>
<div class="stadionzeitung-vorwort__signature-text">
<?php if ($closing !== ''): ?>
<p class="stadionzeitung-vorwort__closing"><?= e($closing) ?></p>
<?php endif; ?>
<p class="stadionzeitung-vorwort__signee"><?= e($signeeName) ?></p>
<?php if ($signeeRole !== ''): ?>
<p class="stadionzeitung-vorwort__role"><?= e($signeeRole) ?></p>
<?php endif; ?>
</div>
</footer>
<?php endif; ?>
</div>

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
/**
* Kennzahlen-Band über abgedunkeltem Foto (Vereinsgelände), Kanten verlaufen
* weich in den Seitenhintergrund. Props:
* $stats array{items: array{value, label}[], image?: array (img-Komponente)}
*/
$image = $stats['image'] ?? [
'base' => 'img/pages/vereinsgelaende',
'widths' => [1000, 1600],
'alt' => '',
];
?>
<section class="section stats" aria-label="Der Verein in Zahlen">
<div class="stats__media" aria-hidden="true">
<?php component('img', ['image' => $image, 'sizes' => '100vw', 'class' => 'stats__img']); ?>
</div>
<div class="container">
<ul class="stats__list">
<?php foreach ($stats['items'] as $item): ?>
<li class="stats__item">
<span class="stats__value"><?= e($item['value']) ?></span>
<span class="stats__label"><?= e($item['label']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>

View File

@@ -39,7 +39,7 @@ if (!empty($team['roster']['members']) || !empty($team['roster']['groups'])) {
component('faq-contact', [
'faq' => $team['faq'] ?? [],
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ' kurz beantwortet.',
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ', kurz beantwortet.',
'persons' => $contacts,
'contactTitle' => '',
'anchor' => 'kontakt',

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* Liste normalisierter Termine (Spiele + Vereins-Events gemischt). Props:
* $items array{date, date_end?, title, department, type, meta?, href?}
* $empty_text string (optional)
* Kennt selbst nichts von Matchcenter-/Events-Datenstruktur — bekommt nur die
* bereits gemergte, normalisierte Form (app/pages/termine.php baut sie).
*/
$items = $items ?? [];
$emptyText = $empty_text ?? 'Aktuell keine Termine.';
?>
<?php if ($items === []): ?>
<p class="text-muted"><?= e($emptyText) ?></p>
<?php else: ?>
<ul class="termine-list">
<?php foreach ($items as $item): ?>
<?php
$tag = !empty($item['href']) ? 'a' : 'div';
$chip = german_date_chip($item['date']);
$dateText = german_date_range($item['date'], $item['date_end'] ?? null);
?>
<li class="termine-list__item">
<<?= $tag ?> class="termine-list__link"<?= !empty($item['href']) ? ' href="' . e($item['href']) . '"' : '' ?>>
<span class="termine-list__date-chip" aria-hidden="true">
<span class="termine-list__date-day"><?= e($chip['day']) ?></span>
<span class="termine-list__date-month"><?= e($chip['month']) ?></span>
</span>
<span class="termine-list__body">
<span class="termine-list__title"><?= e($item['title']) ?></span>
<span class="termine-list__meta">
<time datetime="<?= e($item['date']) ?>"><?= e($dateText) ?></time><?php if (!empty($item['meta'])): ?> · <?= e($item['meta']) ?><?php endif; ?>
</span>
</span>
<span class="termine-list__badge termine-list__badge--<?= e($item['department']) ?>">
<?= e(department_label($item['department'])) ?>
</span>
</<?= $tag ?>>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>

View File

@@ -4,9 +4,13 @@ declare(strict_types=1);
/**
* Interaktive Vereins-Timeline (Museums-Look). Props:
* $events array — Stationen aus data/historie.json (year, text, image{src,width,height,alt})
* $events array — Stationen aus data/historie.json (year, text, image?{src,width,height,alt})
* Bilder werden bewusst in ORIGINALGRÖSSE eingebunden (keine Varianten) —
* historisches Material bleibt unangetastet.
* `image` ist optional: zu vielen belegten Ereignissen (Ligaereignisse, Chronik-Funde)
* gibt es kein Bild, das wir zeigen dürfen — Pressefotos gehören dem Verlag, die Fotos
* der alten Vereinsseiten sind verloren. Solche Stationen rendern als Notiz auf
* derselben Sepia-Fläche, ohne <figure>/<figcaption> (ohne Bild trägt das nichts).
* timeline.js ergänzt Scroll-Reveal + aktive Jahres-Navigation; ohne JS ist
* alles sichtbar (Initial-Versteckt-Zustand nur unter .js-reveal).
*/
@@ -42,6 +46,7 @@ foreach ($events as $event) {
<span class="timeline__marker" aria-hidden="true"></span>
<article class="timeline__card">
<h2 class="timeline__year"><?= e($event['year']) ?></h2>
<?php if (!empty($event['image']['src'])): ?>
<figure class="timeline__figure">
<img src="<?= e(asset($event['image']['src'])) ?>"
alt="<?= e($event['image']['alt']) ?>"
@@ -49,6 +54,11 @@ foreach ($events as $event) {
loading="<?= $i === 0 ? 'eager' : 'lazy' ?>" decoding="async">
<figcaption class="timeline__text"><?= e($event['text']) ?></figcaption>
</figure>
<?php else: ?>
<div class="timeline__figure timeline__figure--note">
<p class="timeline__text"><?= e($event['text']) ?></p>
</div>
<?php endif; ?>
</article>
</li>
<?php endforeach; ?>

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* Bildergalerie einer Veranstaltung: Raster aus Vorschaubildern, Klick öffnet
* das Bild groß. Props:
* $bilder array [{base, widths, alt, source?}, …] (script-verwaltet,
* bin/veranstaltung-bilder.php)
* $title string (optional, Default 'Bilder')
*
* Progressive enhancement: jedes Vorschaubild ist ein normaler Link auf die
* größte Variante der Datei — ohne JS führt der Klick einfach zum Bild. Mit JS
* fängt assets/js/lightbox.js den Klick ab und zeigt es im Overlay
* (Vor/Zurück, Pfeiltasten, Esc). Das Overlay-Markup steht am Ende und ist
* `hidden`, bis JS es übernimmt.
*
* Die Overlay-Grafik erbt ihr srcset vom angeklickten Vorschaubild (siehe
* lightbox.js) — deshalb braucht der Link hier keine zweite Bildliste.
*/
$bilder = $bilder ?? [];
$title = $title ?? 'Bilder';
if ($bilder === []) {
return;
}
$anzahl = count($bilder);
?>
<section class="section veranstaltung-galerie" id="bilder" data-lightbox-gallery>
<div class="container">
<h2><?= e($title) ?></h2>
<ul class="veranstaltung-galerie__grid">
<?php foreach ($bilder as $i => $bild): ?>
<?php
$gross = asset($bild['base'] . '-' . max($bild['widths']) . '.jpg');
?>
<li class="veranstaltung-galerie__item">
<a class="veranstaltung-galerie__link" href="<?= e($gross) ?>" data-lightbox="<?= $i ?>">
<?php component('img', [
'image' => $bild,
'sizes' => '(max-width: 600px) 45vw, (max-width: 992px) 30vw, 22vw',
'class' => 'veranstaltung-galerie__img',
]); ?>
<span class="visually-hidden">(größer ansehen)</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php component('lightbox-overlay', ['label' => $title, 'count' => $anzahl]); ?>
</section>

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* Infoblock einer Veranstaltung: links die harten Angaben (Zeitraum, Beginn,
* Adresse, Hinweis zum Parken), rechts die Karte. Props:
* $date_text string fertig formatierter Zeitraum (german_date_range())
* $date_iso string ISO-Startdatum für <time datetime>
* $time string 'HH:MM' oder leer
* $location string Ortsname der Veranstaltung
* $karte string 'club' = auf unserem eigenen Gelände: Adresse kommt aus
* data/club.json und die Karte wird gezeigt. Alles andere (oder
* leer) → keine Karte, nur der Ortsname (ein Fest woanders hätte
* sonst eine falsche Karte daneben).
* $anfahrt array optionale Absätze zum Parken/Zugang
*
* Nur für Feste, die noch ausstehen (app/pages/veranstaltung.php ruft die
* Komponente danach nicht mehr auf) — deshalb steht hier durchgehend Zukunft.
*
* KEINE Leaflet-Karte, sondern ein statisches Kartenbild: Leaflet würde seine
* Kacheln zur Laufzeit von einem fremden Tile-Server holen, und genau das
* verbietet Regel 1 (die CSP mit img-src 'self' würde sie ohnehin blocken).
* Das Bild liegt selbst gehostet bei uns, der Klick führt erst dann zu Google
* Maps, wenn jemand ihn macht. Namensnennung für die OSM-Daten steht als
* Bildunterschrift, das verlangt die ODbL.
*/
$dateText = $date_text ?? '';
$dateIso = $date_iso ?? '';
$time = $time ?? '';
$location = $location ?? '';
$karte = $karte ?? '';
$anfahrt = $anfahrt ?? [];
$istBeiUns = $karte === 'club';
$club = $istBeiUns ? json_load('club') : [];
$adresse = $club['address'] ?? [];
$geo = $club['geo'] ?? [];
// Kartenlink ohne API-Schlüssel und ohne Einbettung: Koordinaten aus club.json
// (Single Source), damit die Adresse hier nicht als Text zweitverwertet wird.
$mapsUrl = '';
if ($istBeiUns && !empty($geo['lat']) && !empty($geo['lng'])) {
$mapsUrl = 'https://www.google.com/maps/search/?api=1&query=' . $geo['lat'] . ',' . $geo['lng'];
}
?>
<section class="section veranstaltung-info" id="infos">
<div class="container">
<div class="veranstaltung-info__panel">
<div class="veranstaltung-info__facts">
<h2>Gut zu wissen</h2>
<?php if ($dateText !== ''): ?>
<p class="veranstaltung-info__wann">
<time datetime="<?= e($dateIso) ?>"><?= e($dateText) ?></time>
</p>
<?php endif; ?>
<?php if ($time !== ''): ?>
<?php /* Wochentag dazu, weil bei einem mehrtägigen Fest sonst offen bleibt, für
welchen Tag die Uhrzeit gilt. */ ?>
<?php $wochentag = german_weekday($dateIso); ?>
<p class="veranstaltung-info__beginn">Los geht es<?= $wochentag !== '' ? ' am ' . e($wochentag) : '' ?> um <?= e($time) ?> Uhr.</p>
<?php endif; ?>
<address class="veranstaltung-info__adresse">
<?php if ($istBeiUns && !empty($adresse['street'])): ?>
<?= e($adresse['venue'] ?? $location) ?><br>
<?= e($adresse['street']) ?><br>
<?= e($adresse['zip'] ?? '') ?> <?= e($adresse['city'] ?? '') ?>
<?php else: ?>
<?= e($location) ?>
<?php endif; ?>
</address>
<?php foreach ($anfahrt as $absatz): ?>
<p class="veranstaltung-info__hinweis"><?= e($absatz) ?></p>
<?php endforeach; ?>
</div>
<?php if ($mapsUrl !== ''): ?>
<figure class="veranstaltung-info__map">
<a class="veranstaltung-info__map-link" href="<?= e($mapsUrl) ?>" target="_blank" rel="noopener">
<?php component('img', [
'image' => ['src' => 'img/pages/sportheim-map.jpg', 'width' => 512, 'height' => 512, 'alt' => 'Karte mit unserem Sportgelände im Katzbachtal'],
'class' => 'veranstaltung-info__map-img',
]); ?>
<span class="veranstaltung-info__map-cta">
<?= icon('geo-alt-fill') ?> In Google Maps öffnen
<span class="visually-hidden">(öffnet in neuem Tab)</span>
</span>
</a>
<figcaption class="veranstaltung-info__map-credit">Kartendaten © OpenStreetMap-Mitwirkende</figcaption>
</figure>
<?php endif; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* Die Plakate einer Veranstaltung (Ankündigung, Programm, …) als große,
* anklickbare Bilder. Props:
* $plakate array [{base, widths, alt, source?}, …] (script-verwaltet)
* $title string (optional, Default 'Plakate')
* $compact bool (optional) — kleinere Variante für den Rückblick
*
* Die Überschrift ist sichtbar, WENN die Plakate klein und nachgestellt sind
* (Rückblick): nach einer langen Fotogalerie brauchen zwei kleine Bilder am
* Seitenende eine Beschriftung. Als Hauptblock einer Ankündigung erklären sie
* sich dagegen selbst, dort bleibt die Überschrift visually-hidden — im Markup
* steht sie trotzdem, damit die Headings-Hierarchie keine Lücke hat (gleiche
* Lösung wie in stadionzeitung-cards).
*
* Warum eine eigene Komponente und nicht die Fotogalerie: ein Plakat ist kein
* Foto vom Fest, sondern trägt Informationen (Termine, Uhrzeiten, Ort). Es
* gehört nach vorne statt in den Rückblick und braucht Hochformat statt
* beschnittener Raster-Kacheln. Vergrößern läuft aber über dieselbe Lightbox —
* gerade bei einem Programmplakat will man das lesen können.
*
* Ohne JS ist jedes Plakat ein normaler Link auf die größte Variante.
*/
$plakate = $plakate ?? [];
$title = $title ?? 'Plakate';
$compact = $compact ?? false;
if ($plakate === []) {
return;
}
?>
<section class="section veranstaltung-plakate<?= $compact ? ' veranstaltung-plakate--compact' : '' ?>" id="plakate" data-lightbox-gallery>
<div class="container">
<h2<?= $compact ? '' : ' class="visually-hidden"' ?>><?= e($title) ?></h2>
<ul class="veranstaltung-plakate__grid">
<?php foreach ($plakate as $i => $plakat): ?>
<li class="veranstaltung-plakate__item">
<a class="veranstaltung-plakate__link" href="<?= e(asset($plakat['base'] . '-' . max($plakat['widths']) . '.jpg')) ?>" data-lightbox="<?= $i ?>">
<?php component('img', [
'image' => $plakat,
'sizes' => count($plakate) > 1 ? '(max-width: 768px) 92vw, 44vw' : '(max-width: 768px) 92vw, 30vw',
'eager' => $i === 0 && !$compact,
'class' => 'veranstaltung-plakate__img',
]); ?>
<span class="visually-hidden">(größer ansehen)</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php component('lightbox-overlay', ['label' => $title, 'count' => count($plakate)]); ?>
</section>

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* Tagesprogramm einer Veranstaltung. Props:
* $programm array [{tag: 'YYYY-MM-DD', punkte: [{zeit: 'HH:MM', titel, text?}]}, …]
* $title string (optional, Default 'Programm')
* $compact bool (optional) — kleinere Variante für den Rückblick, wo das
* Programm nicht mehr informiert, sondern nur noch dokumentiert.
*
* Ein Tag = eine Gruppe mit Wochentag-Überschrift, damit ein dreitägiges Fest
* lesbar bleibt. Ohne Programm rendert die Komponente nichts (die Seite startet
* ja mit nichts als dem Plakat).
*/
$programm = $programm ?? [];
$title = $title ?? 'Programm';
$compact = $compact ?? false;
if ($programm === []) {
return;
}
?>
<section class="section veranstaltung-programm<?= $compact ? ' veranstaltung-programm--compact' : '' ?>" id="programm">
<div class="container">
<h2><?= e($title) ?></h2>
<div class="veranstaltung-programm__tage">
<?php foreach ($programm as $tag): ?>
<?php
$tagIso = (string) ($tag['tag'] ?? '');
$weekday = german_weekday($tagIso);
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . german_date($tagIso));
?>
<div class="veranstaltung-programm__tag">
<?php if ($tagIso !== ''): ?>
<h3 class="veranstaltung-programm__tag-titel">
<time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time>
</h3>
<?php endif; ?>
<ul class="veranstaltung-programm__liste">
<?php foreach ($tag['punkte'] ?? [] as $punkt): ?>
<?php $zeit = (string) ($punkt['zeit'] ?? ''); ?>
<li class="veranstaltung-programm__punkt">
<?php if ($zeit !== ''): ?>
<span class="veranstaltung-programm__zeit">
<time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time>
</span>
<?php else: ?>
<?php /* Platzhalter hält die Spalte, aber ohne die Zeit-Klasse — sonst stünde
dort der rote Balken ohne Inhalt daneben. */ ?>
<span aria-hidden="true"></span>
<?php endif; ?>
<span class="veranstaltung-programm__body">
<span class="veranstaltung-programm__titel"><?= e($punkt['titel'] ?? '') ?></span>
<?php if (!empty($punkt['text'])): ?>
<span class="veranstaltung-programm__text"><?= e($punkt['text']) ?></span>
<?php endif; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* Liste der Veranstaltungen, ganze Karte verlinkt auf die Detailseite. Props:
* $items array aus data/veranstaltungen.json (bereits vorsortiert)
* $cta string (optional, Default 'Ansehen') — Linktext auf der Karte
* $empty_text string (optional)
*
* EINE Kartenform für „Was ansteht" und „Rückblick" (Beschluss 30.07.2026):
* Plakat links, Text rechts, volle Breite, untereinander gestapelt. Vorher war
* der erste Eintrag breit und alles Weitere ein schmales Raster — zwei Formen
* für dieselbe Sache, und der Rückblick sah dadurch nach Restposten aus.
*
* Vorschaubild ist das Hauptplakat (Hochformat). Fehlt es (die Seite entsteht ja
* schon, bevor das Plakat fertig ist), greift das erste Galeriebild; ohne
* beides bleibt die Karte reiner Text und bricht nicht.
*/
$items = $items ?? [];
$cta = $cta ?? 'Ansehen';
$emptyText = $empty_text ?? 'Aktuell keine Veranstaltungen.';
/** Erstes verfügbares Bild einer Veranstaltung (Hauptplakat vor Galerie). */
$vorschau = static function (array $v): ?array {
return $v['plakate'][0] ?? $v['galerie'][0] ?? null;
};
?>
<?php if ($items === []): ?>
<p class="text-muted"><?= e($emptyText) ?></p>
<?php else: ?>
<ul class="veranstaltungen-cards__list">
<?php foreach ($items as $v): ?>
<?php
$bild = $vorschau($v);
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
?>
<li>
<a class="veranstaltung-card" href="<?= e(url('veranstaltungen/' . $v['slug'])) ?>">
<?php if ($bild !== null): ?>
<div class="veranstaltung-card__media">
<?php component('img', [
'image' => $bild,
'sizes' => '(max-width: 768px) 100vw, 300px',
'class' => 'veranstaltung-card__img',
]); ?>
</div>
<?php endif; ?>
<div class="veranstaltung-card__body">
<h3 class="veranstaltung-card__name"><?= e($v['title']) ?></h3>
<p class="veranstaltung-card__meta">
<time datetime="<?= e($v['date_start']) ?>"><?= e($dateText) ?></time>
<?php if (!empty($v['location'])): ?>
· <?= e($v['location']) ?>
<?php endif; ?>
</p>
<?php if (!empty($v['text'])): ?>
<p class="veranstaltung-card__text"><?= e($v['text']) ?></p>
<?php endif; ?>
<span class="veranstaltung-card__cta">
<?= e($cta) ?> <?= icon('arrow-right', 'veranstaltung-card__arrow') ?>
</span>
</div>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,8 @@ $anchor_slug = static function (string $s): string {
$meta = [
'title' => 'Ansprechpartner',
'description' => 'Ansprechpartner und Kontaktwege beim TSV 1908 Kulmbach Geschäftsstelle, Turnabteilung und Vorstand mit E-Mail und Telefon auf einen Blick.',
'description' => 'Alle Ansprechpartner des TSV 08 Kulmbach auf einen Blick: Herren-, Frauen- und Jugendfußball sowie Turnen, mit Telefonnummer oder E-Mail. Sprich uns direkt an!',
'scripts' => ['persons-tabs.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
@@ -40,7 +41,7 @@ $meta = [
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'bottom-left',
'align' => 'center',
'title' => 'Ansprechpartner',
'text' => $page['intro'] ?? '',
'image' => [
@@ -50,14 +51,29 @@ component('hero', ['hero' => [
],
]]);
$i = 0;
foreach ($groups as $label => $depts):
?>
<?php /* EINE Sektion für beide Bereiche, Tab-Leiste direkt über den
Karten (assets/js/persons-tabs.js schaltet um und spiegelt den
aktiven Bereich in die URL — #kontakt-turnen ist teilbar).
Ohne JS bleibt die Leiste hidden und beide Panels stehen mit
ihren Überschriften untereinander. */ ?>
<section class="section section--tinted" id="kontakt" data-persons-tabs>
<div class="container">
<div class="filter-bar persons-tabs__bar" role="group" aria-label="Bereich wählen" hidden data-persons-tabs-bar>
<?php $i = 0; foreach (array_keys($groups) as $label): ?>
<button type="button" class="tab filter-bar__tab" data-tab-target="kontakt-<?= e($anchor_slug($label)) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
<?php endforeach; ?>
</div>
<?php
foreach ($groups as $label => $depts) {
component('person-grid', [
'departments' => $depts,
'title' => $label,
'intro' => '',
'anchor' => 'kontakt-' . $anchor_slug($label),
'tinted' => ($i % 2 === 0),
'embedded' => true,
]);
$i++;
endforeach;
}
?>
</div>
</section>
<?php

View File

@@ -5,9 +5,16 @@ declare(strict_types=1);
/**
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster),
* Kontaktformular (Versand via Brevo SMTP), server-seitig gecachter
* Instagram-Feed (Bilder von unserer Domain). Vereinsdaten aus club.json.
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die vier
* Formulare (Kontakt, Turnabteilung, Mitgliedsantrag, Sportheimbuchung; Versand
* über den All-Inkl-Mailserver, also derselbe Auftragsverarbeiter wie beim
* Hosting), server-seitig gecachter Instagram-Feed (Bilder von unserer Domain).
* Vereinsdaten aus club.json.
*
* Alle Empfänger liegen auf unserer eigenen Domain bei All-Inkl (info@,
* turnen@) — deshalb stimmt die Aussage weiter unten, dass kein zusätzlicher
* E-Mail-Dienstleister im Spiel ist. Zeigt ein Postfach künftig auf einen
* fremden Anbieter, muss dieser Satz mit.
*/
$club = json_load('club');
$addr = $club['address'] ?? [];
@@ -35,7 +42,7 @@ $meta = [
</p>
</div>
<div class="legal__body">
<div class="legal__body prose">
<section>
<h2>Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</p>
@@ -122,14 +129,20 @@ $meta = [
Tracking-Zwecken</strong> und bindet keine Dienste ein, die solche Cookies
setzen würden. Aus diesem Grund benötigen wir auch kein Cookie-Banner.
</p>
<p>
Eine Ausnahme betrifft nur den passwortgeschützten internen Bereich
(siehe „Interne Dateiablage“): Wer sich dort anmeldet, erhält ein
technisch notwendiges Sitzungs-Cookie, das die Anmeldung für die Dauer
der Sitzung merkt. Ohne dieses Cookie ist eine Anmeldung nicht möglich.
</p>
</section>
<section>
<h2>Kontaktformular &amp; Kontaktaufnahme</h2>
<h2>Formulare &amp; Kontaktaufnahme</h2>
<p>
Wenn du uns über das Kontaktformular oder per E-Mail kontaktierst, verarbeiten
wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten. Über das
Formular sind das:
Wenn du uns über eines unserer Formulare oder per E-Mail kontaktierst,
verarbeiten wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten.
Über das <strong>Kontaktformular</strong> sind das:
</p>
<ul>
<li>Name (Pflichtangabe)</li>
@@ -137,6 +150,29 @@ $meta = [
<li>Telefonnummer (freiwillig)</li>
<li>dein Interessensgebiet, Betreff und deine Nachricht</li>
</ul>
<p>
Über den <strong>Mitgliedsantrag</strong> verarbeiten wir zusätzlich Vorname,
Nachname, Geburtsdatum, Anschrift und die gewählte Beitragsklasse. Diese Angaben
brauchen wir, um die Mitgliedschaft vorzubereiten; die Beitrittserklärung senden
wir dir anschließend per Post zu. Bankdaten erheben wir online bewusst nicht.
Rechtsgrundlage ist hier Art. 6 Abs. 1 lit. b DSGVO (Anbahnung und Durchführung
der Mitgliedschaft). An die von dir angegebene Adresse senden wir außerdem eine
Eingangsbestätigung.
</p>
<p>
Über das Formular <strong>Sportheimbuchung</strong> verarbeiten wir Name,
E-Mail-Adresse, optional die Telefonnummer sowie Art, Datum und Personenzahl
deiner Veranstaltung, um dir ein Angebot machen zu können.
</p>
<p>
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
Postfach der Turnabteilung
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
weil die Abteilung sie selbst beantwortet.
</p>
<p>
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
@@ -146,16 +182,60 @@ $meta = [
Aufbewahrungspflichten entgegenstehen.
</p>
<p>
Für den technischen Versand der Formular-Nachrichten als E-Mail nutzen wir den
Dienstleister Brevo (Sendinblue GmbH, Köln) als Auftragsverarbeiter. Dabei
werden die im Formular angegebenen Daten an die Mailserver von Brevo
übermittelt. Die Übertragung erfolgt verschlüsselt.
Schickst du uns Material für unsere <strong>Vereinshistorie</strong> Fotos,
Zeitungsausschnitte, Dokumente oder Erinnerungen an
<a href="mailto:<?= e($club['email_historie']) ?>"><?= e($club['email_historie']) ?></a>,
bewahren wir es gemeinsam mit deinem Namen als Quellenangabe in unserem
Vereinsarchiv auf. Grundlage dafür ist unser berechtigtes Interesse an der
Dokumentation der Vereinsgeschichte (Art. 6 Abs. 1 lit. f DSGVO).
Veröffentlichen auf dieser Website, in einer Chronik oder Festschrift
tun wir nur, was du uns ausdrücklich freigegeben hast (Art. 6 Abs. 1 lit. a
DSGVO); diese Freigabe kannst du jederzeit widerrufen. Sind auf Bildern
weitere Personen erkennbar, sag uns bitte, was du darüber weißt.
</p>
<p>
Der technische Versand der Formular-Nachrichten als E-Mail läuft über den
Mailserver unseres Webhosters ALL-INKL.COM (siehe oben) — die Daten bleiben
damit innerhalb der dort genannten Auftragsverarbeitung. Die Übertragung zum
Mailserver ist verschlüsselt (STARTTLS). Einen zusätzlichen
E-Mail-Dienstleister setzen wir nicht ein.
</p>
<p class="legal__muted">
Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes
Pflichtfeld sowie einen signierten Zeitstempel. Dabei werden keine
zusätzlichen personenbezogenen Daten erhoben und keine externen Dienste
(z.&nbsp;B. reCAPTCHA) eingebunden.
Pflichtfeld und einen signierten Zeitstempel. Außerdem begrenzen wir, wie oft
von einem Anschluss abgesendet werden kann. Dafür speichern wir deine
IP-Adresse nicht im Klartext, sondern nur als Hashwert, der mit einem
geheimen Schlüssel gebildet wird; die Zähler werden spätestens nach 24 Stunden
gelöscht. Externe Dienste (z.&nbsp;B. reCAPTCHA) binden wir nicht ein.
</p>
</section>
<section>
<h2>Interne Dateiablage</h2>
<p>
Unter <code>/dateien</code> betreiben wir einen
<strong>passwortgeschützten Bereich</strong>, in dem Mitglieder Fotos und
Videos von Vereinsveranstaltungen für die Website abliefern können. Er ist
nicht öffentlich, nicht verlinkt und von Suchmaschinen ausgenommen; die
Zugangsdaten geben wir nur intern weiter.
</p>
<p>
Verarbeitet werden dort ausschließlich die hochgeladenen Dateien selbst
(einschließlich der darin enthaltenen Aufnahmen von Personen und der
technischen Bilddaten wie Aufnahmezeit oder Kameramodell) sowie der
gewählte Benutzername mit dem Zeitpunkt der Anmeldung. Fehlgeschlagene
Anmeldeversuche halten wir zum Schutz gegen automatisierte Angriffe kurz
fest, dabei die IP-Adresse nur als nicht zurückrechenbare Prüfsumme.
</p>
<p>
Die Dateien liegen ausschließlich auf unserem eigenen Webspace, außerhalb
des öffentlich erreichbaren Bereichs, und werden nicht an Dritte
weitergegeben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO
(berechtigtes Interesse an der Vereinskommunikation); für die
Veröffentlichung von Personenaufnahmen auf der Website gilt zusätzlich der
Abschnitt „Foto- und Videoaufnahmen“. Wer nicht möchte, dass eine Aufnahme
von ihm dort abgelegt oder veröffentlicht wird, kann sich jederzeit an die
oben genannten Kontaktdaten wenden wir löschen sie dann.
</p>
</section>

View File

@@ -21,7 +21,7 @@ $cards = [
$meta = [
'title' => 'Fußball in Kulmbach',
'description' => 'Herren-, Frauen- und Jugendfußball beim TSV 08 Kulmbach: zwei Herrenmannschaften, Frauenmannschaft und Nachwuchs aktiv werden im Katzbachtal.',
'description' => 'Fußball in Kulmbach beim TSV 08: zwei Herrenmannschaften, Frauenfußball und Jugendteams von der G- bis zur C-Jugend. Komm zum Probetraining im Katzbachtal!',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema(
[

View File

@@ -3,14 +3,28 @@
declare(strict_types=1);
/**
* Vereinshistorie — schmaler Hero + interaktive Timeline.
* Inhalte: data/historie.json (Single Source of Truth).
* Vereinshistorie — schmaler Hero + interaktive Timeline + Aufruf zum Mitsammeln.
* Inhalte: data/historie.json (Single Source of Truth), Mailadresse aus data/club.json.
*/
$historie = json_load('historie');
$club = json_load('club');
// Die Adresse steht ausschließlich in club.json — der Fließtext in historie.json
// markiert ihre Stelle mit {mail}, CTA-Label und mailto-Ziel entstehen hier.
// Betreff vorbelegt, damit Zuschriften direkt zuordenbar sind.
$mail = $club['email_historie'];
$contribute = $historie['contribute'] + ['cta' => [
'label' => $mail,
'href' => 'mailto:' . $mail . '?subject=' . rawurlencode('Material für die Vereinshistorie'),
]];
$contribute['text'] = array_map(
static fn (string $p): string => strtr($p, ['{mail}' => $mail]),
$contribute['text']
);
$meta = [
'title' => 'Vereinshistorie Der Katzbachtaler Weg',
'description' => 'Erlebe die bewegte Geschichte des TSV 08 Kulmbach von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
'title' => 'Vereinshistorie: Der Katzbachtaler Weg',
'description' => 'Erlebe unsere bewegte Geschichte, von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
'scripts' => ['timeline.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
@@ -21,3 +35,4 @@ $meta = [
component('hero', ['hero' => $historie['hero']]);
component('timeline', ['events' => $historie['events']]);
component('section', ['section' => $contribute]);

View File

@@ -10,14 +10,17 @@ declare(strict_types=1);
$home = json_load('home');
$meta = [
'title' => 'Fußball & Turnen in Kulmbach TSV 08 Kulmbach',
'title' => 'Fußball & Turnen in Kulmbach | TSV 08 Kulmbach',
'title_absolute' => true,
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin seit 1908 im Katzbachtal. Jetzt Mitglied werden!',
'scripts' => ['form.js', 'carousel.js'],
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin: seit 1908 in Kulmbach. Jetzt Mitglied werden!',
'scripts' => ['form.js', 'carousel.js', 'crest.js'],
'schema' => [website_schema()],
];
component('hero', ['hero' => $home['hero']]);
// Die nächsten drei Termine als Live-Aufhänger im Hero — Spiele und
// Vereins-Events gemischt, aus upcoming_highlights() (baut auf derselben
// Merge-Logik auf wie /termine). Keine Termine → der Hero rendert ohne Karte.
component('hero', ['hero' => $home['hero'], 'upcoming' => upcoming_highlights(3)]);
component('banner-slider');
component('instagram-feed');
@@ -25,12 +28,10 @@ foreach ($home['sections'] as $section) {
component('section', ['section' => $section]);
}
component('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]);
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
component('person-grid', [
'departments' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
'columns' => 3,
]);
component('contact-form', ['intro' => $home['contact']]);

View File

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

View File

@@ -21,7 +21,7 @@ $contacts = array_values(array_filter(
$meta = [
'title' => 'Jugendfußball in Kulmbach',
'description' => 'Jugendfußball in Kulmbach entdecke unsere Mannschaften von der G- bis zur C-Jugend, Betreuer und Trainingszeiten. Probiere Fußball in familiärer Atmosphäre aus.',
'description' => 'Jugendfußball in Kulmbach beim TSV 08: Teams von der G- bis zur C-Jugend, Trainingszeiten und kostenloses Schnuppertraining. Komm vorbei ins Katzbachtal!',
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
'schema' => page_schema(
@@ -70,7 +70,7 @@ component('age-groups', ['groups' => $page['groups'] ?? []]);
component('faq-contact', [
'faq' => $page['faq'] ?? [],
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp. Wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
'persons' => $contacts,
'contactTitle' => '',
'anchor' => 'kontakt',

View File

@@ -10,7 +10,7 @@ $page = json_load('kontakt');
$meta = [
'title' => 'Kontakt',
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder einem allgemeinen Anliegen. Wir melden uns so schnell wie möglich.',
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder deinem Anliegen. Wir melden uns so schnell wie möglich.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'scripts' => ['form.js'],
'schema' => page_schema([

View File

@@ -14,9 +14,9 @@ $teams = $mc['teams'] ?? [];
$upcoming = $mc['upcoming'] ?? [];
$meta = [
'title' => 'Matchcenter Spiele, Ergebnisse & Tabellen',
'title' => 'Matchcenter · Spiele, Ergebnisse & Tabellen',
'description' => 'Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften des TSV 08 Kulmbach: 1. Mannschaft und Frauenmannschaft automatisch aktuell.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'og_image' => 'img/pages/matchcenter-hero-1600.jpg',
'scripts' => ['crest.js', 'group-tabs.js'],
'schema' => array_merge(
page_schema([
@@ -30,11 +30,16 @@ $meta = [
component('hero', ['hero' => [
'compact' => true,
'align' => 'bottom-left',
'align' => 'center',
'title' => 'Matchcenter',
'text' => 'Alle Spiele, Ergebnisse und Tabellen unserer Aktivenmannschaften immer aktuell.',
/* Eigenes Hero-Motiv (Spielszene) statt des leeren Platzes von /fussball — die Seite
zeigt Spiele, also zeigt der Kopf welche. Quelle: 14_mrz.jpg aus der alten Seite,
vor dem Skalieren oben um 230 px beschnitten (1620x1080 → 1620x850). Der Beschnitt
rückt die Spielszene in die Bildmitte, sonst schneidet der flache Compact-Hero sie
auf Mobil komplett weg. */
'image' => [
'base' => 'img/pages/fussball-hero',
'base' => 'img/pages/matchcenter-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],

View File

@@ -19,7 +19,7 @@ $feeOptions = array_map(
$meta = [
'title' => 'Mitglied werden',
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag alle Beiträge und Infos für deinen Vereinsbeitritt.',
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag. Alle Beiträge und Infos für deinen Vereinsbeitritt.',
'og_image' => 'img/hero/hero-1600.jpg',
'scripts' => ['form.js'],
'schema' => page_schema(
@@ -33,7 +33,9 @@ $meta = [
component('hero', ['hero' => $page['hero']]);
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung']);
// Titel nur für Screenreader: der Hero sagt bereits „Wähle deine Abteilung", und die
// beiden Karten sprechen optisch für sich. Die h2 bleibt für die Heading-Kette stehen.
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung', 'title_hidden' => true]);
?>
<section class="section membership-signup" id="anmeldung-fussball">
<div class="container membership-signup__inner">

View File

@@ -26,8 +26,8 @@ $contact_for = static function (array $pos) use ($directory): ?array {
$meta = [
'title' => 'Mitmachen & Ehrenamt',
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen mit direktem Ansprechpartner, ohne Vorerfahrung.',
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen. Direkte Ansprechpartner, keine Vorerfahrung nötig!',
'og_image' => 'img/pages/mitmachen-hero-1600.jpg',
'scripts' => ['position-filter.js'],
'schema' => array_merge(
page_schema(
@@ -52,7 +52,7 @@ component('hero', ['hero' => $page['hero']]);
<?php if (!empty($page['filters'])): ?>
<div class="filter-bar" role="group" aria-label="Stellen nach Bereich filtern" data-position-filter>
<?php foreach ($page['filters'] as $i => $f): ?>
<button type="button" class="filter-bar__tab" data-filter="<?= e($f['value']) ?>" aria-pressed="<?= $i === 0 ? 'true' : 'false' ?>"><?= e($f['label']) ?></button>
<button type="button" class="tab filter-bar__tab" data-filter="<?= e($f['value']) ?>" aria-pressed="<?= $i === 0 ? 'true' : 'false' ?>"><?= e($f['label']) ?></button>
<?php endforeach; ?>
</div>
<?php endif; ?>
@@ -61,7 +61,7 @@ component('hero', ['hero' => $page['hero']]);
<?php component('position-card', ['position' => $pos, 'contact' => $contact_for($pos)]); ?>
<?php endforeach; ?>
</div>
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
</div>
</section>
<?php if (!empty($page['arguments']['items'])): ?>

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Einzelne Meldung als Artikel: dynamische Route news/<slug>, siehe
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
* Ermittelt den Slug selbst wieder aus der URL, Daten aus data/news.json
* (chat-gepflegt, keine Automatisierung).
*
* Die Seite existiert nur für Meldungen MIT `artikel` (news_has_article()):
* eine Kurzmeldung bleibt eine Feed-Zeile auf /news, ihre URL liefert 404. Ohne
* diese Bedingung stünde unter jedem Slug eine Seite mit nichts als der
* Überschrift.
*
* Geladen wird bewusst UNGEFILTERT (news_find(), nicht news_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 hero (wie die Veranstaltungs-Detailseite): das Titelbild ist der
* Aufmacher im Artikelkörper, nicht ein cover-beschnittener Hintergrund.
*/
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$newsSlug = substr($urlSlug, strlen('news/'));
$n = $newsSlug !== '' ? news_find($newsSlug) : null;
if ($n === null || !news_has_article($n)) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
http_response_code(404);
require APP_PATH . '/pages/404.php';
return;
}
$isDraft = !news_is_published($n);
$pageSlug = 'news/' . $n['slug'];
$hatBilder = array_filter(
$n['artikel'],
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild'
) !== [];
$meta = [
'title' => $n['title'],
'description' => (string) ($n['text'] ?? $n['title']),
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'News', 'slug' => 'news'],
['name' => $n['title'], 'slug' => $pageSlug],
]),
// Mit $pageSlug: derselbe Article-Knoten (@id) wie auf /news.
array_values(array_filter([news_article_schema($n, $pageSlug)]))
),
];
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
if ($hatBilder) {
$meta['scripts'] = ['lightbox.js'];
}
if (!empty($n['image']['base']) && !empty($n['image']['widths'])) {
$meta['og_image'] = $n['image']['base'] . '-' . max($n['image']['widths']) . '.jpg';
}
if ($isDraft) {
$meta['noindex'] = true;
}
?>
<?php component('detail-head', [
'back' => ['href' => url('news'), 'label' => 'Alle News'],
'title' => $n['title'],
'badge' => $isDraft ? 'Entwurf' : '',
'date_iso' => (string) ($n['date'] ?? ''),
'date_text' => !empty($n['date']) ? german_date((string) $n['date']) : '',
]); ?>
<?php component('news-artikel', [
'image' => $n['image'] ?? null,
'blocks' => $n['artikel'],
]); ?>

56
app/pages/news.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* Zentrale News-Seite: mischt redaktionelle Meldungen (data/news.json,
* chat-gepflegt), Instagram-Posts (data/instagram.json, automatisch gecacht)
* und zuletzt gespielte Ergebnisse (data/matchcenter.json, teamübergreifend)
* zu einem gemeinsamen, absteigend sortierten Feed. Merge-Logik in
* news_build_feed() (app/helpers.php) — dieselbe Grundlage nutzt die
* Stadionzeitungs-Vorlage (Phase 3), damit beide nie auseinanderlaufen.
*
* Meldungen mit Artikeltext (`artikel` in data/news.json) verlinken auf ihre
* Detailseite /news/<slug>. Sie stehen immer alle im Feed: die Kappung aus
* config('news.max_items') trifft nur Instagram und Ergebnisse, sonst wäre ein
* älterer Artikel irgendwann von der Website aus nicht mehr verlinkt.
*/
$feed = news_build_feed();
$meta = [
'title' => 'News',
'description' => 'Alle Neuigkeiten des TSV 08 Kulmbach: aktuelle Meldungen, Fußball-Ergebnisse und Instagram-Beiträge aus dem Vereinsleben in Kulmbach an einem Ort.',
'scripts' => ['news-filter.js'],
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'News', 'slug' => 'news'],
]),
// Meldungen mit Detailseite bekommen ihren Slug mit — dann trägt der
// Article-Knoten hier dieselbe @id wie auf /news/<slug>, beide Seiten
// beschreiben also nachweislich denselben Artikel.
array_values(array_filter(array_map(
static fn (array $n): ?array => news_article_schema($n, news_has_article($n) ? 'news/' . $n['slug'] : null),
$feed['news']
)))
),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'News',
'text' => 'Unsere Meldungen, Ergebnisse und Instagram-Beiträge an einem Ort.',
'image' => [
'base' => 'img/pages/fussball-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
?>
<section class="section">
<div class="container">
<?php component('news-feed', ['items' => $feed['items'], 'featured' => true, 'filterable' => true, 'empty_text' => 'Aktuell gibt es noch keine News.']); ?>
</div>
</section>

View File

@@ -11,7 +11,7 @@ $page = json_load('partner-werden');
$meta = [
'title' => 'Werbepartner werden',
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen.',
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach: sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen!',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema(
[

View File

@@ -11,7 +11,7 @@ $club = json_load('club');
$meta = [
'title' => 'Sportheim mieten in Kulmbach',
'description' => 'Das Sportheim auf der Hans-Rausch-Sportanlage in Kulmbach für Feiern und Veranstaltungen mieten Küche, Außenbereich, Beamer optional. Jetzt anfragen.',
'description' => 'Sportheim in Kulmbach mieten: auf der Hans-Rausch-Sportanlage, mit Küche, Außenbereich und Beamer auf Wunsch. Jetzt unverbindlich anfragen!',
'og_image' => 'img/pages/sportheim-2.jpg',
'schema' => page_schema(
[

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
/**
* Einzelne Stadionzeitung-Ausgabe: dynamische Route stadionzeitung/<slug>,
* siehe public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs
* kennt). Ermittelt den Ausgaben-Slug selbst wieder aus der URL, Daten aus
* data/stadionzeitung.json (maschinenverwaltet von bin/stadionzeitung-add.php).
*
* Bewusst OHNE das übliche hero-Component: der Swipe-Viewer soll so viel
* Bildschirmhöhe wie möglich bekommen (gerade am Handy) — stattdessen ein
* schmaler Kopfbereich direkt über dem Viewer.
*/
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$ausgabeSlug = substr($urlSlug, strlen('stadionzeitung/'));
/*
* Reservierter Slug „aktuell": leitet auf die neueste veröffentlichte Ausgabe
* weiter. Damit zeigt ein GEDRUCKTER QR-Code (Plakat, Aushang) dauerhaft auf die
* jeweils aktuelle Ausgabe, ohne dass zu jedem Heimspiel ein neuer Code
* entstehen muss. Kollision ausgeschlossen: Ausgaben-Slugs beginnen immer mit
* dem Datum (2026-08-01-…).
*
* Drei Dinge sind hier load-bearing:
* 1. **302, nie 301.** Ein 301 wird von Browsern und Crawlern dauerhaft
* gemerkt — das Plakat zeigte dann für immer auf die erste Ausgabe, und
* zwar unreparierbar auf allen Geräten, die ihn einmal gesehen haben.
* 2. `no-store`, damit ein Handy, das im letzten Monat gescannt hat, nicht
* das alte Ziel wiederverwendet.
* 3. Ohne veröffentlichte Ausgabe geht es aufs Archiv statt in einen 404 —
* ein gedruckter Code darf nie ins Leere führen.
*/
if ($ausgabeSlug === 'aktuell') {
$latest = stadionzeitung_latest();
header('Cache-Control: no-store');
header(
'Location: ' . ($latest !== null ? url('stadionzeitung/' . $latest['slug']) : url('stadionzeitung')),
true,
302
);
// exit, nicht return: nach einem `return` liefe in public/index.php noch
// layout.php und hängte einen kompletten HTML-Body an die Weiterleitung.
// Der Ausgabepuffer aus render_page() ist hier noch leer, es geht also
// nichts verloren.
exit;
}
$issue = null;
foreach (json_load('stadionzeitung')['issues'] ?? [] as $entry) {
if (($entry['slug'] ?? '') === $ausgabeSlug) {
$issue = $entry;
break;
}
}
if ($issue === null) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
http_response_code(404);
require APP_PATH . '/pages/404.php';
return;
}
// Entwurf vs. veröffentlicht (Interview-Beschluss 29.07.2026): Entwürfe sind
// „unsichtbar, aber offen" — sie fehlen im Archiv und in der Sitemap und
// tragen noindex, die direkte URL funktioniert aber (zum Gegenlesen ohne
// Login-System). Die Druckfassung (?druck=1) montiert dieselben Seiten in
// Broschüren-Reihenfolge fürs gefaltete A5-Heft.
$isDraft = !stadionzeitung_is_published($issue);
$isDruck = isset($_GET['druck']);
// Erste Seite ist seit dem automatischen Cover (Phase 3) eine Live-Seite ohne
// eigenes Bild — og_image gibt es nur, wenn die Ausgabe (noch/wieder) mit
// einem Bild beginnt, sonst greift das globale Default-OG-Bild (meta.php).
$firstImage = $issue['images'][0] ?? null;
$cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null;
$meta = [
'title' => 'Stadionzeitung gegen ' . $issue['opponent'] . ($isDruck ? ' (Druckfassung)' : ''),
'description' => 'Stadionzeitung zum Heimspiel gegen ' . $issue['opponent'] . ' am ' . german_date($issue['date']) . ' digital zum Durchblättern.',
'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
['name' => 'Gegen ' . $issue['opponent'], 'slug' => 'stadionzeitung/' . $issue['slug']],
]),
];
if ($cover !== null) {
$meta['og_image'] = $cover['base'] . '-' . max($cover['widths']) . '.jpg';
}
// Entwürfe und die Druckfassung (parametrisierte Zweitansicht derselben
// Inhalte) gehören nicht in den Index.
if ($isDraft || $isDruck) {
$meta['noindex'] = true;
}
?>
<?php
// Kopfleiste: im Viewer Titel + Datum, in der Druckfassung stattdessen die
// Blatt-Bilanz und ein Drucken-Knopf griffbereit oben (der ausführliche
// Hinweis + Button am Seitenende bleibt).
$druckSeiten = count($issue['images'] ?? []);
$druckBlaetter = (int) ceil($druckSeiten / 4);
?>
<?php /* Kopfleiste in drei Zonen und zwei Reihen: oben Rückweg links, Titel mittig,
Aktion rechts — darunter eine ruhige Statuszeile. Die Badges standen
früher neben dem Titel und machten die Mitte unruhig; Zustand („Entwurf",
„Druckfassung") und Bilanz sind Information, keine Überschrift. */ ?>
<header class="stadionzeitung-issue-head<?= $isDruck ? ' stadionzeitung-issue-head--druck' : '' ?>">
<div class="container stadionzeitung-issue-head__inner">
<a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>">
<?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?>
</a>
<p class="stadionzeitung-issue-head__title">Gegen <?= e($issue['opponent']) ?></p>
<?php if ($isDruck): ?>
<p class="stadionzeitung-issue-head__actions">
<button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden>
<?= icon('printer') ?> Drucken
</button>
</p>
<?php else: ?>
<p class="stadionzeitung-issue-head__date">
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
</p>
<?php endif; ?>
<?php if ($isDraft || $isDruck): ?>
<p class="stadionzeitung-issue-head__state">
<?php if ($isDraft): ?>
<span class="stadionzeitung-issue-head__badge stadionzeitung-issue-head__badge--entwurf">Entwurf</span>
<?php endif; ?>
<?php if ($isDruck): ?>
<span>Druckfassung · <?= $druckBlaetter ?> Blätter · <?= $druckSeiten ?> Seiten</span>
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</header>
<?php component($isDruck ? 'stadionzeitung-druck' : 'stadionzeitung-viewer', ['issue' => $issue]); ?>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Stadionzeitung-Archiv: Vorschau-Karten aller Ausgaben, verlinkt auf den
* Swipe-Viewer je Ausgabe (stadionzeitung/<slug>, dynamische Route in
* public/index.php). Daten: data/stadionzeitung.json (maschinenverwaltet von
* bin/stadionzeitung-add.php — nie von Hand editieren).
*/
// Entwürfe bleiben unsichtbar (kein Archiv-Eintrag, siehe status-Feld) —
// erreichbar sind sie nur über ihre direkte URL, zum Gegenlesen.
$issues = array_values(array_filter(
json_load('stadionzeitung')['issues'] ?? [],
'stadionzeitung_is_published'
));
$meta = [
'title' => 'Stadionzeitung',
'description' => 'Die Stadionzeitung zu jedem Heimspiel des TSV 08 Kulmbach digital zum Durchblättern alle Ausgaben im Archiv.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
]),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'Stadionzeitung',
'text' => 'Die Stadionzeitung zu jedem Heimspiel digital zum Durchblättern, auch unterwegs am Handy.',
'image' => [
'base' => 'img/pages/fussball-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
component('stadionzeitung-cards', ['issues' => $issues]);

View File

@@ -10,8 +10,8 @@ $slug = 'fussball/damen';
$team = json_load('teams')['damen'];
$meta = [
'title' => 'Frauenmannschaft Frauenfußball Kulmbach',
'description' => 'Frauen- und Mädchenfußball beim TSV 08 Kulmbach: Ob erfahren oder Wiedereinsteigerin im Katzbachtal findest du dein Team. Komm zum Probetraining.',
'title' => 'Frauenmannschaft · Frauenfußball Kulmbach',
'description' => 'Frauenfußball in Kulmbach beim TSV 08: Ob erfahren oder Wiedereinsteigerin, im Katzbachtal findest du dein Team. Komm zum Probetraining!',
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug),
];

View File

@@ -9,8 +9,8 @@ $slug = 'fussball/erste-mannschaft';
$team = json_load('teams')['erste-mannschaft'];
$meta = [
'title' => '1. Mannschaft Kreisklasse Kulmbach',
'description' => 'Die erste Herrenmannschaft des TSV 08 Kulmbach spielt auf Kreisklasse-Niveau ambitioniert und mit echtem Vereinsgeist. Komm vorbei und spiel mit.',
'title' => '1. Mannschaft · Kreisklasse Kulmbach',
'description' => 'Die 1. Mannschaft des TSV 08 Kulmbach spielt Fußball in der Kreisklasse im Katzbachtal. Ehrgeizig, aber familiär: Komm zum Probetraining!',
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug),
];

View File

@@ -9,8 +9,8 @@ $slug = 'fussball/zweite-mannschaft';
$team = json_load('teams')['zweite-mannschaft'];
$meta = [
'title' => '2. Mannschaft Herrenfußball Kulmbach',
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach spielt in Spielgemeinschaft mit dem TSV Melkendorf Fußball mit Spaß für alle, die gern kicken.',
'title' => '2. Mannschaft · Herrenfußball Kulmbach',
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach: Spielgemeinschaft mit dem TSV Melkendorf. Fußball mit Spaß für alle, die gern kicken. Komm vorbei!',
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [],
'schema' => team_schema($team, $slug),

62
app/pages/termine.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* Zentrale Termine/Events-Seite: kombiniert Fußball-Spieltermine (aus
* data/matchcenter.json — next_matches/last_results, NICHT dupliziert) mit
* manuell gepflegten Vereins-Events (data/events.json, chat-gepflegt wie
* teams.json/partners.json — keine Automatisierung, kein Bin-Skript) und den
* Veranstaltungen mit eigener Detailseite (data/veranstaltungen.json, dorthin
* verlinkt). Getrennt
* in kommend/vergangen, je eine gemeinsame chronologische Liste — kein Filter
* nach Kategorie (v1, siehe Interview). Merge-Logik in termine_build_lists()
* (app/helpers.php) — dieselbe Grundlage nutzt die Stadionzeitungs-Vorlage
* (Phase 3), damit beide nie auseinanderlaufen.
*/
$termine = termine_build_lists();
$meta = [
'title' => 'Termine',
'description' => 'Alle kommenden Termine des TSV 08 Kulmbach: Fußball-Spiele unserer Mannschaften und Vereins-Events in Kulmbach auf einen Blick. Schau vorbei im Katzbachtal!',
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Termine', 'slug' => 'termine'],
]),
sportsevent_nodes($termine['upcoming_matches']),
array_values(array_filter(array_map('event_schema', $termine['events']))),
// Veranstaltungen tragen zusätzlich ihre eigene URL, damit dieser Knoten
// und der auf der Detailseite dasselbe Event beschreiben.
array_values(array_filter(array_map(
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
$termine['veranstaltungen']
)))
),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'Termine',
'text' => 'Alle kommenden Spiele und Vereins-Events auf einen Blick.',
'image' => [
'base' => 'img/pages/fussball-hero',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
?>
<section class="section">
<div class="container">
<h2>Kommende Termine</h2>
<?php component('termine-list', ['items' => $termine['upcoming'], 'empty_text' => 'Aktuell sind keine Termine geplant.']); ?>
</div>
</section>
<section class="section section--sunken">
<div class="container">
<h2>Vergangene Termine</h2>
<?php component('termine-list', ['items' => $termine['past'], 'empty_text' => 'Noch keine vergangenen Termine erfasst.']); ?>
</div>
</section>

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/hero/hero-1600.jpg',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'scripts' => ['group-tabs.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],

View File

@@ -11,8 +11,8 @@ $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/hero/hero-1600.jpg',
'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',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -11,8 +11,8 @@ $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/hero/hero-1600.jpg',
'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'],

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['trampolin'];
$meta = [
'title' => 'Trampolinturnen in Kulmbach',
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
'og_image' => 'img/hero/hero-1600.jpg',
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin: Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['wettkampfturnen'];
$meta = [
'title' => 'Wettkampfturnen in Kulmbach',
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
'og_image' => 'img/hero/hero-1600.jpg',
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen: Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],

View File

@@ -3,14 +3,24 @@
declare(strict_types=1);
/**
* Turn-Übersichtsseite (reiner Hub). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten. Keine weiteren Sektionen — die Detailinhalte
* (Beiträge, FAQ, Trainingszeiten) leben auf den Disziplin-Unterseiten.
* Turn-Übersichtsseite (Hub + Abteilungskontakt). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten → Anfrageformular. Die Detailinhalte
* (Beiträge, Trainingszeiten) leben auf den Disziplin-Unterseiten.
*
* Das Formular ist die allgemeine contact-form-Komponente mit
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
* Turnabteilung (club.email_turnen) statt info@. Die alte Seite hatte dafür ein
* eigenes, zweites Formular — hier ist es dieselbe Komponente und dieselbe
* Action, damit der Spam-Schutz nicht doppelt gepflegt werden muss.
*/
$page = json_load('turnen')['overview'];
// Heros nutzen das Vereins-Default-Bild; nur die Übersichts-Kacheln zeigen die
// (vorläufigen) themenbezogenen Unsplash-Platzhalter aus public/assets/img/pages/.
// 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/.
$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']],
@@ -22,7 +32,7 @@ $cards = [
$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.',
'og_image' => 'img/hero/hero-1600.jpg',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'],
@@ -36,3 +46,8 @@ component('team-cards', [
'title_hidden' => true,
'anchor' => 'gruppen',
]);
component('contact-form', [
'intro' => $page['contact'],
'return' => 'turnen',
'department' => 'turnen',
]);

157
app/pages/veranstaltung.php Normal file
View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
/**
* Einzelne Veranstaltung: dynamische Route veranstaltungen/<slug>, siehe
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
* Ermittelt den Slug selbst wieder aus der URL, Daten aus
* 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
* trägt noindex.
*
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
* ein Archiv-Eintrag hat kein eigenes Titelbild, und das einzige vorhandene
* Bild ist ein Plakat im Hochformat, das als cover-beschnittener
* Hero-Hintergrund zerstört würde. Stattdessen ein schmaler Kopf mit Rückweg.
* Die Übersichtsseite /veranstaltungen behält den regulären Unterseiten-Hero.
*/
$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;
}
}
if ($v === null) {
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
http_response_code(404);
require APP_PATH . '/pages/404.php';
return;
}
$isDraft = !veranstaltung_is_published($v);
// Der Lebenszyklus läuft über das Datum, nicht über ein Feld von Hand:
// vor dem Fest wirbt die Seite, danach erzählt sie. Maßgeblich ist der LETZTE
// Tag (termin_is_upcoming()), damit ein dreitägiges Fest am Samstag nicht schon
// zum Rückblick wird.
$isVorbei = !termin_is_upcoming($v, date('Y-m-d'));
$plakate = $v['plakate'] ?? [];
$galerie = $v['galerie'] ?? [];
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
$meta = [
'title' => $v['title'],
'description' => (string) ($v['text'] ?? $v['title']),
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
['name' => $v['title'], 'slug' => 'veranstaltungen/' . $v['slug']],
]),
array_values(array_filter([event_schema($v, 'veranstaltungen/' . $v['slug'])]))
),
];
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
if ($plakate !== [] || $galerie !== []) {
$meta['scripts'] = ['lightbox.js'];
}
$ogBild = $plakate[0] ?? $galerie[0] ?? null;
if ($ogBild !== null && !empty($ogBild['widths'])) {
$meta['og_image'] = $ogBild['base'] . '-' . max($ogBild['widths']) . '.jpg';
}
if ($isDraft) {
$meta['noindex'] = true;
}
// Einleitung: vor dem Fest die Ankündigung, danach der Rückblick. Fehlt der
// jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die Seite darf
// nie leer sein, sie startet ja mit nichts als dem Plakat.
// Fällt der jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die
// Seite darf nie leer sein, sie startet ja mit nichts als dem Plakat.
$introText = $isVorbei ? ($v['rueckblick'] ?? null) : ($v['ankuendigung'] ?? null);
$introSection = !empty($introText) ? [
'id' => 'ueberblick',
'title' => $isVorbei ? 'Unser Rückblick' : 'Das erwartet dich',
// Ohne Bild läuft die Sektion einspaltig auf Lesebreite (split--plain).
'text' => $introText,
] : null;
?>
<?php component('detail-head', [
'back' => ['href' => url('veranstaltungen'), 'label' => 'Alle Veranstaltungen'],
'title' => $v['title'],
'badge' => $isDraft ? 'Entwurf' : '',
'date_iso' => (string) $v['date_start'],
'date_text' => $dateText,
'location' => (string) ($v['location'] ?? ''),
]); ?>
<?php /* Einleitungstext nur, wenn wirklich einer gepflegt ist — die Regel für die
ganze Seite: gezeigt wird, was in der JSON steht, und sonst nichts. */ ?>
<?php if ($introSection !== null): ?>
<?php component('section', ['section' => $introSection]); ?>
<?php endif; ?>
<?php /* Plakate und Galerie tauschen den Platz: vor dem Fest wirbt das Plakat,
danach tragen die Fotos die Seite und das Plakat bleibt als Andenken. */ ?>
<?php if ($isVorbei): ?>
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => 'Plakate von damals', 'compact' => true]); ?>
<?php else: ?>
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => count($plakate) > 1 ? 'Plakat und Programm' : 'Plakat']); ?>
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
<?php endif; ?>
<?php /* Zeitraum, Beginn, Adresse und Karte in einem Block — aber nur, solange das
Fest noch aussteht. Hinterher wäre es reine Doppelung: Datum und Ort
stehen im Kopf, und eine Anfahrtskarte zu einem Fest von letztem Jahr
hilft niemandem mehr. Alles Weitere steht auf den Plakaten. */ ?>
<?php if (!$isVorbei): ?>
<?php component('veranstaltung-info', [
'date_text' => $dateText,
'date_iso' => (string) $v['date_start'],
'time' => (string) ($v['time'] ?? ''),
'location' => (string) ($v['location'] ?? ''),
'karte' => (string) ($v['karte'] ?? ''),
'anfahrt' => $v['anfahrt'] ?? [],
]); ?>
<?php endif; ?>
<?php
// phase steuert, welche Abschnitte wann stehen: „Anfahrt und Parken" hilft nur
// vor dem Fest. Ohne Angabe steht ein Abschnitt immer — der harmlosere Default:
// geschriebener Text verschwindet nie überraschend.
foreach ($v['abschnitte'] ?? [] as $abschnitt):
$phase = $abschnitt['phase'] ?? 'immer';
if ($phase !== 'immer' && $phase !== ($isVorbei ? 'rueckblick' : 'ankuendigung')) {
continue;
}
?>
<?php component('section', ['section' => $abschnitt]); ?>
<?php endforeach; ?>
<?php /* Das Programm als Liste ist die Ausnahme, nicht die Regel: normalerweise
trägt es das Programmplakat. Nur wenn `programm` gepflegt ist, kommt es
zusätzlich als lesbare Liste dazu (Screenreader, Suchmaschinen). */ ?>
<?php component('veranstaltung-programm', [
'programm' => $v['programm'] ?? [],
'title' => $isVorbei ? 'So lief unser Programm' : 'Programm',
'compact' => $isVorbei,
]); ?>
<?php /* Helfer-Aufruf nur vor dem Fest: hinterher sind die Schichten gelaufen. */ ?>
<?php if (!$isVorbei && !empty($v['helfer'])): ?>
<?php component('cta-band', ['band' => $v['helfer'], 'anchor' => 'helfen']); ?>
<?php endif; ?>
<?php if (!empty($v['partner'])): ?>
<?php component('partner-grid', ['intro' => $v['partner'], 'anchor' => 'fest-partner']); ?>
<?php endif; ?>

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* Übersicht aller Veranstaltungen mit eigener Detailseite (Sportfest,
* Jubiläum, …) — Quelle data/veranstaltungen.json, geladen über
* veranstaltungen_load() (filtert Entwürfe, sortiert neueste zuerst).
*
* Zwei Abschnitte statt einer gemischten Liste: oben was ansteht (der nächste
* Termin als große Karte mit Plakat), unten das Archiv. Auch in der Sommerpause
* ohne anstehendes Fest ist die Seite damit gefüllt.
*
* Abgrenzung zu /termine: dort steht der vollständige Kalender (Spiele +
* Vereins-Termine + Veranstaltungen), hier nur das, was eine eigene Seite hat.
*/
$today = date('Y-m-d');
$alle = veranstaltungen_load();
$kommend = array_values(array_filter($alle, static fn (array $v): bool => termin_is_upcoming($v, $today)));
// veranstaltungen_load() sortiert absteigend (fürs Archiv richtig) — was
// ansteht, gehört aufsteigend sortiert: das Nächste zuerst.
usort($kommend, static fn (array $a, array $b): int => (string) $a['date_start'] <=> (string) $b['date_start']);
$vergangen = array_values(array_filter($alle, static fn (array $v): bool => !termin_is_upcoming($v, $today)));
$meta = [
'title' => 'Veranstaltungen',
'description' => 'Unsere Feste und Veranstaltungen: Sportfest, Frühschoppen und alles, was wir im Katzbachtal auf die Beine stellen.',
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
]),
// Nur die anstehenden als Event-Knoten: ein vergangenes Fest ist für
// Suchmaschinen kein Termin mehr, den man sich vormerken kann. Mit Slug,
// damit es derselbe Knoten ist wie auf /termine und der Detailseite.
array_values(array_filter(array_map(
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
$kommend
)))
),
];
component('hero', ['hero' => [
'compact' => true,
'align' => 'center',
'title' => 'Veranstaltungen',
'text' => 'Sportfest, Frühschoppen, Feiern: hier findest du alles zu unseren Festen, vorher das Programm und hinterher die Bilder.',
'image' => [
'base' => 'img/pages/vereinsgelaende',
'widths' => [640, 1000, 1600],
'alt' => '',
],
]]);
?>
<section class="section">
<div class="container">
<h2>Was ansteht</h2>
<?php component('veranstaltungen-cards', [
'items' => $kommend,
'cta' => 'Zum Programm',
'empty_text' => 'Gerade ist kein Fest geplant. Sobald der nächste Termin steht, findest du ihn hier.',
]); ?>
</div>
</section>
<section class="section section--sunken">
<div class="container">
<h2>Rückblick</h2>
<?php component('veranstaltungen-cards', [
'items' => $vergangen,
'cta' => 'Bilder ansehen',
'empty_text' => 'Noch kein Fest im Rückblick. Nach unserem nächsten Sportfest findest du hier die Bilder.',
]); ?>
</div>
</section>

View File

@@ -26,7 +26,7 @@ $cards = [
$meta = [
'title' => 'Der Verein',
'description' => 'Der TSV 1908 Kulmbach e. V. seit 1908 dein Sportverein im Katzbachtal: über 650 Mitglieder, Fußball und Turnen, zuhause auf der Hans-Rausch-Sportanlage.',
'description' => 'Der TSV 1908 Kulmbach e. V.: seit 1908 dein Sportverein in Kulmbach mit über 650 Mitgliedern. Fußball und Turnen auf der Hans-Rausch-Sportanlage im Katzbachtal.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema(
[

View File

@@ -11,11 +11,13 @@ declare(strict_types=1);
*/
return [
'' => ['file' => 'home.php', 'sitemap' => true],
'news' => ['file' => 'news.php', 'sitemap' => true],
'fussball' => ['file' => 'fussball.php', 'sitemap' => true],
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true],
'stadionzeitung' => ['file' => 'stadionzeitung.php', 'sitemap' => true],
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
@@ -28,6 +30,8 @@ return [
'historie' => ['file' => 'historie.php', 'sitemap' => true],
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
'termine' => ['file' => 'termine.php', 'sitemap' => true],
'veranstaltungen' => ['file' => 'veranstaltungen.php', 'sitemap' => true],
'kontakt' => ['file' => 'kontakt.php', 'sitemap' => true],
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],

181
bin/anzeige-add.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* Sponsoren-Anzeige in den Bestand aufnehmen oder ihr Motiv erneuern (nur CLI).
*
* php bin/anzeige-add.php <bild-oder-ordner> [-v]
*
* Beispiele:
* php bin/anzeige-add.php ~/Desktop/anzeigen/ alle Bilder im Ordner
* php bin/anzeige-add.php ~/Desktop/eldorado.jpg nur diese eine
*
* DER DATEINAME IST DER SLUG: `eldorado.jpg` wird zu `img/anzeigen/eldorado-*.jpg`
* und findet damit den bestehenden Eintrag wieder. Ein neues Motiv für einen
* bekannten Sponsor heißt also einfach wieder `eldorado.jpg`.
*
* Anzeigen sind ein DAUERHAFTER Bestand (data/anzeigen.json), kein Zubehör einer
* einzelnen Ausgabe: Sponsorenmotive ändern sich selten, die Stadionzeitung
* erscheint oft. bin/stadionzeitung-add.php holt sie sich von hier, pro Heft ist
* nichts abzuliefern. Vorher lag jede Anzeige als Kopie unter jeder Ausgabe und
* ihr Alt-Text musste jedes Mal neu geschrieben werden.
*
* Das Skript schreibt ausschließlich `base` und `widths`. `sponsor`, `alt`,
* `aktiv` und die REIHENFOLGE der Liste (= Reihenfolge im Heft) sind
* chat-gepflegt und überleben jeden Lauf unangetastet — dasselbe Prinzip wie bei
* bin/veranstaltung-bilder.php. Bei einem neuen Sponsor wird `sponsor` aus dem
* Dateinamen vorbelegt und muss im Chat richtiggestellt werden; das Skript sagt,
* bei welchen.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('stadionzeitung', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
exit(1);
};
if ($args === []) {
fwrite(STDERR, "Nutzung: php bin/anzeige-add.php <bild-oder-ordner> [-v]\n");
exit(1);
}
// --- Quelldateien einsammeln ---
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$quelle = $args[0];
if (is_dir($quelle)) {
$sources = glob(rtrim($quelle, '/') . "/*.{{$exts}}", GLOB_BRACE) ?: [];
natsort($sources);
$sources = array_values($sources);
} elseif (is_file($quelle)) {
$sources = [$quelle];
} else {
$fail("Nicht gefunden: {$quelle}");
}
if ($sources === []) {
$fail("Keine Bilder in {$quelle} gefunden.");
}
// Selbst-Import verhindern: zeigt jemand auf den Zielordner, wären die Quellen
// die erzeugten Varianten (`eldorado-1440.jpg`), und aus jeder Breite entstünde
// ein eigener Sponsor („eldorado 1440"). Einmal reingelaufen, deshalb der Riegel.
$zielReal = realpath(PUBLIC_PATH . '/assets/img/anzeigen');
foreach ($sources as $src) {
if ($zielReal !== false && realpath(dirname($src)) === $zielReal) {
$fail('Quelle liegt im Zielordner public/assets/img/anzeigen/ — das sind die erzeugten Varianten, nicht die Originale.');
}
}
// --- Lock gegen parallele Läufe ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/anzeigen.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
$dataFile = DATA_PATH . '/anzeigen.json';
$data = is_file($dataFile)
? (json_decode((string) file_get_contents($dataFile), true) ?: ['anzeigen' => []])
: ['anzeigen' => []];
$destDir = PUBLIC_PATH . '/assets/img/anzeigen';
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
$widths = [480, 960, 1440];
/** Dateiname zu Slug: Kleinschreibung, Umlaute weg, alles andere zu Bindestrichen. */
$slugify = static function (string $text): string {
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
$text = strtolower($text);
return trim(preg_replace('/[^a-z0-9]+/', '-', $text) ?? '', '-');
};
$neu = [];
$erneuert = [];
foreach ($sources as $src) {
$slug = $slugify(pathinfo($src, PATHINFO_FILENAME));
if ($slug === '') {
$log('Dateiname ergibt keinen Slug, übersprungen: ' . basename($src), 'WARN');
continue;
}
// 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) {
unlink($alt);
}
try {
// Qualität 82 wie bei den Plakaten: Anzeigen tragen Text, und Ringing um
// Buchstaben sieht man.
$variants = resize_image_variants($src, "{$destDir}/{$slug}", $widths, 82);
} catch (RuntimeException $e) {
$fail('Anzeige nicht verarbeitbar (' . basename($src) . '): ' . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
// Bestehenden Eintrag suchen und NUR base/widths ersetzen.
$index = null;
foreach ($data['anzeigen'] as $i => $eintrag) {
if (($eintrag['slug'] ?? '') === $slug) {
$index = $i;
break;
}
}
if ($index === null) {
// Neuer Sponsor: Platzhalter-Name aus dem Slug, im Chat zu ersetzen.
$data['anzeigen'][] = [
'slug' => $slug,
'sponsor' => str_replace('-', ' ', $slug),
'aktiv' => true,
'base' => "img/anzeigen/{$slug}",
'widths' => $generated,
];
$neu[] = $slug;
} else {
$data['anzeigen'][$index]['base'] = "img/anzeigen/{$slug}";
$data['anzeigen'][$index]['widths'] = $generated;
$erneuert[] = $slug;
}
$log("Anzeige {$slug}: " . implode('/', $generated) . 'px');
}
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $dataFile . '.tmp';
file_put_contents($tmp, $payload . "\n");
if (!rename($tmp, $dataFile)) {
$fail('Konnte data/anzeigen.json nicht schreiben.');
}
$log(sprintf('Anzeigen-Bestand aktualisiert: %d neu, %d erneuert.', count($neu), count($erneuert)));
echo 'Anzeigen-Bestand: ' . count($data['anzeigen']) . ' Motive, ' . count($neu) . ' neu, ' . count($erneuert) . " erneuert.\n";
if ($neu !== []) {
echo "Sponsorenname noch Platzhalter bei: " . implode(', ', $neu) . "\n";
echo "In data/anzeigen.json richtigstellen (daraus entsteht der Alt-Text „Anzeige: …\").\n";
}

491
bin/chronik-add.php Normal file
View File

@@ -0,0 +1,491 @@
<?php
declare(strict_types=1);
/**
* Fundstücke für die Vereinschronik 2028 einsammeln und ablegen.
*
* Holt zu einer Web-Adresse Überschrift, Datum, Teaser, Bildnachweis und das Aufmacherbild,
* legt beides im richtigen Jahrzehnt-Ordner unter Vereinshistorie/ ab, schreibt einen
* Quellen-Beleg daneben und eine Zeile ins Register (docs/chronik/register.csv).
*
* Warum überhaupt ein Skript und kein „Rechtsklick, Bild speichern": das Bild allein ist
* in fünf Jahren wertlos. Woher es stammt, wann es entstand und wem es gehört, steht nirgends
* im JPEG — und genau das braucht man, wenn die Chronik in den Druck geht. Das Skript hält
* den Zusammenhang fest, in dem Moment, in dem er noch bekannt ist.
*
* Es wird NICHTS massenhaft abgegriffen: pro Aufruf eine Seite, mit Pause zwischen den
* Abrufen. Die Presseportale haben keine Übersichtsseite mehr, über die man den Verein
* vollständig abfragen könnte — die Fundstellen kommen aus gezielter Suche, nicht aus
* einem Crawler.
*
* NUR CLI, nie im Request-Pfad (wie alle bin/-Skripte).
*
* Aufruf:
* php bin/chronik-add.php <URL> [--datum=1972-07-15] [--titel="…"] [--art=Zeitungsartikel]
* [--notiz="…"] [--urheber="…"] [--ohne-bild] [--nur-register]
* php bin/chronik-add.php --liste=urls.txt Zeilen: URL | Datum | Titel (| optional)
* php bin/chronik-add.php --datei=<pfad> --datum=1983-06-11 --titel="Festzug 75 Jahre"
* [--quelle="Album Familie Rausch"] [--urheber="…"] [--art=Foto]
* [--rechte-klaeren]
* php bin/chronik-add.php --rechte nur 00_RECHTE-ZU-KLAEREN.txt neu schreiben
* php bin/chronik-add.php --bericht Registerübersicht auf stdout
*
* Optionen --datum/--titel gewinnen immer gegen das, was die Seite selbst angibt.
* --datei benennt die Datei um und verschiebt sie in den passenden Jahrzehnt-Ordner.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
require __DIR__ . '/chronik-lib.php';
$opts = [];
$positional = [];
foreach (array_slice($argv, 1) as $arg) {
if (preg_match('/^--([a-z-]+)(?:=(.*))?$/s', $arg, $m)) {
$opts[$m[1]] = $m[2] ?? true;
} else {
$positional[] = $arg;
}
}
$root = chronik_root();
if (!is_dir($root)) {
fwrite(STDERR, "Vereinshistorie/ fehlt — erst 'php bin/chronik-init.php' laufen lassen.\n");
exit(1);
}
// --- Nebenmodi -------------------------------------------------------------
if (isset($opts['rechte'])) {
$n = chronik_write_rights_todo();
echo "00_RECHTE-ZU-KLAEREN.txt geschrieben — {$n} offene Posten.\n";
exit(0);
}
if (isset($opts['bericht'])) {
chronik_report();
exit(0);
}
// --- HTTP ------------------------------------------------------------------
/**
* Browser-typische Header. Ohne sie antworten mehrere der Quellen mit 403 —
* dieselbe Erfahrung wie beim BFV-Widget (dort HTTP 418), siehe matchcenter-sync.php.
*/
function chronik_fetch(string $url, ?string $referer = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 25,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
CURLOPT_HTTPHEADER => array_filter([
'Accept: text/html,application/xhtml+xml,image/avif,image/webp,*/*;q=0.8',
'Accept-Language: de-DE,de;q=0.9',
$referer !== null ? 'Referer: ' . $referer : null,
]),
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$final = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
return [
'ok' => $body !== false && $status === 200,
'status' => $status,
'body' => $body === false ? '' : $body,
'type' => $type,
'url' => $final !== '' ? $final : $url,
];
}
/** Ersten Treffer eines Musters liefern, HTML-Entities aufgelöst. */
function chronik_match(string $html, string $pattern): ?string
{
if (!preg_match($pattern, $html, $m)) {
return null;
}
$val = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $val !== '' ? $val : null;
}
/**
* Fließtext einer Seite herausschälen.
*
* Grob und absichtlich: Skripte und Styles raus, Tags raus, alles behalten, was länger als
* ein Navigationspunkt ist. Für saubere Extraktion bräuchte es pro Portal eine eigene Regel —
* das lohnt hier nicht, weil der Text als Beleg dient und nicht als Layout.
*/
function chronik_body_text(string $html): string
{
$html = (string) preg_replace('#<(script|style|nav|header|footer|form)\b.*?</\1>#is', ' ', $html);
$html = (string) preg_replace('#<(br|/p|/div|/h[1-6]|/li|/td|/tr)[^>]*>#i', "\n", $html);
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$keep = [];
$seen = [];
foreach (explode("\n", $text) as $line) {
$line = trim((string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $line));
// 45 Zeichen trennt Fließtext zuverlässig von Menüpunkten und Buttons.
if (mb_strlen($line) < 45 || isset($seen[$line])) {
continue;
}
$seen[$line] = true;
$keep[] = $line;
}
return implode("\n\n", $keep);
}
/** Seite auswerten: was an Metadaten drinsteht, ohne Layout-Annahmen. */
function chronik_parse(string $html): array
{
$strip = static fn(string $s): string => trim((string) preg_replace('/\s+/', ' ', strip_tags($s)));
$titel = chronik_match($html, '/<meta property="og:title" content="([^"]+)"/i')
?? chronik_match($html, '/<h1[^>]*>(.*?)<\/h1>/is')
?? chronik_match($html, '/<title[^>]*>(.*?)<\/title>/is');
if ($titel !== null) {
$titel = $strip($titel);
// Seitentitel tragen oft " | Portalname" mit — für den Dateinamen stört das nur.
$titel = (string) preg_replace('/\s*[|-]\s*(inFranken\.de|BFV|FUSSBALL\.DE|Deutsche Turnliga)\s*$/iu', '', $titel);
}
$datum = chronik_match($html, '/<meta property="article:published_time" content="(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/"datePublished"\s*:\s*"(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/datetime="(\d{4}-\d{2}-\d{2})/i');
$teaser = chronik_match($html, '/<meta property="og:description" content="([^"]+)"/i')
?? chronik_match($html, '/<meta name="description" content="([^"]+)"/i');
$bild = chronik_match($html, '/<meta property="og:image" content="([^"]+)"/i');
// Bildnachweis steht bei Presseseiten fast immer in der Bildunterschrift
// („Foto: Max Mustermann") — genau die Angabe, die später fehlt.
$urheber = null;
if (preg_match_all('/<figcaption[^>]*>(.*?)<\/figcaption>/is', $html, $caps)) {
foreach ($caps[1] as $cap) {
$cap = $strip(html_entity_decode($cap, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (preg_match('/\b(Foto|Bild|Quelle|©)\s*:?\s*(.{2,80})$/ui', $cap, $m)) {
$urheber = trim($m[2], " .;");
break;
}
}
}
$urheber ??= chronik_match($html, '/<meta name="author" content="([^"]+)"/i');
return compact('titel', 'datum', 'teaser', 'bild', 'urheber');
}
// --- Ein Fundstück verarbeiten ---------------------------------------------
function chronik_add(string $url, array $opts, array $override = []): bool
{
$root = chronik_root();
$heute = date('Y-m-d');
if (chronik_register_has_url($url) && !isset($opts['erneut'])) {
echo "· schon im Register: {$url}\n";
return true;
}
// Bei Wayback-Adressen zählt für Rechte und Quellenkürzel die ARCHIVIERTE Seite,
// nicht das Archiv — abgerufen wird trotzdem der Schnappschuss.
$quellUrl = chronik_origin_url($url);
$host = (string) (parse_url($quellUrl, PHP_URL_HOST) ?: '');
if ($host === '') {
fwrite(STDERR, "✗ Keine gültige Adresse: {$url}\n");
return false;
}
$rights = chronik_rights($host);
$meta = ['titel' => null, 'datum' => null, 'teaser' => null, 'bild' => null, 'urheber' => null];
$volltext = '';
if (!isset($opts['nur-register'])) {
$res = chronik_fetch($url);
if (!$res['ok']) {
fwrite(STDERR, "✗ HTTP {$res['status']}: {$url}\n");
log_write('chronik', 'WARN', "Abruf fehlgeschlagen (HTTP {$res['status']}): {$url}");
return false;
}
$meta = chronik_parse($res['body']);
// Die Rechtelage entscheidet, wie viel Text bleibt: eigenes Vereinsmaterial sichern
// wir vollständig (sonst ist es weg — die alten Vereinsseiten sind offline), fremde
// Presseartikel nur als Nachweis mit Überschrift und Anriss.
if (!$rights['klaeren']) {
$volltext = chronik_body_text($res['body']);
}
}
$titel = (string) ($override['titel'] ?? $opts['titel'] ?? $meta['titel'] ?? 'Ohne Titel');
$datum = $override['datum'] ?? $opts['datum'] ?? $meta['datum'] ?? null;
$urheber = (string) ($opts['urheber'] ?? $meta['urheber'] ?? '');
$art = (string) ($opts['art'] ?? 'Zeitungsartikel');
$notiz = (string) ($opts['notiz'] ?? '');
if ($quellUrl !== $url) {
$notiz = trim($notiz . ' Archivfassung (Internet Archive), Original nicht mehr online: ' . $quellUrl);
}
$tag = chronik_source_tag($host);
$bucket = chronik_bucket(is_string($datum) ? $datum : null);
$dnorm = chronik_date_norm(is_string($datum) ? $datum : null);
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
$dir = $root . '/' . $bucket;
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Namenskollision auflösen. Passiert wirklich: zwei Seiten „Bundesliga" und
// „Bundesliga-1" mit gleicher Überschrift und gleichem Jahr ergeben denselben Namen —
// ohne Suffix überschreibt der zweite Fund den ersten stillschweigend.
// Anker ist die .quelle.txt, die zu jedem Fundstück entsteht.
if (is_file($dir . '/' . $base . '.quelle.txt')) {
$n = 2;
while (is_file($dir . '/' . $base . '-' . $n . '.quelle.txt')) {
$n++;
}
$base .= '-' . $n;
}
// --- Aufmacherbild ---
$bildDatei = '';
if (!isset($opts['ohne-bild']) && !isset($opts['nur-register']) && is_string($meta['bild'])) {
$img = chronik_fetch($meta['bild'], $url);
$ext = match (true) {
str_contains($img['type'], 'png') => 'png',
str_contains($img['type'], 'webp') => 'webp',
str_contains($img['type'], 'gif') => 'gif',
default => 'jpg',
};
// 6 KB Untergrenze: Portale liefern bei fehlendem Aufmacher ein Platzhalter-Pixel aus.
if ($img['ok'] && strlen($img['body']) > 6144) {
$bildDatei = $base . '.' . $ext;
file_put_contents($dir . '/' . $bildDatei, $img['body']);
}
}
// --- Textbeleg: das Fundstück bleibt auffindbar, auch wenn die Seite verschwindet ---
$belegDatei = $base . '.artikel.txt';
$beleg = implode("\n", array_filter([
$titel,
str_repeat('=', min(72, max(8, strlen($titel)))),
'',
$dnorm !== '0000-00-00' ? 'Erschienen: ' . $dnorm : null,
'Quelle: ' . $host,
'URL: ' . $url,
'Abgerufen: ' . $heute,
'',
// Bei Volltext ist der Teaser nur dessen erster Absatz — nicht doppelt hinschreiben.
($meta['teaser'] !== null && $volltext === '') ? wordwrap((string) $meta['teaser'], 88, "\n") : null,
$volltext === '' ? '' : null,
'--',
$volltext !== ''
? "Eigenes Vereinsmaterial — vollständig gesichert, weil die Quellseite offline ist."
: 'Nur Überschrift, Datum und Anrisstext festgehalten — der vollständige Artikeltext',
$volltext !== ''
? ''
: 'gehört dem Verlag und wird hier nicht gespeichert. Für die Chronik über die URL',
$volltext !== '' ? '' : 'oder das Zeitungsarchiv im Original nachlesen.',
$volltext !== '' ? "\n" . wordwrap($volltext, 88, "\n") : null,
], static fn($l): bool => $l !== null));
file_put_contents($dir . '/' . $belegDatei, $beleg . "\n");
// --- Register + Beleg-Sidecar ---
$row = [
'datei' => $bucket . '/' . ($bildDatei !== '' ? $bildDatei : $belegDatei),
'datum' => $dnorm,
'titel' => $titel,
'art' => $art,
'quelle' => $host,
'url' => $url,
'urheber' => $urheber,
'rechte' => $rights['status'],
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
'abgerufen' => $heute,
'notiz' => $notiz,
];
chronik_register_append($row);
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
$flag = $rights['klaeren'] ? ' [Rechte klären]' : '';
echo "{$bucket}/{$base}" . ($bildDatei !== '' ? ' (+Bild)' : ' (nur Text)') . $flag . "\n";
log_write('chronik', 'INFO', "Fundstück aufgenommen: {$bucket}/{$base} ({$host})");
return true;
}
/**
* Eine Datei aufnehmen, die schon da ist — Scan aus einer Festschrift, Foto aus einem
* privaten Album, per Hand gespeichertes Pressebild.
*
* Genau derselbe Weg wie bei einer Web-Adresse: umbenennen nach Konvention, ins richtige
* Jahrzehnt einsortieren, Beleg daneben, Zeile ins Register. Ohne das landen die Scans aus
* dem Sammelaufruf als IMG_4711.jpg im Ordner und sind in einem Jahr nicht mehr zuzuordnen.
*/
function chronik_add_file(string $path, array $opts): bool
{
if (!is_file($path)) {
fwrite(STDERR, "✗ Datei nicht gefunden: {$path}\n");
return false;
}
$root = chronik_root();
$heute = date('Y-m-d');
$datum = is_string($opts['datum'] ?? null) ? $opts['datum'] : null;
$titel = (string) ($opts['titel'] ?? pathinfo($path, PATHINFO_FILENAME));
$quelle = (string) ($opts['quelle'] ?? 'Vereinsbestand');
$urheber = (string) ($opts['urheber'] ?? '');
$art = (string) ($opts['art'] ?? 'Foto');
$notiz = (string) ($opts['notiz'] ?? '');
// Ohne --quelle gilt: es kommt aus dem Verein und ist nutzbar. Mit --quelle=<Host>
// greift dieselbe Einschätzung wie bei einem Abruf aus dem Netz.
$rights = str_contains($quelle, '.')
? chronik_rights($quelle)
: ['status' => 'Vereinsbestand — Herkunft ' . $quelle, 'klaeren' => false,
'hinweis' => 'Aus dem Bestand des Vereins bzw. von Mitgliedern. Bei erkennbaren Personen '
. 'die Einwilligung zur Veröffentlichung einholen, bei Kindern die Eltern fragen.'];
if (isset($opts['rechte-klaeren'])) {
$rights['klaeren'] = true;
}
$tag = str_contains($quelle, '.') ? chronik_source_tag($quelle) : strtoupper(chronik_slug($quelle, 12));
$bucket = chronik_bucket($datum);
$dnorm = chronik_date_norm($datum);
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)) ?: 'bin';
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
$dir = $root . '/' . $bucket;
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "✗ Zielordner nicht anlegbar: {$bucket}\n");
return false;
}
$ziel = $dir . '/' . $base . '.' . $ext;
if (realpath($path) !== realpath($ziel) && !rename($path, $ziel)) {
fwrite(STDERR, "✗ Verschieben fehlgeschlagen: {$path}\n");
return false;
}
$row = [
'datei' => $bucket . '/' . $base . '.' . $ext,
'datum' => $dnorm,
'titel' => $titel,
'art' => $art,
'quelle' => $quelle,
'url' => (string) ($opts['url'] ?? ''),
'urheber' => $urheber,
'rechte' => $rights['status'],
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
'abgerufen' => $heute,
'notiz' => $notiz,
];
chronik_register_append($row);
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
echo "{$bucket}/{$base}.{$ext}" . ($rights['klaeren'] ? ' [Rechte klären]' : '') . "\n";
return true;
}
/** Registerübersicht — was haben wir, wo klafft die Lücke. */
function chronik_report(): void
{
$path = chronik_register_path();
if (!is_file($path)) {
echo "Register ist noch leer.\n";
return;
}
$fh = fopen($path, 'r');
fgetcsv($fh, 0, ';', '"', '\\');
$perBucket = array_fill_keys(array_merge(array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]), 0);
$perQuelle = [];
$offen = 0;
$gesamt = 0;
while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
$gesamt++;
$bucket = explode('/', (string) ($r[0] ?? ''))[0];
if (isset($perBucket[$bucket])) {
$perBucket[$bucket]++;
}
$perQuelle[$r[4] ?? '?'] = ($perQuelle[$r[4] ?? '?'] ?? 0) + 1;
if (($r[8] ?? '') === 'ja') {
$offen++;
}
}
fclose($fh);
echo "Chronik-Register — {$gesamt} Fundstücke, {$offen} mit offenen Rechten\n\n";
echo "Nach Zeitraum:\n";
foreach ($perBucket as $b => $n) {
printf(" %-28s %3d %s\n", $b, $n, $n === 0 ? '← Lücke' : str_repeat('▌', min(40, $n)));
}
echo "\nNach Quelle:\n";
arsort($perQuelle);
foreach ($perQuelle as $q => $n) {
printf(" %-28s %3d\n", $q, $n);
}
}
// --- Hauptlauf -------------------------------------------------------------
if (isset($opts['datei'])) {
$ok = chronik_add_file((string) $opts['datei'], $opts);
$offen = chronik_write_rights_todo();
echo "\n{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
exit($ok ? 0 : 1);
}
$jobs = [];
if (isset($opts['liste'])) {
$listFile = (string) $opts['liste'];
if (!is_file($listFile)) {
fwrite(STDERR, "Liste nicht gefunden: {$listFile}\n");
exit(1);
}
foreach (file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$parts = array_map('trim', explode('|', $line));
$jobs[] = [
'url' => $parts[0],
'datum' => ($parts[1] ?? '') !== '' ? $parts[1] : null,
'titel' => ($parts[2] ?? '') !== '' ? $parts[2] : null,
];
}
} elseif ($positional !== []) {
$jobs[] = ['url' => $positional[0], 'datum' => null, 'titel' => null];
} else {
fwrite(STDERR, "Aufruf: php bin/chronik-add.php <URL> | --liste=urls.txt | --rechte | --bericht\n");
exit(1);
}
$ok = 0;
$fehler = 0;
foreach ($jobs as $i => $job) {
if ($i > 0) {
// Pause zwischen den Abrufen: das hier ist Recherche, kein Crawl.
usleep(1_500_000);
}
$override = array_filter(['datum' => $job['datum'], 'titel' => $job['titel']], static fn($v): bool => $v !== null);
chronik_add($job['url'], $opts, $override) ? $ok++ : $fehler++;
}
$offen = chronik_write_rights_todo();
echo "\n{$ok} aufgenommen" . ($fehler > 0 ? ", {$fehler} fehlgeschlagen" : '') . ".\n";
echo "{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
exit($fehler > 0 ? 1 : 0);

94
bin/chronik-init.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* Ordnerstruktur für die Jubiläums-Chronik 2028 anlegen (unter Vereinshistorie/).
*
* Vereinshistorie/
* 00_LIESMICH.txt ← Benennung + Ablageregeln, direkt im Ordner lesbar
* 00_RECHTE-ZU-KLAEREN.txt ← von chronik-add.php gepflegt, siehe unten
* 1908-1929/ … 2010-2028/ ← Jahrzehnt-Bündel, chronologisch
* Festschriften-und-Dokumente/ ← Jubiläumsschriften, Satzungen, Urkunden, Protokolle
* Undatiert/ ← Fundstücke ohne Jahr; wandern nach der Zuordnung um
*
* Warum Zeit als oberste Ebene und nicht Abteilung: eine Chronik wird chronologisch
* gelesen und geschrieben. Fußball/Turnen steht im Dateinamen, danach kann man filtern —
* ein Jahrzehnt dagegen ist die Einheit, in der Kapitel entstehen.
*
* Warum Jahrzehnte und nicht Einzeljahre: für 1908-1970 gibt es pro Jahr oft null bis drei
* Fundstücke. 60 fast leere Ordner verstecken den Bestand, statt ihn zu zeigen. Die frühen
* Bündel sind bewusst 20 Jahre breit, die jüngeren enger — dort wird das Material dichter.
*
* Die Ablage ist bewusst dieselbe wie in dateien-init.php: flach, ohne Vorgaben in der
* Tiefe. Wer ein Ereignis bündeln will, legt darin „1972-07-15 Einweihung Sportheim" an.
*
* Idempotent — vorhandene Ordner und Dateien bleiben unberührt.
*
* NUR CLI, und die Ordner müssen auf dem SERVER entstehen, nicht per FTP hochgeladen
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC).
*
* Aufruf: php bin/chronik-init.php
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
require __DIR__ . '/chronik-lib.php';
$root = chronik_root();
$parent = dirname($root);
if (!is_dir($parent)) {
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
exit(1);
}
if (!is_writable($parent)) {
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
exit(1);
}
$created = 0;
$failed = 0;
$dirs = array_merge([''], array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]);
foreach (array_unique($dirs) as $rel) {
$dir = $rel === '' ? $root : $root . '/' . $rel;
$label = 'Vereinshistorie' . ($rel === '' ? '' : '/' . $rel);
if (is_dir($dir)) {
echo "· {$label} (gibt es schon)\n";
continue;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "{$label} konnte nicht angelegt werden\n");
$failed++;
continue;
}
echo "{$label}\n";
$created++;
}
// LIESMICH: die Ablageregeln gehören in den Ordner, nicht nur ins Repo — gelesen wird
// dort, wo hochgeladen wird. Wird nicht überschrieben, falls jemand sie ergänzt hat.
$readme = $root . '/00_LIESMICH.txt';
if (!is_file($readme)) {
file_put_contents($readme, chronik_readme_text());
echo "✓ Vereinshistorie/00_LIESMICH.txt\n";
$created++;
} else {
echo "· Vereinshistorie/00_LIESMICH.txt (gibt es schon)\n";
}
// Entwurf der Verlagsanfrage: gehört neben die Fundstücke, auf die er sich bezieht.
$anfrage = $root . '/00_RECHTE-Anfrage-Verlag-Entwurf.txt';
if (!is_file($anfrage)) {
file_put_contents($anfrage, chronik_verlagsanfrage_text());
echo "✓ Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt\n";
$created++;
} else {
echo "· Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt (gibt es schon)\n";
}
echo "\n{$created} Einträge angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
exit($failed > 0 ? 1 : 0);

564
bin/chronik-lib.php Normal file
View File

@@ -0,0 +1,564 @@
<?php
declare(strict_types=1);
/**
* Gemeinsame Logik der Chronik-Skripte (chronik-init.php, chronik-add.php).
*
* Bewusst hier unter bin/ und NICHT in app/helpers.php: das ist Recherche-Werkzeug für
* die Jubiläums-Chronik 2028, kein Website-Code. Nichts davon darf je im Request-Pfad
* landen — die Seite kennt die Chronik nicht.
*
* Zwei Dinge trägt diese Datei, und beide sind der eigentliche Punkt der Übung:
*
* 1. Der DATEINAME ist der Metadatenträger. Die Files Gallery unter /dateien kennt keine
* Felder für Datum, Quelle oder Rechte — sie listet Dateien. Also muss alles, was ein
* Fundstück später brauchbar macht, in den Namen: `1972-07-15_Einweihung-Sportheim_BR.jpg`.
* Datum vorn, damit die alphabetische Sortierung der Gallery zufällig die chronologische ist.
*
* 2. RECHTE werden beim Einsammeln festgehalten, nicht 2028 rekonstruiert. Zu jedem Fundstück
* entsteht eine `.quelle.txt` daneben und eine Zeile im Register. Was geklärt werden muss,
* landet zusätzlich in 00_RECHTE-ZU-KLAEREN.txt — im Ordner, wo es jemand sieht.
*/
/** Jahrzehnt-Bündel: [von, bis] => Ordnername. Früh breit, später enger — dort wird das Material dichter. */
const CHRONIK_BUCKETS = [
'1908-1929' => '1908-1929',
'1930-1949' => '1930-1949',
'1950-1969' => '1950-1969',
'1970-1989' => '1970-1989',
'1990-2009' => '1990-2009',
'2010-2028' => '2010-2028',
];
const CHRONIK_DOKUMENTE = 'Festschriften-und-Dokumente';
const CHRONIK_UNDATIERT = 'Undatiert';
const CHRONIK_README = '00_LIESMICH.txt';
const CHRONIK_RECHTE = '00_RECHTE-ZU-KLAEREN.txt';
/** Register liegt im REPO, nicht im Uploads-Ordner: uploads/ ist gitignored, das Register braucht Versionsgeschichte. */
function chronik_register_path(): string
{
return ROOT_PATH . '/docs/chronik/register.csv';
}
function chronik_root(): string
{
return STORAGE_PATH . '/dateien/uploads/Vereinshistorie';
}
/**
* Wayback-Adressen auf die ursprüngliche Adresse zurückführen.
*
* Das Internet Archive ist nur der Aufbewahrungsort — Urheber und Rechte richten sich nach
* der Seite, die dort eingefroren wurde. Ein Schnappschuss der eigenen alten Vereinsseite
* gehört dem Verein, nicht dem Archiv.
*/
function chronik_origin_url(string $url): string
{
if (preg_match('#^https?://web\.archive\.org/web/[^/]+/(https?://.+)$#i', $url, $m)) {
return $m[1];
}
return $url;
}
/**
* Rechtelage aus dem Host ableiten.
*
* Die Einschätzung ist eine ARBEITSHILFE, keine Rechtsauskunft — sie sagt nur, wo geklärt
* werden muss, bevor etwas gedruckt oder auf die Website gestellt wird. Sammeln und Sichten
* ist das eine, Veröffentlichen das andere; genau diese Grenze markiert das Feld.
*
* @return array{status:string, klaeren:bool, hinweis:string}
*/
function chronik_rights(string $host): array
{
$host = strtolower(preg_replace('/^www\./', '', $host));
$map = [
'infranken.de' => [
'Presse © Mediengruppe Oberfranken (inFranken/Bayerische Rundschau)',
true,
'Text UND Foto sind geschützt. Für Abdruck in der Chronik bzw. Nutzung auf der Website '
. 'Nachdruckrechte beim Verlag anfragen (Mediengruppe Oberfranken, Bamberg). Für '
. 'Vereinsjubiläen wird das üblicherweise gegen Quellenangabe eingeräumt — aber schriftlich, und früh.',
],
'bayerische-rundschau.de' => [
'Presse © Mediengruppe Oberfranken (Bayerische Rundschau)',
true,
'Wie inFranken — gleicher Verlag. Nachdruckrechte schriftlich einholen.',
],
'frankenpost.de' => [
'Presse © Mediengruppe Oberfranken (Frankenpost)',
true,
'Gleiches Haus wie Bayerische Rundschau/inFranken — eine gemeinsame Anfrage an die '
. 'Mediengruppe Oberfranken deckt alle drei Titel ab.',
],
'fraenkischertag.de' => [
'Presse © Mediengruppe Oberfranken (Fränkischer Tag)',
true,
'Vierter Titel des Hauses, dieselbe Anfrage deckt ihn mit ab. Achtung beim Lokalsport '
. 'Kulmbach: dort steht oft „Foto: privat" — das Bild kommt dann aus dem Verein oder von '
. 'einer Privatperson, die Bildrechte liegen bei ihr und nicht beim Verlag. Wer das ist, '
. 'weiß die Redaktion oder die Mannschaft; danach ist es ein Anruf statt einer Anfrage.',
],
'kicker.de' => [
'Presse © kicker / Olympia-Verlag',
true,
'Reine Stammdaten (Liga, Spielort) sind Fakten und frei; Texte und Fotos nicht.',
],
'europlan-online.de' => [
'© Europlan-Online (Groundhopping-Portal)',
true,
'Stadiondaten als Fakten frei verwendbar; die Stadionfotos gehören den einzelnen '
. 'Fotografen und sind einzeln anzufragen.',
],
'turneninkulmbach.info' => [
'Turnabteilung TSV 08 Kulmbach — eigenes Material',
false,
'Seite der eigenen Turnabteilung. Intern klären, wer sie betreut, dann sind die '
. 'Inhalte für die Chronik verfügbar.',
],
'anpfiff.info' => [
'© anpfiff.info',
true,
'Amateurfußball-Portal. Ergebnisse frei, Berichte und Fotos nicht.',
],
'bfv.de' => [
'© Bayerischer Fußball-Verband',
true,
'Reine Ergebnis- und Tabellendaten (Fakten) sind frei verwendbar; Texte, Fotos und Wappen nicht. '
. 'Wappen fremder Vereine nur mit deren Zustimmung abbilden.',
],
'fussball.de' => [
'© DFB / Bayerischer Fußball-Verband',
true,
'Ergebnisse und Tabellenstände als Fakten frei; Seitenaufbau, Texte und Fotos nicht.',
],
'deutsche-turnliga.de' => [
'© Deutsche Turnliga',
true,
'Ergebnisdaten frei, Texte/Fotos nicht.',
],
'tsv08kulmbach.de' => [
'Eigenes Material (TSV 08 Kulmbach)',
false,
'Aus dem Bestand des Vereins — nutzbar. Bei Personenfotos trotzdem das Recht am eigenen Bild beachten.',
],
// Frühere Vereinsdomain (Jimdo, ~20162023). DNS läuft nicht mehr, erreichbar nur
// noch über das Internet Archive — inhaltlich aber unser eigenes Material.
'tsv1908kulmbach.de' => [
'Eigenes Material (TSV 08 Kulmbach, frühere Vereinsseite)',
false,
'Alte Vereinsseite, nur noch im Internet Archive. Inhalte stammen vom Verein selbst. '
. 'Bei Personenfotos das Recht am eigenen Bild beachten.',
],
'commons.wikimedia.org' => [
'Wikimedia Commons — freie Lizenz, Bedingung prüfen',
true,
'Frei nutzbar, aber fast immer MIT Namensnennung und Lizenzangabe. Die konkrete Lizenz '
. '(CC BY-SA, CC0, PD) auf der Dateiseite ablesen und im Register eintragen.',
],
'de.wikipedia.org' => [
'Wikipedia — CC BY-SA',
true,
'Text nur mit Namensnennung und unter gleicher Lizenz weiterverwendbar. Für eine Chronik '
. 'besser als Faktenquelle nutzen und selbst formulieren.',
],
'facebook.com' => [
'Social Media — Urheber unklar',
true,
'Wer das Foto gemacht hat, steht dort meist nicht. Vor einer Veröffentlichung Urheber '
. 'ermitteln (oft der Verein selbst oder ein Mitglied) und Einwilligung abgeborgener Personen prüfen.',
],
'instagram.com' => [
'Social Media — Urheber unklar',
true,
'Wie Facebook. Eigene Vereinsposts sind unkritisch, fremde nicht.',
],
'digipress.digitale-sammlungen.de' => [
'Bayerische Staatsbibliothek — meist gemeinfrei',
false,
'Zeitungen bis ~1920er sind in der Regel gemeinfrei. Quellenangabe (Blatt, Datum, Seite, BSB) trotzdem mitführen.',
],
];
foreach ($map as $needle => [$status, $klaeren, $hinweis]) {
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
return ['status' => $status, 'klaeren' => $klaeren, 'hinweis' => $hinweis];
}
}
return [
'status' => 'Ungeklärt — Urheber unbekannt',
'klaeren' => true,
'hinweis' => 'Herkunft im Register nachtragen und vor jeder Veröffentlichung klären.',
];
}
/**
* Datum (YYYY, YYYY-MM oder YYYY-MM-DD) → Zielordner.
* Unbekanntes oder unplausibles Datum landet in Undatiert/ statt still im falschen Jahrzehnt.
*/
function chronik_bucket(?string $date): string
{
if ($date === null || !preg_match('/^(\d{4})/', $date, $m)) {
return CHRONIK_UNDATIERT;
}
$year = (int) $m[1];
foreach (CHRONIK_BUCKETS as $range => $dir) {
[$from, $to] = array_map('intval', explode('-', $range));
if ($year >= $from && $year <= $to) {
return $dir;
}
}
return CHRONIK_UNDATIERT;
}
/** Datum auf YYYY-MM-DD normalisieren; unbekannte Teile werden 00 (sortiert vor dem 1. des Monats). */
function chronik_date_norm(?string $date): string
{
if ($date === null || !preg_match('/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/', $date, $m)) {
return '0000-00-00';
}
return sprintf('%s-%s-%s', $m[1], $m[2] ?? '00', $m[3] ?? '00');
}
/** Titel → dateinamensicherer Slug. Umlaute ausgeschrieben, damit NFD/NFC nie zum Thema wird. */
function chronik_slug(string $text, int $maxLen = 60): string
{
$text = strtr($text, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue',
'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'á' => 'a', 'à' => 'a', 'ç' => 'c',
]);
$text = (string) preg_replace('/[^A-Za-z0-9]+/', '-', $text);
$text = trim((string) preg_replace('/-+/', '-', $text), '-');
if (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
$text = (string) preg_replace('/-[^-]*$/', '', $text) ?: $text;
}
return $text !== '' ? $text : 'ohne-titel';
}
/** Kurzkürzel der Quelle für den Dateinamen (BR, BFV, …). */
function chronik_source_tag(string $host): string
{
$host = strtolower(preg_replace('/^www\./', '', $host));
$tags = [
'infranken.de' => 'BR',
'bayerische-rundschau.de' => 'BR',
'frankenpost.de' => 'FP',
'fraenkischertag.de' => 'FT',
'kicker.de' => 'KICKER',
'europlan-online.de' => 'EUROPLAN',
'anpfiff.info' => 'ANPFIFF',
'turneninkulmbach.info' => 'TURNEN08',
'tsv1908kulmbach.de' => 'TSV08',
'bfv.de' => 'BFV',
'fussball.de' => 'FUSSBALLDE',
'deutsche-turnliga.de' => 'DTL',
'tsv08kulmbach.de' => 'TSV08',
'facebook.com' => 'FB',
'instagram.com' => 'IG',
'de.wikipedia.org' => 'WIKIPEDIA',
'commons.wikimedia.org' => 'COMMONS',
'digipress.digitale-sammlungen.de' => 'BSB',
'web.archive.org' => 'WAYBACK',
];
foreach ($tags as $needle => $tag) {
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
return $tag;
}
}
return strtoupper(chronik_slug(explode('.', $host)[0] ?? 'quelle', 12));
}
/** Registerzeile anhängen (CSV, Semikolon — öffnet sich in Excel/LibreOffice ohne Import-Dialog). */
function chronik_register_append(array $row): void
{
$path = chronik_register_path();
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$cols = ['datei', 'datum', 'titel', 'art', 'quelle', 'url', 'urheber', 'rechte', 'klaeren', 'abgerufen', 'notiz'];
$new = !is_file($path);
$fh = fopen($path, 'a');
if (!$fh) {
return;
}
flock($fh, LOCK_EX);
if ($new) {
fputcsv($fh, $cols, ';', '"', '\\');
}
fputcsv($fh, array_map(static fn(string $c): string => (string) ($row[$c] ?? ''), $cols), ';', '"', '\\');
flock($fh, LOCK_UN);
fclose($fh);
}
/** Schon im Register? Verhindert Dubletten bei wiederholten Läufen. */
function chronik_register_has_url(string $url): bool
{
$path = chronik_register_path();
if (!is_file($path)) {
return false;
}
$fh = fopen($path, 'r');
if (!$fh) {
return false;
}
$found = false;
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
if (($row[5] ?? '') === $url) {
$found = true;
break;
}
}
fclose($fh);
return $found;
}
/** Beleg-Sidecar neben dem Fundstück: alles, was der Dateiname nicht fasst. */
function chronik_sidecar_text(array $row, string $hinweis): string
{
$lines = [
'FUNDSTÜCK FÜR DIE VEREINSCHRONIK — TSV 08 Kulmbach',
str_repeat('=', 52),
'',
'Titel: ' . ($row['titel'] ?? ''),
'Datum: ' . ($row['datum'] ?? '') . ' (00 = unbekannt)',
'Art: ' . ($row['art'] ?? ''),
'Quelle: ' . ($row['quelle'] ?? ''),
'URL: ' . ($row['url'] ?? ''),
'Urheber: ' . (($row['urheber'] ?? '') !== '' ? $row['urheber'] : 'nicht angegeben'),
'Abgerufen am: ' . ($row['abgerufen'] ?? ''),
'',
'RECHTE: ' . ($row['rechte'] ?? ''),
' ' . (($row['klaeren'] ?? '') === 'ja' ? '>>> VOR VERÖFFENTLICHUNG KLÄREN <<<' : 'nutzbar'),
'',
wordwrap($hinweis, 88, "\n"),
];
if (($row['notiz'] ?? '') !== '') {
$lines[] = '';
$lines[] = 'Notiz:';
$lines[] = wordwrap((string) $row['notiz'], 88, "\n");
}
return implode("\n", $lines) . "\n";
}
/**
* 00_RECHTE-ZU-KLAEREN.txt aus dem Register neu schreiben.
* Bewusst generiert statt fortgeschrieben: die Datei soll immer den aktuellen Stand zeigen,
* auch wenn im Register etwas auf „geklärt" gesetzt wurde.
*/
function chronik_write_rights_todo(): int
{
$path = chronik_register_path();
if (!is_file($path)) {
return 0;
}
$byRights = [];
$count = 0;
$fh = fopen($path, 'r');
if (!$fh) {
return 0;
}
fgetcsv($fh, 0, ';', '"', '\\'); // Kopfzeile
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
if (($row[8] ?? '') !== 'ja') {
continue;
}
$byRights[$row[7] ?? 'Ungeklärt'][] = [
'datei' => $row[0] ?? '',
'titel' => $row[2] ?? '',
'datum' => $row[1] ?? '',
'url' => $row[5] ?? '',
// Der Verlag verlangt den Bildnachweis namentlich — wo er bekannt ist,
// gehört er in die Anfrage-Anlage, nicht nur ins Register.
'urheber' => $row[6] ?? '',
];
$count++;
}
fclose($fh);
$out = [
'RECHTE, DIE VOR DER VERÖFFENTLICHUNG ZU KLÄREN SIND',
str_repeat('=', 52),
'',
'Diese Datei wird von bin/chronik-add.php erzeugt — nicht von Hand pflegen,',
'sondern die Spalte „klaeren" in docs/chronik/register.csv auf „nein" setzen,',
'sobald eine Freigabe vorliegt, und das Skript erneut laufen lassen.',
'',
'Sammeln und intern sichten ist unproblematisch. Erst der Abdruck in der Chronik',
'oder die Veröffentlichung auf der Website braucht eine Erlaubnis.',
'',
"Offen: {$count} Fundstücke",
'',
];
foreach ($byRights as $rechte => $items) {
$out[] = str_repeat('-', 52);
$out[] = $rechte . ' (' . count($items) . ')';
$out[] = str_repeat('-', 52);
foreach ($items as $i) {
$out[] = ' · ' . $i['datum'] . ' ' . $i['titel'];
$out[] = ' ' . $i['datei'];
if ($i['urheber'] !== '') {
$out[] = ' ' . $i['urheber'];
}
if ($i['url'] !== '') {
$out[] = ' ' . $i['url'];
}
}
$out[] = '';
}
$out[] = 'Stand: ' . date('d.m.Y H:i');
file_put_contents(chronik_root() . '/' . CHRONIK_RECHTE, implode("\n", $out) . "\n");
return $count;
}
/**
* Entwurf der Nachdruck-Anfrage an den Verlag, als Datei im Ordner.
*
* Steht hier und nicht im Repo, weil der Vorstand sie braucht, nicht die Entwicklung — und
* weil sie neben den Fundstücken liegen soll, auf die sie sich bezieht. Der wichtigste Satz
* darin ist die Bitte um EINE Freigabe für alle drei Titel: Bayerische Rundschau, inFranken
* und Frankenpost gehören zur selben Mediengruppe, drei Einzelanfragen wären unnötig.
*/
function chronik_verlagsanfrage_text(): string
{
return <<<TXT
ENTWURF — ANFRAGE NACHDRUCKRECHTE
=================================
Für die Vereinschronik zum 120-jährigen Bestehen (2028) wollen wir Zeitungsberichte und
Pressefotos verwenden. Die gehören dem Verlag, nicht uns — auch die, in denen wir vorkommen.
Bitte früh abschicken. Eine Absage im Jahr 2027 wirft das Chronik-Konzept um, eine Absage
2026 kostet nichts.
Empfänger: Mediengruppe Oberfranken Zeitungsverlage GmbH & Co. KG
(deckt Bayerische Rundschau, inFranken.de UND Frankenpost gemeinsam ab)
Vor dem Versand prüfen: aktuelle Anschrift und zuständige Ansprechperson auf der
Verlagsseite nachsehen, die Liste im Anhang aus docs/chronik/register.csv erzeugen.
---------------------------------------------------------------------------
Betreff: Nachdruckrechte für die Vereinschronik zum 120-jährigen Jubiläum des
TSV 08 Kulmbach (2028)
Sehr geehrte Damen und Herren,
der TSV 1908 Kulmbach e. V. begeht im Jahr 2028 sein 120-jähriges Bestehen. Aus diesem
Anlass erarbeiten wir eine Vereinschronik.
Über die Jahrzehnte hat die Bayerische Rundschau das Vereinsleben in Kulmbach begleitet.
Ihre Berichte sind für unsere Vereinsgeschichte häufig die einzige erhaltene Quelle.
Wir möchten Sie deshalb um die Erlaubnis bitten, einzelne Artikel und Pressefotos in der
Chronik abzudrucken — jeweils mit vollständiger Quellenangabe (Titel, Datum, Autorin
oder Autor, Bildnachweis).
Konkret geht es um:
· Umfang: voraussichtlich [ANZAHL] Artikel und [ANZAHL] Fotos
(Aufstellung im Anhang)
· Verwendung: gedruckte Festschrift, Auflage ca. [ANZAHL] Exemplare,
Abgabe an Mitglieder und Gäste, nicht gewerblich
· zusätzlich: Darstellung einzelner Ausschnitte im Historie-Bereich unserer
Vereinswebsite tsv08kulmbach.de
· Zeitraum: Erscheinen der Chronik im Jubiläumsjahr 2028
Sollten für die Nutzung Gebühren anfallen, bitten wir um eine Auskunft über die Konditionen.
Als gemeinnütziger Verein sind wir für ein Entgegenkommen dankbar.
Für Rückfragen stehen wir gerne zur Verfügung.
Mit freundlichen Grüßen
[NAME], [FUNKTION]
TSV 1908 Kulmbach e. V.
[ANSCHRIFT / TELEFON / E-MAIL]
Anlage: Aufstellung der gewünschten Artikel und Fotos
---------------------------------------------------------------------------
NOCH ZU KLÄREN, UNABHÄNGIG VOM VERLAG
-------------------------------------
· Private Fotos von Mitgliedern: Einverständnis der abgebildeten Personen einholen.
Bei Kindern die Eltern fragen. Am einfachsten gleich beim Einsammeln miterledigen.
· Wappen anderer Vereine: nur mit deren Zustimmung abbilden.
· Fotos ohne bekannten Urheber: Herkunft ermitteln, solange es noch jemand weiß.
In zwei Jahren weiß es niemand mehr.
TXT;
}
function chronik_readme_text(): string
{
return <<<TXT
VEREINSHISTORIE — MATERIALSAMMLUNG FÜR DAS JUBILÄUM 2028
========================================================
Hier sammeln wir alles, was in die Vereinschronik zum 120-jährigen Bestehen
einfließen kann: Fotos, Zeitungsausschnitte, Urkunden, Festschriften, Programmhefte,
Mannschaftsbilder, Wimpel, Protokolle.
Lieber zu viel als zu wenig. Aussortiert wird später.
WO KOMMT ES HIN?
----------------
In den Ordner des Jahrzehnts, aus dem es stammt:
1908-1929 1930-1949 1950-1969 1970-1989 1990-2009 2010-2028
Weißt du das Jahr nicht? -> Undatiert/ (wir ordnen es später zu)
Festschrift, Satzung, Urkunde, Protokoll? -> Festschriften-und-Dokumente/
WIE SOLL ES HEISSEN?
--------------------
Datum zuerst, dann worum es geht:
1972-07-15_Einweihung-Sportheim.jpg
1985-00-00_Mannschaftsfoto-Erste.jpg (Monat/Tag unbekannt -> 00)
1962-00-00_Aufstieg-Bericht_BR.jpg (Quelle hinten, hier: Bayerische Rundschau)
Das Datum vorn sorgt dafür, dass die Liste sich von selbst richtig sortiert.
Wenn du den Namen nicht hinbekommst: trotzdem hochladen. Ein falsch benanntes
Foto ist unendlich viel besser als ein Foto, das im Schuhkarton bleibt.
WAS UNS BEI JEDEM STÜCK HILFT
-----------------------------
Wenn du es weißt, schreib es dazu (in den Dateinamen oder als kurze Textdatei daneben):
· Wann ungefähr?
· Wer ist zu sehen?
· Wo aufgenommen?
· Wer hat fotografiert / wo stand der Artikel?
Der letzte Punkt ist der wichtigste und der, der am häufigsten fehlt. Ohne ihn
dürfen wir das Bild später womöglich nicht drucken.
RECHTE
------
Zeitungsartikel und Pressefotos gehören dem Verlag, nicht uns — auch wenn wir
darin vorkommen. Was noch freigegeben werden muss, steht in
00_RECHTE-ZU-KLAEREN.txt
Private Aufnahmen von Vereinsmitgliedern sind unkritisch, solange die abgebildeten
Personen mit einer Veröffentlichung einverstanden sind.
Fragen? Melde dich bei der Vorstandschaft.
TXT;
}

83
bin/dateien-init.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* Ordnerstruktur der Dateiablage /dateien anlegen: Jahr → Abteilung.
*
* 2026/
* Fußball/
* Jugend/
* Turnen/
* Feste und Aktionen/
*
* Warum diese Reihenfolge: das Jahr außen hält die oberste Ebene für immer kurz
* (ein Eintrag pro Jahr) und macht die Grenze zum Archiv sichtbar. Die Abteilungen
* innen sind bewusst dieselben wie die Bereiche der Website — dann ist beim Hochladen
* schon klar, auf welche Seite das Material später gehört. Wer ein Ereignis bündeln
* will, legt darin einen Ordner mit Datum voran an („2026-08-15 Sommerfest"); der
* sortiert sich dann von selbst richtig ein.
*
* Tiefer als diese zwei Ebenen wird nichts vorgegeben: die hochladenden Personen
* stehen meist mit dem Handy am Spielfeldrand, und jede zusätzliche Ebene wird
* ignoriert oder falsch getroffen.
*
* Idempotent — vorhandene Ordner bleiben unberührt. Im Januar für das neue Jahr
* einmal neu aufrufen (oder das Vereins-Konto legt den Jahresordner selbst an).
*
* NUR CLI. Die Ordner müssen auf dem Server entstehen, nicht per FTP hochgeladen
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC), sonst
* steht „Fußball" am Ende zweimal da.
*
* Aufruf: php bin/dateien-init.php [Jahr] (ohne Argument: laufendes Jahr)
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const DATEIEN_ABTEILUNGEN = ['Fußball', 'Jugend', 'Turnen', 'Feste und Aktionen'];
// Europe/Berlin, weil PHP global auf UTC läuft — sonst kippt das Jahr am 31.12. zu früh.
$year = (string) ($argv[1] ?? (new DateTimeImmutable('now', new DateTimeZone('Europe/Berlin')))->format('Y'));
if (!preg_match('/^20\d{2}$/', $year)) {
fwrite(STDERR, "Ungültiges Jahr: {$year}\nAufruf: php bin/dateien-init.php [Jahr]\n");
exit(1);
}
$root = STORAGE_PATH . '/dateien/uploads';
if (!is_dir($root)) {
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
exit(1);
}
if (!is_writable($root)) {
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
exit(1);
}
$paths = [$year];
foreach (DATEIEN_ABTEILUNGEN as $abteilung) {
$paths[] = $year . '/' . $abteilung;
}
$created = 0;
$failed = 0;
foreach ($paths as $rel) {
$dir = $root . '/' . $rel;
if (is_dir($dir)) {
echo "· {$rel} (gibt es schon)\n";
continue;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "{$rel} konnte nicht angelegt werden\n");
$failed++;
continue;
}
echo "{$rel}\n";
$created++;
}
echo "\n{$created} Ordner angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
exit($failed > 0 ? 1 : 0);

273
bin/filesgallery-update.php Normal file
View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* Files Gallery (Dateiablage /dateien) aktualisieren — App-Datei und self-hosted Assets.
*
* Warum ein Skript: die App lädt ihre Assets sonst vom jsDelivr-CDN (index.php:965), was
* gegen Regel 1 verstößt. Self-Hosting ist offiziell vorgesehen, muss aber bei JEDEM
* Versionswechsel wiederholt werden, weil die Asset-Pfade die Version enthalten
* (files.photo.gallery@<version>/...). Dazu kommt der TSV-PATCH in index.php, den ein
* Update sonst wegwirft. Beides von Hand ist genau der Schritt, der einmal vergessen wird.
*
* NUR CLI/Build — nie im Request-Pfad. Die einzige ausgehende Verbindung ist jsDelivr,
* und die passiert hier, nicht im Browser des Besuchers.
*
* Aufruf:
* php bin/filesgallery-update.php --check nur prüfen (Patch da? Assets zur Version da?)
* php bin/filesgallery-update.php --assets Assets zur vendorten Version neu holen
* php bin/filesgallery-update.php neue index.php holen, patchen, Assets holen
*
* Nach einem echten Versionssprung: Login, Upload und Vorschau einmal durchklicken und
* die alten Asset-Verzeichnisse löschen (das Skript sagt, welche das sind).
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const FG_APP = PUBLIC_PATH . '/dateien/index.php';
const FG_ASSETS_DIR = PUBLIC_PATH . '/assets/filesgallery';
const FG_APP_URL = 'https://cdn.jsdelivr.net/npm/files.photo.gallery/index.php';
const FG_LOG = STORAGE_PATH . '/logs/filesgallery.log';
// Der Patch, ohne den die App bei abweichendem Arbeitsverzeichnis ohne Login startet.
const FG_PATCH_NEEDLE = "public static \$localconfigpath = '_filesconfig.php';";
const FG_PATCH_MARKER = "public static \$localconfigpath = __DIR__ . '/_filesconfig.php';";
const FG_PATCH_REPLACEMENT = <<<'PHP'
// TSV-PATCH (einzige Änderung an dieser Datei): absolut statt relativ.
// Der Original-Pfad wird gegen das aktuelle Arbeitsverzeichnis aufgelöst. Weicht das ab
// (z. B. unter `php -S` mit Router-Script), findet die App _filesconfig.php nicht und
// startet mit ihrer Default-Config — also OHNE Login und mit falschen Pfaden.
// Nebeneffekt: der Selbst-Ausschluss in Path::is_exclude() vergleicht diesen Wert mit
// einem Basename und greift dadurch nicht mehr. Belanglos, weil unser `root`
// (storage/dateien/uploads) außerhalb dieses Verzeichnisses liegt.
// Nach jedem Update neu setzen: `php bin/filesgallery-update.php` erledigt und prüft das.
public static $localconfigpath = __DIR__ . '/_filesconfig.php';
PHP;
$mode = $argv[1] ?? '';
if (!in_array($mode, ['', '--check', '--assets'], true)) {
fwrite(STDERR, "Unbekannte Option: {$mode}\nAufruf: php bin/filesgallery-update.php [--check|--assets]\n");
exit(1);
}
$errors = 0;
function fg_log(string $line): void
{
@file_put_contents(FG_LOG, date('c') . ' ' . $line . "\n", FILE_APPEND);
}
function fg_fail(string $message): void
{
global $errors;
$errors++;
fwrite(STDERR, "{$message}\n");
fg_log('FEHLER ' . $message);
}
/** Datei von jsDelivr holen; gibt den Rohinhalt zurück oder null. */
function fg_fetch(string $url): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120, // die Assets sind ein paar MB
CURLOPT_USERAGENT => 'tsv08kulmbach-filesgallery-update/1.0',
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
if (!is_string($body) || $body === '' || $status !== 200) {
fg_fail("Download fehlgeschlagen ({$status}" . ($error !== '' ? ", {$error}" : '') . "): {$url}");
return null;
}
return $body;
}
/** Version aus dem Quelltext der App lesen. */
function fg_version(string $source): ?string
{
return preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : null;
}
/**
* Den TSV-PATCH anwenden. Idempotent: ist er schon drin, bleibt die Datei unverändert.
* Fehlt beides (Patch und Original-Zeile), bricht das Skript ab — dann hat die App ihre
* Struktur geändert und der Patch muss von Hand nachgezogen werden.
*/
function fg_patch(string $source): ?string
{
if (str_contains($source, FG_PATCH_MARKER)) {
return $source;
}
if (!str_contains($source, FG_PATCH_NEEDLE)) {
fg_fail('Patch-Stelle nicht gefunden ($localconfigpath) — index.php von Hand prüfen, NICHT ungepatcht ausliefern.');
return null;
}
// Die Original-Zeile trägt einen Kommentar hinter dem Semikolon; den behalten wir.
return (string) preg_replace(
'/^\h*' . preg_quote(FG_PATCH_NEEDLE, '/') . '/m',
FG_PATCH_REPLACEMENT,
$source,
1
);
}
/** assets.zip zur Version holen und nach public/assets/filesgallery/ entpacken. */
function fg_assets(string $version): void
{
$url = "https://cdn.jsdelivr.net/npm/files.photo.gallery@{$version}/_files/assets/assets.zip";
$zipData = fg_fetch($url);
if ($zipData === null) {
return;
}
$tmp = tempnam(sys_get_temp_dir(), 'fg-assets-');
if ($tmp === false || file_put_contents($tmp, $zipData) === false) {
fg_fail('Konnte assets.zip nicht zwischenspeichern.');
return;
}
$zip = new ZipArchive();
if ($zip->open($tmp) !== true) {
fg_fail('assets.zip ist kein lesbares ZIP-Archiv.');
@unlink($tmp);
return;
}
if (!is_dir(FG_ASSETS_DIR) && !mkdir(FG_ASSETS_DIR, 0775, true) && !is_dir(FG_ASSETS_DIR)) {
fg_fail('Kann ' . FG_ASSETS_DIR . ' nicht anlegen.');
$zip->close();
@unlink($tmp);
return;
}
$written = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
// Manche Archive tragen den _files/assets-Pfad noch mit sich; wir wollen den Inhalt flach.
$rel = (string) preg_replace('#^(\./)?(_files/)?assets/#', '', $name);
$rel = ltrim(str_replace('\\', '/', $rel), '/');
// Zip-Slip: alles mit Parent-Traversal oder absolutem Pfad wird verworfen.
if ($rel === '' || str_contains($rel, '../')) {
continue;
}
$target = FG_ASSETS_DIR . '/' . $rel;
if (str_ends_with($name, '/')) {
if (!is_dir($target)) {
@mkdir($target, 0775, true);
}
continue;
}
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fg_fail("Kann Verzeichnis nicht anlegen: {$dir}");
continue;
}
$stream = $zip->getStream($name);
if ($stream === false) {
fg_fail("Konnte Eintrag nicht lesen: {$name}");
continue;
}
if (@file_put_contents($target, $stream) === false) {
fg_fail("Konnte Datei nicht schreiben: {$target}");
} else {
$written++;
}
fclose($stream);
}
$zip->close();
@unlink($tmp);
echo "✓ Assets entpackt ({$written} Dateien) → " . str_replace(ROOT_PATH . '/', '', FG_ASSETS_DIR) . "\n";
fg_log("Assets {$version} entpackt ({$written} Dateien)");
}
/** Prüfen, ob Patch und Assets zur vendorten Version passen. */
function fg_check(string $version, string $source): void
{
if (str_contains($source, FG_PATCH_MARKER)) {
echo "✓ TSV-PATCH in public/dateien/index.php vorhanden\n";
} else {
fg_fail('TSV-PATCH fehlt in public/dateien/index.php — die App würde ohne Login starten.');
}
$css = FG_ASSETS_DIR . "/files.photo.gallery@{$version}/css/files.css";
if (is_file($css)) {
echo "✓ Assets für Version {$version} vorhanden\n";
} else {
fg_fail("Assets für Version {$version} fehlen (erwartet: " . str_replace(ROOT_PATH . '/', '', $css) . ") — php bin/filesgallery-update.php --assets");
}
// Verirrte Assets anderer Versionen melden, damit sie nicht mitgeschleppt werden.
foreach (glob(FG_ASSETS_DIR . '/files.photo.gallery@*') ?: [] as $dir) {
if (basename($dir) !== "files.photo.gallery@{$version}") {
echo "· Alte Assets, können weg: " . str_replace(ROOT_PATH . '/', '', $dir) . "\n";
}
}
}
// --- Ablauf ---------------------------------------------------------------------------
if (!is_file(FG_APP)) {
fwrite(STDERR, '✗ ' . FG_APP . " fehlt.\n");
exit(1);
}
$source = (string) file_get_contents(FG_APP);
if ($mode === '' ) {
// Vollupdate: neue App-Datei holen, patchen, dann Assets zur neuen Version.
$fresh = fg_fetch(FG_APP_URL);
if ($fresh !== null) {
$newVersion = fg_version($fresh);
if ($newVersion === null) {
fg_fail('Version im Download nicht gefunden — Datei nicht übernommen.');
} else {
$patched = fg_patch($fresh);
if ($patched !== null) {
$old = fg_version($source) ?? '?';
if ($patched === $source) {
echo "· Version {$newVersion} ist schon aktuell und gepatcht\n";
} elseif (file_put_contents(FG_APP, $patched) === false) {
fg_fail('Konnte ' . FG_APP . ' nicht schreiben.');
} else {
echo "✓ index.php aktualisiert: {$old}{$newVersion} (gepatcht)\n";
fg_log("index.php {$old}{$newVersion}");
$source = $patched;
}
}
}
}
}
$version = fg_version($source);
if ($version === null) {
fwrite(STDERR, "✗ Version in public/dateien/index.php nicht lesbar.\n");
exit(1);
}
if ($mode !== '--check') {
fg_assets($version);
}
fg_check($version, $source);
if ($errors > 0) {
fwrite(STDERR, "\n{$errors} Problem(e) — nicht ausliefern, bis sie behoben sind.\n");
exit(1);
}
echo "\nFiles Gallery {$version}: alles an seinem Platz.\n";
exit(0);

View File

@@ -16,6 +16,8 @@ if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_slice($argv, 1);
$crop = false;
if (($args[0] ?? '') === '--crop') {
@@ -31,21 +33,21 @@ if (count($args) < 3) {
[$src, $dest, $spec] = $args;
$quality = (int) ($args[3] ?? 78);
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
if ($crop) {
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
'png' => imagecreatefrompng($src),
'webp' => imagecreatefromwebp($src),
default => null,
};
if (!$image) {
};
if (!$image) {
fwrite(STDERR, "Kann Quelle nicht lesen: {$src}\n");
exit(1);
}
}
$srcW = imagesx($image);
$srcH = imagesy($image);
$srcW = imagesx($image);
$srcH = imagesy($image);
if ($crop) {
[$w, $h] = array_map('intval', explode('x', $spec));
$srcRatio = $srcW / $srcH;
$dstRatio = $w / $h;
@@ -65,12 +67,13 @@ if ($crop) {
exit(0);
}
foreach (explode(',', $spec) as $width) {
$w = min((int) $width, $srcW); // nie hochskalieren
$h = (int) round($srcH * $w / $srcW);
$out = imagecreatetruecolor($w, $h);
imagecopyresampled($out, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
$file = "{$dest}-{$w}.jpg";
imagejpeg($out, $file, $quality);
echo "{$file} ({$w}x{$h}, " . round(filesize($file) / 1024) . " KB)\n";
try {
$variants = resize_image_variants($src, $dest, array_map('intval', explode(',', $spec)), $quality);
} catch (RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
foreach ($variants as $variant) {
echo "{$variant['file']} ({$variant['width']}x{$variant['height']}, " . round($variant['bytes'] / 1024) . " KB)\n";
}

View File

@@ -16,35 +16,42 @@ declare(strict_types=1);
* 2. web_profile_info-API (JSON, edges[])
* 3. Profil-HTML nach eingebettetem JSON parsen
*/
if (PHP_SAPI !== 'cli') {
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
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 und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv, true);
$verbose = in_array('-v', $argv ?? [], true);
$username = (string) config('instagram.username', 'tsv08kulmbach');
$maxPosts = (int) config('instagram.max_posts', 9);
$imgDir = PUBLIC_PATH . '/assets/img/instagram';
$cacheFile = DATA_PATH . '/instagram.json';
$log = static function (string $msg) use ($verbose): void {
$line = '[' . date('c') . "] {$msg}\n";
file_put_contents(STORAGE_PATH . '/logs/instagram.log', $line, FILE_APPEND);
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/instagram.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('instagram', $level, $msg);
if ($verbose) {
echo $line;
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$fail('Läuft bereits (Lock belegt)');
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- HTTP-Helfer mit Browser-typischen Headern ---
@@ -203,12 +210,12 @@ foreach ($posts as $post) {
if (!is_file($file)) {
$img = $fetch($post['display_url']);
if ($img === false) {
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen");
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$src = imagecreatefromstring($img);
if ($src === false) {
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen");
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$w = imagesx($src);

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