Der Satz „Weil uploads/ gitignored ist" stimmte nicht mehr. Ersetzt durch die Regel, die den Ordner im Repo tragfähig macht (Server pusht zuerst) und den dauerhaften Preis (Historie schrumpft nie). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
986 lines
80 KiB
Markdown
986 lines
80 KiB
Markdown
# TSV 08 Kulmbach — Website
|
||
|
||
Neubau von tsv08kulmbach.de. Vereinsseite (Fußball + Turnen, gegründet 1908) als sauberer,
|
||
selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
|
||
|
||
## Stack & Umgebung
|
||
|
||
- **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 -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 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 — 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).
|
||
Niemals hardcoden, loggen oder ausgeben. `config/config.example.php` dokumentiert alle Keys.
|
||
|
||
## Single Source of Truth (vor JEDER Änderung prüfen)
|
||
|
||
| Was | Einzige Quelle | Niemals |
|
||
|---|---|---|
|
||
| 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, 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` |
|
||
| Ansprechpartner/Personen (geteilte Quelle) | `data/ansprechpartner.json` (per Abteilung auf Team-/Jugend-/Verein-Seiten gezogen) | Namen/Funktionen/Kontakte in Templates duplizieren |
|
||
| Banner-Slider (Startseite) | `data/banners.json` | Slides in `pages/home.php` oder der Komponente |
|
||
| 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.
|
||
|
||
**Strukturierte Daten (JSON-LD):** Seiten setzen `$meta['schema']` = Array von schema.org-Knoten;
|
||
`app/components/jsonld.php` hängt sie an den globalen `@graph` (Organization `#club`) an — nie ein
|
||
`<script type="application/ld+json">` von Hand ins Markup. Knoten **nur** über die Helper in
|
||
`helpers.php` bauen: `breadcrumb_schema()`, `faq_schema()`, `page_schema()` (Breadcrumb + optional FAQ),
|
||
`team_schema()` (SportsTeam + Breadcrumb + FAQ, verweist via `#club` auf den Club),
|
||
`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. **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), **`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; 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,
|
||
`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, 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 — 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).
|
||
- **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) `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)
|
||
|
||
- Dunkles sportliches Theme: BG `#222` mit Textur, weiße Schrift, **Akzent-Rot `#e20612`**
|
||
(das ist der echte Markenwert aus der alten DB — nicht #ff6532).
|
||
- 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. `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.
|
||
- Ausgabe **nur** über den Helper: `icon('pause-fill')` (dekorativ, `aria-hidden`), `icon('pause-fill', 'meine-klasse')` (mit Klasse), `icon('envelope', '', 'E-Mail')` (semantisch, `role="img"` + `aria-label`). Inline-SVG, currentColor erbt die Schriftfarbe.
|
||
- Größe = `font-size` des Elements (Icon ist `1em`), Farbe = `currentColor`. Basisklasse `.icon` in `utilities.css`.
|
||
- Nur tatsächlich genutzte Icons werden abgelegt (kein Komplett-Set). Neues Icon: `php bin/icons-add.php <name>` (Name = Dateiname auf icons.getbootstrap.com ohne `.svg`).
|
||
|
||
## Checkliste für jede neue Seite/Komponente
|
||
|
||
- [ ] Semantisches HTML (Landmarks, Headings-Hierarchie ohne Sprünge)
|
||
- [ ] 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).
|
||
Responsive `widths`-Varianten mit `php bin/img-resize.php` erzeugen (nicht von Hand skalieren)
|
||
- [ ] 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
|
||
`data/instagram.json` atomar und lädt Bilder nach `public/assets/img/instagram/`. Bei Fehlern bleibt
|
||
der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente
|
||
eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.
|
||
|
||
## Matchcenter (BFV-Widget-Ersatz)
|
||
|
||
`bin/matchcenter-sync.php` (nur CLI) holt Spielplan, Ergebnisse und Tabelle der drei Aktivenmannschaften
|
||
aus der öffentlichen **BFV-Widget-JSON-API** (`widget-prod.bfv.de`, reverse-engineert aus dem alten
|
||
`<BFVWidget.HTML5…>`-Embed — kein HTML-Scraping), lädt die Vereinswappen lokal nach
|
||
`public/assets/img/crests/` und schreibt `data/matchcenter.json` atomar. Endpunkte: `…/api/service/widget/v1/team/{teamId}/matches`,
|
||
`…/api/service/widget/v1/competition/{compoundId}/table`, Wappen `app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId}`.
|
||
Browser-User-Agent + `Referer` nötig (sonst HTTP 418). Eigene-Verein-Erkennung exakt über `permanentId`.
|
||
Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert
|
||
`matchcenter-empty`). Cron 2×/Tag + am Wochenende ½-stündlich nachmittags (siehe `config.example.php`).
|
||
Das „Nächste Spiele"-Band (`next-matches`) zeigt links den Aufmacher (`match-feature`: das zeitlich
|
||
nächste Spiel aller Mannschaften) und rechts die folgenden Termine als verlinkte `match-row`-Zeilen
|
||
(`match-list--plain`, ohne Kartenfläche). **Ein Bauprinzip fürs ganze Band:** der Aufmacher ist die
|
||
große Variante der Terminzeile — Heim über Gast, linksbündig, dieselbe Struktur, nur größer.
|
||
Genau **drei Schriftrollen**, nicht mehr: Meta-Zeile „Mannschaft · Liga · Heim/Auswärts" (Abel,
|
||
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). **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`.
|
||
Das Skript läuft **auf dem Server** — Ordner per FTP hochzuladen erzeugt bei Umlauten Doppelgänger
|
||
(macOS NFD vs. Linux NFC).
|
||
|
||
**`uploads/` ist versioniert** (Beschluss 31.07.2026, vorher gitignored): das Material des Vereins ist
|
||
damit gesichert und beim Entwickeln lokal da, und ein frischer Klon ist wirklich vollständig. Der
|
||
Ordner ist aber das einzige Verzeichnis im Repo, in das **andere Menschen** schreiben — daraus folgt
|
||
die eine Regel, die das Ganze zusammenhält:
|
||
|
||
> **Erst auf dem Server committen und pushen, dann lokal pullen, dann entwickeln.** Nie umgekehrt.
|
||
|
||
Hält man sie ein, kann kein Deploy hochgeladene Fotos überschreiben (ein `pull` fasst neue Dateien
|
||
auf dem Server ohnehin nicht an, aber ein `reset`/`checkout` schon). Zweiter Preis, dauerhaft: **Git
|
||
vergisst nichts** — eine gelöschte Datei bleibt in der Historie, das Repo schrumpft nie wieder.
|
||
Deshalb Videos sparsam: zwei Sportfest-Clips machten allein 85 der ersten 174 MB, und für die
|
||
Website wären sie ohnehin neu zu kodieren. Wird es unbequem, ist der Schnitt `*.mp4` auszunehmen und
|
||
Videos separat zu sichern; die Fotos und die `.quelle.txt`-Belege der Chronik sind der wertvolle Teil.
|
||
|
||
**Genau ein Patch in der Fremd-Datei** (`index.php:122`, `$localconfigpath` absolut statt relativ) —
|
||
ohne ihn startet die App bei abweichendem Arbeitsverzeichnis ohne Login. **Update ausschließlich über
|
||
`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.
|