Compare commits
10 Commits
17acfe7081
...
2f8619e78d
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f8619e78d | |||
| 93b67bc9d3 | |||
| a101b0c3ec | |||
| 3fb480dd78 | |||
| 82cf21085d | |||
| eacf9449e0 | |||
| 3fa521eaf1 | |||
| 87055bd14b | |||
| 8304095dc3 | |||
| 96a698ebdc |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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
863
CLAUDE.md
@@ -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 Bindestriche 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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
app/components/detail-head.php
Normal file
64
app/components/detail-head.php
Normal 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>
|
||||
53
app/components/event-feature.php
Normal file
53
app/components/event-feature.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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' ?>"
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
app/components/hero-upcoming.php
Normal file
74
app/components/hero-upcoming.php
Normal 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>
|
||||
@@ -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,26 @@ $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; ?>
|
||||
<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; ?>
|
||||
</div>
|
||||
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
|
||||
<?php else: ?>
|
||||
<?php if (!empty($hero['kicker'])): ?>
|
||||
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
|
||||
<?php endif; ?>
|
||||
@@ -41,6 +69,7 @@ $display = !empty($hero['display']);
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($display): ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
41
app/components/lightbox-overlay.php
Normal file
41
app/components/lightbox-overlay.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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): ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
app/components/news-artikel.php
Normal file
84
app/components/news-artikel.php
Normal 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>
|
||||
146
app/components/news-feed.php
Normal file
146
app/components/news-feed.php
Normal 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; ?>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,19 +43,50 @@ if (!empty($names)) {
|
||||
if ($persons === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
|
||||
if (!empty($embedded)): ?>
|
||||
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<ul class="persons__grid">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person]); ?>
|
||||
</li>
|
||||
<?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; ?>
|
||||
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>">
|
||||
<?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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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): ?>
|
||||
|
||||
47
app/components/stadionzeitung-cards.php
Normal file
47
app/components/stadionzeitung-cards.php
Normal 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>
|
||||
130
app/components/stadionzeitung-cover.php
Normal file
130
app/components/stadionzeitung-cover.php
Normal 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>
|
||||
98
app/components/stadionzeitung-druck.php
Normal file
98
app/components/stadionzeitung-druck.php
Normal 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>
|
||||
72
app/components/stadionzeitung-ergebnisse.php
Normal file
72
app/components/stadionzeitung-ergebnisse.php
Normal 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>
|
||||
58
app/components/stadionzeitung-historie.php
Normal file
58
app/components/stadionzeitung-historie.php
Normal 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>
|
||||
59
app/components/stadionzeitung-impressum.php
Normal file
59
app/components/stadionzeitung-impressum.php
Normal 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>
|
||||
69
app/components/stadionzeitung-interview.php
Normal file
69
app/components/stadionzeitung-interview.php
Normal 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>
|
||||
69
app/components/stadionzeitung-kontakt.php
Normal file
69
app/components/stadionzeitung-kontakt.php
Normal 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>
|
||||
43
app/components/stadionzeitung-mitglied.php
Normal file
43
app/components/stadionzeitung-mitglied.php
Normal 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>
|
||||
52
app/components/stadionzeitung-momente.php
Normal file
52
app/components/stadionzeitung-momente.php
Normal 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>
|
||||
90
app/components/stadionzeitung-news.php
Normal file
90
app/components/stadionzeitung-news.php
Normal 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>
|
||||
63
app/components/stadionzeitung-partner.php
Normal file
63
app/components/stadionzeitung-partner.php
Normal 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>
|
||||
91
app/components/stadionzeitung-seite.php
Normal file
91
app/components/stadionzeitung-seite.php
Normal 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; ?>
|
||||
41
app/components/stadionzeitung-sportheim.php
Normal file
41
app/components/stadionzeitung-sportheim.php
Normal 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>
|
||||
31
app/components/stadionzeitung-tabelle.php
Normal file
31
app/components/stadionzeitung-tabelle.php
Normal 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>
|
||||
90
app/components/stadionzeitung-termine.php
Normal file
90
app/components/stadionzeitung-termine.php
Normal 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>
|
||||
77
app/components/stadionzeitung-viewer.php
Normal file
77
app/components/stadionzeitung-viewer.php
Normal 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>
|
||||
50
app/components/stadionzeitung-vorschau.php
Normal file
50
app/components/stadionzeitung-vorschau.php
Normal 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>
|
||||
70
app/components/stadionzeitung-vorwort.php
Normal file
70
app/components/stadionzeitung-vorwort.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
44
app/components/termine-list.php
Normal file
44
app/components/termine-list.php
Normal 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; ?>
|
||||
@@ -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; ?>
|
||||
|
||||
52
app/components/veranstaltung-galerie.php
Normal file
52
app/components/veranstaltung-galerie.php
Normal 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>
|
||||
93
app/components/veranstaltung-info.php
Normal file
93
app/components/veranstaltung-info.php
Normal 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>
|
||||
55
app/components/veranstaltung-plakate.php
Normal file
55
app/components/veranstaltung-plakate.php
Normal 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>
|
||||
66
app/components/veranstaltung-programm.php
Normal file
66
app/components/veranstaltung-programm.php
Normal 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>
|
||||
68
app/components/veranstaltungen-cards.php
Normal file
68
app/components/veranstaltungen-cards.php
Normal 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; ?>
|
||||
1234
app/helpers.php
1234
app/helpers.php
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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 & Kontaktaufnahme</h2>
|
||||
<h2>Formulare & 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. 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. 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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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']]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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' => '',
|
||||
],
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'])): ?>
|
||||
|
||||
82
app/pages/news-artikel.php
Normal file
82
app/pages/news-artikel.php
Normal 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
56
app/pages/news.php
Normal 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>
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
140
app/pages/stadionzeitung-ausgabe.php
Normal file
140
app/pages/stadionzeitung-ausgabe.php
Normal 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]); ?>
|
||||
41
app/pages/stadionzeitung.php
Normal file
41
app/pages/stadionzeitung.php
Normal 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]);
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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
62
app/pages/termine.php
Normal 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>
|
||||
@@ -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' => ''],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
157
app/pages/veranstaltung.php
Normal 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; ?>
|
||||
77
app/pages/veranstaltungen.php
Normal file
77
app/pages/veranstaltungen.php
Normal 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>
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
181
bin/anzeige-add.php
Normal 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
491
bin/chronik-add.php
Normal 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
94
bin/chronik-init.php
Normal 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
564
bin/chronik-lib.php
Normal 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, ~2016–2023). 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
83
bin/dateien-init.php
Normal 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
273
bin/filesgallery-update.php
Normal 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);
|
||||
@@ -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))) {
|
||||
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
||||
'png' => imagecreatefrompng($src),
|
||||
'webp' => imagecreatefromwebp($src),
|
||||
default => null,
|
||||
};
|
||||
if (!$image) {
|
||||
fwrite(STDERR, "Kann Quelle nicht lesen: {$src}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$srcW = imagesx($image);
|
||||
$srcH = imagesy($image);
|
||||
|
||||
if ($crop) {
|
||||
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
|
||||
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
||||
'png' => imagecreatefrompng($src),
|
||||
'webp' => imagecreatefromwebp($src),
|
||||
default => null,
|
||||
};
|
||||
if (!$image) {
|
||||
fwrite(STDERR, "Kann Quelle nicht lesen: {$src}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$srcW = imagesx($image);
|
||||
$srcH = imagesy($image);
|
||||
|
||||
[$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";
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user