1414 lines
117 KiB
Markdown
1414 lines
117 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:** All-Inkl Shared Hosting, PHP 8.x, `.htaccess`, SSH (seit 31.07.2026) und
|
||
Cronjobs, die aber **nur URLs aufrufen können** — deshalb läuft der Takt über
|
||
`public/cron.php`, siehe dort. Deploy-Anleitung Schritt für Schritt: **`docs/deploy.md`**.
|
||
- **Lokales Dev:** `php -d upload_max_filesize=250M -d post_max_size=260M -S localhost:8000 -t public public/index.php`
|
||
(index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt). Die beiden `-d`-Schalter
|
||
brauchst du für die Dateiablage `/dateien`: der CLI-Server liest keine `.user.ini`, und mit den
|
||
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/`
|
||
(Matchcenter-Sync, Instagram-Aufnahme, 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-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen |
|
||
| 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`, `interview`, `aufruf`, `rueckblick` (Text/Bildnachweis; `image` bleibt script-verwaltet), `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`), `cover` (`teaser` einer Sonderausgabe), `opponent_short` (gedruckter Anzeigename des Gegners) und `gegner_teams` (weitere Mannschaften mit Gegnerseite).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt |
|
||
| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen |
|
||
| Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) |
|
||
| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) |
|
||
| News (`/news` + optionale Detailseiten) | `data/news.json` (redaktionelle Meldungen; `artikel[]` = Fließtext-Blöcke, `status` = Entwurf/veröffentlicht) — Instagram-Posts und Ergebnisse kommen weiterhin nur aus `data/instagram.json`/`data/matchcenter.json`, werden auf der News-Seite nur gemergt | Von Claude im Chat gepflegt wie `events.json`/`teams.json` — **keine** Automatisierung, kein Bin-Skript. Instagram/Ergebnisse nie hier duplizieren. `text` bleibt der Teaser (Feed, Meta-Description, Article-Schema), der Artikel steht in `artikel[]` |
|
||
| Dateiablage `/dateien` (Konfiguration) | `public/dateien/_filesconfig.php` (Rechte pro Konto: `storage/dateien/system/users/admin/config.php`) | **`storage/dateien/system/config/config.php` von Hand editieren** — die App generiert sie selbst und überschreibt sie bei jedem Update. Passwörter/Lizenz gehören in `config.php` (`dateien.*`) |
|
||
| Logo-Downloads (`/logo`) | Texte: `data/logo.json` · Master: `storage/logo-quellen/*.svg` | Die Dateien unter `public/assets/downloads/logo/` sind **maschinenverwaltet von `bin/logo-build.php`** — nie von Hand skalieren, nie von Hand umfärben, nie einzeln nachlegen. Auch keine Dateiliste in die JSON schreiben: welche Formate es gibt, entscheidet das Skript, Größen liest die Seite von der Platte |
|
||
| 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.
|
||
Ein FAQ-Eintrag darf zusätzlich `ctas[]` tragen (Knöpfe unter der Antwort, gerendert über die
|
||
`cta`-Komponente) — für Antworten, an deren Ende ein Handgriff steht, etwa „so meldest du dich an".
|
||
**Die Antwort muss den Weg trotzdem in Worten nennen:** `faq_schema()` liest nur `q` und `a`, bei
|
||
Google steht sonst eine Antwort ohne Ziel. Und **keine zweite Sektion mit denselben Knöpfen daneben**
|
||
— ein `cta-band` unter den Beiträgen von `/turnen` war gebaut und wurde am 31.07.2026 wieder
|
||
entfernt (Entscheidung Felix): dieselbe Sache zweimal auf einer Seite schwächt beide Stellen, und
|
||
man weiß nicht mehr, welcher der richtige Weg ist.
|
||
Redaktioneller Text aus `data/*.json` darf interne Deeplinks als `[Label](slug)` tragen (auch
|
||
`[Label](#anker)` bzw. `slug#anker`) — `inline_links_html()` rendert sie als `<a>`,
|
||
`inline_links_text()` hält HTML-freie Ausgaben (FAQPage-Schema) sauber. Genutzt von den
|
||
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 — die CSP erzwingt das, und zwar nur in
|
||
Produktion.** `default-src 'self'` ohne `style-src` heißt: der Browser verwirft jedes
|
||
`style`-Attribut und jeden `<style>`-Block stillschweigend. Lokal fällt das **nie** auf,
|
||
weil `php -S` keine `.htaccess` liest. Genau so blieben die Banner-Slides beim ersten
|
||
Deploy schwarz (31.07.2026): die Komponente setzte `style="--banner-bg: url(…)"`.
|
||
**Die zwei Auswege, wenn ein Wert aus `data/*.json` ins Aussehen muss:**
|
||
ein **Bild** wird ein `<img>` (die CSP erlaubt `img-src 'self' data:`, und `srcset`
|
||
gibt es gratis dazu), eine **Farbe** wird ein Token in `tokens.css` plus eine Klasse in
|
||
`components.css`, und die JSON nennt nur den Namen (`"accent": "breadcrumb"`).
|
||
`bin/preflight.php` bricht mit Fehler ab, wenn irgendwo unter `app/` ein `style`-Attribut
|
||
auftaucht. Per JavaScript gesetzte Styles (`element.style.…`) sind nicht betroffen.
|
||
Komponenten-Styles in `components.css` mit
|
||
Banner-Kommentaren (`/* === hero === */`).
|
||
**Kontext-Regeln setzen Custom Properties, kein Layout.** Wenn eine Komponente in einem Kontext
|
||
anders aussehen soll, gehört auf `.kontext .komponente` nur die Variable (`--crest-size`,
|
||
`--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, Geburtsdatum-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide
|
||
dort. Ein variantenspezifisches Feld braucht **drei** Stellen: den Schalter in
|
||
`contact_variant()`, die bedingte Ausgabe in `contact-form.php` und in der Action beides —
|
||
Validierung **und** ein Zurücksetzen auf leer, wenn die Variante das Feld nicht anbietet (sonst
|
||
könnte ein manipulierter POST es an einer Variante vorbei einschmuggeln). Ein
|
||
unbekannter Wert fällt auf die allgemeine Variante zurück, ein manipuliertes Feld erreicht also
|
||
keinen fremden Empfänger. Die Turn-Variante geht an `club.email_turnen`, hat die Disziplinen aus
|
||
`data/turnen.json` als Auswahl (Pflicht, ersetzt den Betreff) und eine freiwillige Nachricht.
|
||
**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` · `chronik` · `filesgallery` · `logo` · `logrotate`
|
||
(letzterer nur für den Übersprungen-Hinweis des Cron-Endpoints, der seinen Kanal aus dem
|
||
Job-Namen bildet)
|
||
(`php-errors.log` schreibt PHP selbst, eigenes Format).
|
||
- Level mit klarer Bedeutung: **INFO** Normalbetrieb (auch ein geblockter Bot — der Schutz
|
||
arbeitet, das ist kein Fehler) · **WARN** Auffälligkeit, Betrieb läuft weiter (Einzelbild
|
||
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`**.
|
||
- **Coolvetica unter ~40px braucht `letter-spacing: var(--ls-heading)`** (0.012em, also 0,4px bei
|
||
34px). Der Schnitt ist condensed: bei 60px (Abschnitts-h2) trägt er ohne Sperrung, bei 34px in einer
|
||
schmalen Spalte kleben die Versalien und der Titel wird schwer lesbar (aufgefallen an den
|
||
Beitragskarten, 31.07.2026). **Nicht `--ls-caps` (0.08em) nehmen** — das ist für kleine Labels
|
||
gedacht und lässt eine Überschrift auseinandergezogen wirken. Die globalen `h1`–`h3` stehen
|
||
bewusst auf `letter-spacing: normal`, gesperrt wird pro Komponente.
|
||
- Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
|
||
**Gemessene Zahl dazu:** `#e20612` auf der Glas-Karte (`rgba(0 0 0 / 0.22)` über `#222`) erreicht
|
||
**3,50:1** und verfehlt die 4,5:1 für Fließtext; Weiß liegt bei 17,22:1. Genau daran sind die
|
||
Beitragspreise hängengeblieben, die zuerst rot waren. Hervorhebung über Schriftart und Größe
|
||
(Coolvetica 28px gegen 21px Fließtext) trägt ohne Farbe.
|
||
- **Hervorgehobene Hinweise laufen über `component('notice')`** (`app/components/notice.php`,
|
||
Klassen `.notice`/`.notice--info`/`.notice--stop`), nie als eigenes Markup. Die Tonlage trägt
|
||
Bedeutung und ist keine Geschmacksfrage: **`--stop` (gefüllt rot) heißt „geht nicht"**
|
||
(Wartelisten-Stopp der Turnabteilung), **`--info` (Glas-Fläche, Rot nur im Icon) heißt „geht"**
|
||
(neue Kinder dürfen zum Schnuppern kommen). Eine Einladung in Alarmrot liest sich wie eine
|
||
Absage, und wenn alles rot ist, stumpft Rot ab und der echte Stopp verliert seine Wirkung.
|
||
**`--info` ist der Default, Rot die Ausnahme.** Der Text und die Tonlage stehen in der
|
||
`data/*.json` der Seite (`notice`, `notice_title`, `notice_tone`, `notice_icon`), nie im
|
||
Template; eingebettet wird über eine Zusatzklasse, die **nur** Position bzw. Breite setzt
|
||
(`.turnen-dash__notice` = `grid-area`, `.contact__notice` = Formularbreite).
|
||
Eine Überschrift ist Pflicht, wenn der Hinweis rot ist: ohne sie liest sich die rote Fläche
|
||
wie eine Fehlermeldung.
|
||
- Alle Werte als CSS Custom Properties in `tokens.css`; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
|
||
|
||
### Token-Disziplin (gilt für jede CSS-Änderung)
|
||
|
||
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 |
|
||
| Innenabstand von Karten-Flächen | `--pad-card` (20…32px) / `--pad-card-lg` (24…40px) | Keine festen Werte. Auf einem 309px-Handy stapeln sich Container- und Kartenabstand sonst so, dass dem Eingabefeld nur 71% der Bildschirmbreite bleiben. Die Obergrenzen entsprechen den früheren festen Werten, am Desktop ändert sich dadurch nichts. Ein harter Breakpoint (etwa „ab 310px ohne Abstand") wäre die Alternative, sieht aber bei 311px wieder falsch aus |
|
||
| `font-size` (Text) | `--fs-300 … --fs-1000` | `--fs-550`/`--fs-690` sind die fluiden Zwischenstufen (Lead, Karten-Überschrift) |
|
||
| `font-size` (Icons) | `--icon-s/m/l/xl` (rem), `--icon-inline-sm/--icon-inline/--icon-inline-lg` (em) | em-Stufen für Icons, die mit ihrer Textzeile mitwachsen sollen |
|
||
| `letter-spacing` | `--ls-tight/caps/wide/wider` | |
|
||
| 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 Takt-Geber**
|
||
|
||
**Die KAS-Cronjobs können ausschließlich URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026:
|
||
das Anlege-Formular hat nur ein Feld „Protokoll / Pfad" mit `https://`). Der Tarif hat inzwischen SSH
|
||
und Cronjobs, aber ein `php bin/…` ist dort nicht eintragbar. Deshalb ruft der **KAS-Cron diesen
|
||
Endpoint** auf, und er startet das Skript serverseitig. Der Ablauf steht in **`docs/deploy.md`**, Teil D,
|
||
die drei Zeitpunkte in `config/config.example.php`.
|
||
|
||
Das ist besser als der ursprünglich geplante externe Cron-Dienst: **der Aufrufer ist der Hoster
|
||
selbst**, der Schlüssel verlässt den Webspace nie, es ist überhaupt kein Dritter beteiligt.
|
||
`cron.key` muss also gesetzt sein — steht er leer, läuft **kein** Sync mehr.
|
||
|
||
Ein externer Cron-Dienst ruft, falls je nötig, dieselben URLs auf:
|
||
`https://…/cron.php?job=matchcenter` · `?job=instagram` · `?job=logrotate`. Schlüssel aus
|
||
`config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter (im KAS notgedrungen als
|
||
Parameter, das Formular kennt keine Header).
|
||
|
||
**Warum es drei Jobs sind:** `logrotate` kam am 31.07.2026 dazu, weil dieser Endpoint der einzige Weg
|
||
ist, überhaupt etwas getaktet laufen zu lassen. Ohne ihn liefe die Logpflege nie automatisch und die
|
||
2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Der Job passt in denselben Rahmen wie die Syncs: kein
|
||
Parameter aus der URL, keine Verbindung nach außen, er schreibt nur in `storage/logs`. Eine **vierte**
|
||
Ausnahme braucht wieder denselben Aufwand an Begründung. `bin/log-rotate.php` trägt dafür denselben
|
||
Riegel wie die Syncs (`PHP_SAPI !== 'cli' && !defined('CRON_HTTP')`) und `require_once`.
|
||
|
||
**Warum dieser Weg und nicht GitHub Actions mit FTP-Upload:** dort müsste ein Dritter Schreibzugang
|
||
zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der ausschließlich „starte einen der beiden
|
||
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. Der damals hier notierte Ausstieg („wenn der Tarif je Cronjobs bekommt,
|
||
`cron.key` leeren") ist am 31.07.2026 genau so eingetreten und vollzogen.
|
||
|
||
**Erledigt (17.09.2026):** die Frage, ob der Instagram-Scraper von der Server-IP durchkommt, hat sich
|
||
anders beantwortet als gedacht — er kommt von **keiner** IP mehr durch, siehe unten. Der Job ist aus
|
||
der Whitelist entfernt, es sind nur noch zwei.
|
||
|
||
## Instagram-Beiträge (Juicer-Ersatz, seit 17.09.2026 ohne Scraper)
|
||
|
||
**`php bin/instagram-add.php --profil`** (nur CLI) ist der Normalfall: liest die Beitragsliste vom
|
||
Profil und nimmt alles auf, was noch fehlt. Je Beitrag holt es über Instagrams oEmbed-Endpunkt Bild,
|
||
Caption und Datum, bringt das Bild auf 640×800 (mittiger Crop auf 4:5) und schreibt
|
||
`data/instagram.json` atomar. Daneben einzelne Post-URLs als Argument, `--liste=<datei>`,
|
||
`--entfernen=<shortcode>` und `--bild=<pfad>` (Notfall). Besucher laden Instagram-Inhalte weiterhin
|
||
ausschließlich von unserer Domain; ohne Einträge rendert die Komponente eine „Folge uns"-CTA-Karte.
|
||
|
||
**Die Beitragsliste kommt aus headless Chrome, und das ist der Kern des Ganzen.** Im ausgelieferten
|
||
Profil-HTML stehen die Beitrags-Codes NICHT — sie entstehen erst durch das JavaScript der Seite.
|
||
Ein echter Browser bekommt sie ausgeloggt weiterhin (nachgewiesen 17.09.2026: zwölf Codes, darunter
|
||
vier, die dem Feed fehlten). Das Skript liest sie per `--dump-dom` aus dem gerenderten DOM.
|
||
Den GraphQL-Aufruf, den der Browser dafür macht, **bewusst nicht nachbauen**: er ist mit
|
||
Sitzungs-Token abgesichert, und die Rekonstruktion wäre genau die Bastelei, die beim nächsten
|
||
Meta-Update wieder bricht. `bin/chrome-lib.php` hält `chrome_binary()` und `chrome_run()` für die
|
||
zwei Skripte, die einen Browser brauchen (hier und `bin/logo-build.php`) — **kein `--user-data-dir`**,
|
||
siehe Kommentar dort.
|
||
|
||
**Kein Cron, und der Grund ist hart:** auf dem All-Inkl-Webspace gibt es kein Chrome. `--profil`
|
||
läuft nur lokal auf einem Arbeitsrechner, die Aktualisierung braucht also einen Anstoß im Chat
|
||
(„hol die neuen Instagram-Beiträge"). Dafür sind es zwei Wörter statt gesammelter Links, und die
|
||
Seite hängt an nichts mehr, das unbemerkt von außen kaputtgehen kann.
|
||
|
||
**Warum der Scraper weg ist.** `bin/instagram-sync.php` holte die letzten Beiträge des öffentlichen
|
||
Profils und lieferte seit dem **03.09.2026** bei jedem Lauf „Keine Posts ermittelbar". Am 17.09.2026
|
||
von einem **privaten** Anschluss gegengeprüft, alle drei Strategien: `feed/user` und
|
||
`web_profile_info` antworten mit **401 `require_login`** (`igweb_rollout`), das Profil-HTML enthält
|
||
keine Beitragsdaten mehr, `?__a=1` ist tot, und auch `/embed/` liefert nur noch eine App-Hülle, die
|
||
ihren Inhalt erst im Browser nachlädt. **Es lag also nicht an der Server-IP** — ein Lauf von woanders
|
||
hilft nicht, und der Scraper ist gelöscht, damit ihn niemand wiederbelebt.
|
||
Verworfene Alternativen: die offizielle **Graph API** (setzt Professional-Konto, Meta-App und Token
|
||
voraus, abgelehnt von Felix) und **kommerzielle Scraping-Dienste** (Dritter, Kosten, bricht beim
|
||
nächsten Meta-Update).
|
||
|
||
**Was noch geht, und warum es trägt:** `https://www.instagram.com/api/v1/oembed/?url=<post-url>`
|
||
antwortet ohne Token und liefert `thumbnail_url` (640×800, also genau unser Format), `title` (die
|
||
komplette Caption) und `media_id`. **Das Datum steckt in der media_id**: `(id >> 23) + 1314220021721`
|
||
ergibt Millisekunden seit der Instagram-Epoche. Gegen alle neun echten `taken_at`-Werte gerechnet:
|
||
sechs exakt, drei mit 2 bis 8 Minuten Abweichung (Aufnahme- gegen Veröffentlichungszeit), sichtbar
|
||
ist ohnehin nur der Tag. **Der Shortcode ist der Primärschlüssel** und damit die komplette
|
||
Dedupe-Logik: er ist der Dateiname des Bildes und der Schlüssel des Eintrags, ein zweimal
|
||
geschickter Link aktualisiert statt zu verdoppeln.
|
||
|
||
**Die Datenstruktur bleibt exakt wie vorher** (sieben Felder je Beitrag) — deshalb war an
|
||
`app/components/instagram-feed.php`, `news_build_feed()`, der Momente-Seite der Stadionzeitung und
|
||
`bin/stadionzeitung-publish.php` **nichts** zu ändern.
|
||
|
||
**`data/instagram.json` ist seit dem 17.09.2026 versioniert**, vorher gitignored. Sie ist nicht mehr
|
||
maschinenverwaltet, ihr Inhalt ist Pflegearbeit. Solange sie ignoriert war, lief sie gegen die
|
||
versionierten Bilder auseinander: am 17.09.2026 lagen lokal **10 Bilder zu 9 Einträgen** — drei
|
||
Beiträge vom Server fehlten im lokalen Feed, zwei Einträge hatten kein Bild mehr. `bin/preflight.php`
|
||
prüft das jetzt in beide Richtungen (Eintrag ohne Bild = Fehler, Bild ohne Eintrag = Warnung).
|
||
|
||
**Ehrliche Grenze:** der genutzte Endpunkt ist Instagrams eigener, nicht der offiziell dokumentierte
|
||
(`graph.facebook.com/instagram_oembed` antwortet zwar auch ohne Token, liefert aber **kein** Bild,
|
||
nur den Einbettungscode, den Regel 1 verbietet). Fällt er weg, bleibt alles Eingetragene liegen und
|
||
nichts bricht rückwirkend; für neue Beiträge greift dann `--bild=<pfad>`. Das ist der Unterschied zum
|
||
Scraper, dessen Ausfall den ganzen Block eingefroren hat.
|
||
|
||
## 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`) als
|
||
**Liste über die volle Breite, Cover links und die Angaben rechts daneben** (dasselbe Muster wie
|
||
`.veranstaltung-card`, und aus demselben Grund: der Bestand ist klein). Vorher war das ein
|
||
horizontales Regal mit 280px-Karten; bei **einer** veröffentlichten Ausgabe stand die Karte allein
|
||
links auf einer 1280px breiten Seite (geändert 31.07.2026). Der Titel der Zeile lautet
|
||
**„Stadionzeitung zum Heimspiel gegen <Gegner>"** (nicht nur „Gegen <Gegner>"): so ist die Zeile für
|
||
sich verständlich, auch als Link-Text für Screenreader. Als Teaser darunter steht die **Coverzeile des
|
||
Interviews** (`interview.teaser`, dieselbe Quelle wie „Im Heft" auf dem Cover) — ohne Interview
|
||
entfällt sie. Pro Ausgabe ein Swipe-Viewer (`/stadionzeitung/<ausgabe-slug>`, dynamische Route,
|
||
siehe „Architektur-Muster"). **Bewusst kein PDF auf der Website** — die Ausgabe entsteht ohnehin in
|
||
Canva, jede Seite wird dort direkt als Bild (PNG/JPG) statt als PDF exportiert. Ein PDF-Export der
|
||
Muster-Datei war ~80 MB (hochauflösende Druck-Scans) und hätte serverseitig Ghostscript/Imagick
|
||
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
|
||
<ordner>/galerie/* optional, Fotos vom letzten Spiel (natsort, das erste ist der
|
||
Aufmacher, höchstens sieben) für die Bento-Galerie-Seite
|
||
```
|
||
**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 → Programm* → Interview → Aufruf (jedes nur wenn die Ausgabe es trägt)] ⌷
|
||
[Turnierpläne*: je Turnier eine Seite, EIN Block] ⌷ Ergebnis-Rückblick ⌷
|
||
[Galerie, nur wenn galerie/ geliefert] ⌷
|
||
[Gegnerstatistik: normal nur die tragende Mannschaft, in der Sportfest-Ausgabe je
|
||
Mannschaft mit Heimspiel am Fest eine — EIN Block, automatisch aus deren Tabelle] ⌷
|
||
[je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine
|
||
Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
|
||
News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
|
||
Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten
|
||
Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) →
|
||
Danke-Seite (Partner-Logo-Wand + QR auf /partner-werden, ohne Lücke davor: Akquise und Dank
|
||
gehören zusammen) ⌷
|
||
Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim
|
||
Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite
|
||
```
|
||
Alle Seiten außer Rückseite und Vorwort-Fallback sind Live-Seiten. Nach dem Cover, im Aufschlag
|
||
und vor der Rückseite steht **keine** Lücke (Begründung unten). \* = nur Sonderausgabe, siehe
|
||
„Sportfest-Ausgabe" unten.
|
||
**Doppelseiten-Regel fürs Falt-Heft:** aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander
|
||
(gerade Seite links). Die erste Tabellenseite muss deshalb auf einer GERADEN Seitenzahl liegen, damit
|
||
die Tabellen nebeneinander stehen. **Das rechnet das Skript selbst** (seit 31.07.2026): es verschiebt
|
||
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` | `programm` | `aufruf` | `turnier` | `tabelle` | `termine` | `news` | `ergebnisse` | `gegner` | `vorschau` | `kontakt` | `mitglied` | `historie` | `sportheim` | `partner` | `danke` | `momente` | `galerie` | `impressum`, kein Bild;
|
||
`tabelle` und `gegner` zusätzlich mit `team`: Team-Key aus `matchcenter.json`, ohne `team` greift der
|
||
Team-Key der Ausgabe; `turnier` mit `turnier`: Key aus `turniere[]` der Ausgaben-Veranstaltung;
|
||
`danke` mit `anzeige`: das platzierte Anzeigenmotiv, siehe unten). Fehlt `type` (alte Phase-1-Ausgaben) → Fallback `image`, bricht nicht. **Gerendert
|
||
wird jede Seite (Bild wie Live) über `app/components/stadionzeitung-seite.php`** — die eine
|
||
gemeinsame Render-Stelle für Viewer und Druckfassung, nie zwei Implementierungen derselben Seite.
|
||
|
||
**Danke-Seite + platzierte Anzeigen (03.09.2026).** Der Dank an die Partner war bis dahin unten an
|
||
ein Anzeigen-Bild geklebt (ein Canva-Motiv, das Sponsorenanzeige und Dank in einer Datei hatte) und
|
||
bestand aus Wappen, Titel und einer Zeile auf leerer Fläche. Jetzt ist es eine Live-Seite
|
||
(`stadionzeitung-danke.php`): die **Logo-Wand aus `data/partners.json`** (Single Source, dieselbe
|
||
Quelle wie die Startseite), Kopf und der QR auf `/partner-werden`. Dazu ein neues, generisches Feld
|
||
im Anzeigenbestand: **`platzierung`** nimmt eine Anzeige aus dem Streu-Pool und gibt ihr einen festen
|
||
Platz auf einer Live-Seite (derzeit `danke`, dort sitzt die Emons-Anzeige über der Logo-Wand).
|
||
- **Das Motiv steht im Seiteneintrag, nicht in der Komponente**: `bin/stadionzeitung-add.php` schreibt
|
||
es als `anzeige` in den `images[]`-Eintrag. Dadurch ist es eingefroren wie jede andere Anzeige, und
|
||
eine veröffentlichte Ausgabe ändert sich nicht, wenn der Sponsor später ein neues Motiv liefert.
|
||
- **Die Seitenzahl bleibt gleich**: eine Anzeige verlässt den Pool, eine Live-Seite kommt dazu.
|
||
- Das Kombi-Motiv wurde an seiner roten Trennlinie zerschnitten, damit die Sponsorenanzeige
|
||
vollständig bleibt und der Dank nicht zweimal im Heft steht.
|
||
- **Falle in `bin/anzeige-add.php`** (dabei gefunden und behoben): das Aufräum-Muster lautete
|
||
`{$slug}-*.jpg` und traf damit jeden Slug, der den neuen als Präfix hat — `emons` löschte die
|
||
Motive von `emons-partnerdank`. Jetzt `{$slug}-[0-9]*.jpg`.
|
||
|
||
**Entwurf / Veröffentlicht (`status`-Feld):** `bin/stadionzeitung-add.php` legt jede Ausgabe als
|
||
`entwurf` an — unsichtbar in Archiv und Sitemap, `noindex`, aber unter ihrer direkten URL erreichbar
|
||
(zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen
|
||
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 — **auf dem Server**, dort
|
||
liegt der frische Sync-Stand (`data/matchcenter.json` ist gitignored, lokal also älter).
|
||
`--entwurf` zieht zurück (Snapshot wird verworfen).
|
||
|
||
**Nach dem Veröffentlichen auf dem Server SOFORT committen und pushen** — das ist keine Kür,
|
||
sondern Teil des Vorgangs. `bin/stadionzeitung-publish.php` schreibt den Snapshot in
|
||
`data/stadionzeitung.json` und legt die eingefrorenen `fix-*`-Bilder an; beides existiert danach
|
||
zunächst **nur auf dem Server**. Bleibt es dort liegen, passieren zwei Dinge: ein `git pull` auf dem
|
||
Server scheitert beim nächsten Deploy an der geänderten Datei, und wer sie dort verwirft, verliert
|
||
die veröffentlichten Ausgaben ersatzlos (ein lokaler Stand setzt sie auf `entwurf` zurück, weil er
|
||
die Snapshots nie gesehen hat).
|
||
**Am 03.09.2026 genau so eingetreten:** drei veröffentlichte Ausgaben (01.08., 15.08., 29.08.)
|
||
lagen samt Snapshots und 70 `fix-*`-Dateien ausschließlich auf dem Server, der Pull der
|
||
Sportfest-Ausgabe brach mit „local changes would be overwritten" ab. Reparatur, in dieser
|
||
Reihenfolge: Server-Datei außerhalb des Repos sichern → `git checkout --` + `git pull` →
|
||
Ausgaben-Listen **je Slug** zusammenführen, **Server als Wahrheit**, aus dem Repo nur ergänzen was
|
||
dort fehlt → `git add data/stadionzeitung.json public/assets/img/stadionzeitung` → committen und
|
||
pushen. Ein Merge über den Dateiinhalt wäre keine Option gewesen: 1600 geänderte Zeilen JSON,
|
||
Konflikte mitten in eingefrorenen Datensätzen.
|
||
|
||
**Mit dem Snapshot werden auch die BILDER eingefroren** (seit 31.07.2026): das Skript kopiert
|
||
alles, worauf er zeigt (`img/crests/`, `img/instagram/`, `img/news/`), als `fix-*` in den
|
||
Ausgaben-Ordner und schreibt die Pfade um. **Grund:** vorher fror nur die Daten ein, die Pfade
|
||
zeigten weiter in Ordner, die die Sync-Skripte verwalten — `matchcenter-sync` räumt Wappen weg,
|
||
die der BFV nicht mehr führt (eine Saison), und der damalige `instagram-sync` ältere Posts (Wochen;
|
||
dieses Skript gibt es seit dem 17.09.2026 nicht mehr, das Einfrieren bleibt trotzdem richtig). Am 31.07.2026
|
||
nachgewiesen: ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe wäre also von
|
||
selbst zerfallen. `img/news` ist mit dabei, weil ein Artikelbild im Chat ersetzt werden kann.
|
||
**Varianten-Sets** (`{base, widths}`) werden über alle Breiten kopiert und nur umgeschrieben, wenn
|
||
**alle** da sind — ein halbes `srcset` wäre schlimmer als der alte Pfad. Fehlt eine Quelle, bleibt
|
||
der Pfad stehen und es gibt eine WARN (die `crest`-Komponente fällt auf Initialen zurück; ein
|
||
fehlendes Bild darf das Veröffentlichen nicht verhindern). Flache `fix-*`-Dateinamen statt eines
|
||
Unterordners, weil die Aufräum-Logik in `stadionzeitung-add.php` nur Dateien entfernt und ein
|
||
Unterordner als Waise bliebe. `--entwurf` löscht sie wieder, sie gehören zum Snapshot.
|
||
`bin/preflight.php` warnt, wenn eine veröffentlichte Ausgabe noch auf `img/crests` oder
|
||
`img/instagram` zeigt — Reparatur: `--entwurf`, dann neu veröffentlichen. Ein erneuter `stadionzeitung-add.php`-Lauf setzt eine veröffentlichte
|
||
Ausgabe zurück auf Entwurf (Seiten ersetzt = Stand muss neu geprüft und veröffentlicht werden).
|
||
Ausgaben ohne `status`-Feld gelten als veröffentlicht (Bestandsschutz). Die Kontakt-Seite ist
|
||
bewusst NICHT im Snapshot: sie liest immer `data/ansprechpartner.json` (ein Snapshot würde nur
|
||
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).
|
||
|
||
**Sportfest-Ausgabe (03.09.2026): ein Slug-Verweis schaltet drei Seitentypen zu.** Trägt eine Ausgabe
|
||
das chat-gepflegte Feld `veranstaltung` (Slug aus `data/veranstaltungen.json`), kommen dazu: die
|
||
**Programm-Seite** (`stadionzeitung-programm.php`, aus `programm[]` der Veranstaltung), ein Block
|
||
**Turnierplan-Seiten** (`stadionzeitung-turnier.php`, je Eintrag in `turniere[]` eine Seite) und eine
|
||
**Gegnerseite je Mannschaft mit Heimspiel im Zeitraum** der Veranstaltung statt nur für die tragende.
|
||
Dazu trägt `cover.teaser` die Coverzeile „Im Heft"; das Feld überlebt erneute Skript-Läufe wie
|
||
`vorwort`. **`stadionzeitung_teaser()`** ist die eine Quelle dieser Zeile für Cover **und**
|
||
Archivkarte: `cover.teaser` vor `interview.teaser` — eine Sonderausgabe ohne Interview hat sonst keine.
|
||
**Das Cover einer Sonderausgabe zeigt das FEST, nicht die Begegnung** (Felix, 03.09.2026): Wappen
|
||
und „Heimspiel gegen …" entfallen, an ihrer Stelle steht der Veranstaltungstitel in der
|
||
Display-Schrift plus Zeitraum (Quelle: `veranstaltungen.json`, nie neu getippt). Grund: ein Fest
|
||
bringt mehrere Heimspiele mit, eines davon aufs Cover zu heben wäre schlicht falsch. Ausgelöst wird
|
||
das vom `veranstaltung`-Feld, nicht von einem eigenen Schalter.
|
||
**Statt des Spielerfotos steht das HAUPTPLAKAT der Veranstaltung rechts** (erstes Plakat aus
|
||
`veranstaltungen.json`, dieselbe Quelle wie die Detailseite, nie eine Kopie): leicht schräg gelegt
|
||
(4 Grad) und an zwei gegenüberliegenden Ecken gerundet, links oben und rechts unten, mit Schatten
|
||
statt Rahmen — wie ein aufs Cover gelegter Aushang (Felix, 03.09.2026). Es ist mit 66cqi deutlich
|
||
kleiner als das Spielerfoto (98cqi): ein Freisteller ist schmal, ein Plakat im Hochformat läge bei
|
||
derselben Höhe über der Typografie unten links. Die Textspalte wird dafür auf 48cqi verengt.
|
||
Ohne Plakat greift das Spielerfoto, ohne beides trägt der Hintergrund die Seite. `box-shadow` ist
|
||
hier unbedenklich; die Druck-Falle des Projekts betrifft `text-shadow` auf umbrechendem Text.
|
||
|
||
**Sonst bleibt das Cover unverändert, und das ist eine Entscheidung:** eine rote Kennung
|
||
„Sportfest-Ausgabe" als Kasten im Kopf und ein kräftiger roter Doppelrahmen waren gebaut und wurden
|
||
am 03.09.2026 wieder entfernt (Felix: „sieht schrecklich aus"). Der Rahmen lief auf z2 quer über das
|
||
freigestellte Spielerfoto, und der Kasten stach aus dem ruhigen Kopf heraus. Die Ausgabe kennzeichnet
|
||
sich durch den großen Fest-Titel unten links ausreichend selbst. **Kein zweites Rot in den Kopf, und
|
||
keinen farbigen Rahmen über das Spielerfoto** — der Kicker bleibt „Saison · Datum" wie überall.
|
||
- **Die Daten liegen in `veranstaltungen.json`, nicht in der Ausgabe** — eine Quelle für Website und
|
||
Heft: dasselbe `programm[]` rendert `veranstaltung-programm.php` auf `/veranstaltungen/<slug>` und
|
||
`stadionzeitung-programm.php` auf dem A5-Blatt (zwei Darstellungen, nie zwei Datenstände).
|
||
`turniere[]` ist bisher **nur** Heft-Inhalt (Website-Darstellung offen), Feldform:
|
||
`{key, title, date, start, spieldauer, felder?, modus?, teams[], spiele[]{nr, zeit, feld?, heim, gast}}`.
|
||
- **`stadionzeitung_gegner_fixtures()`** (`helpers.php`) ist die EINZIGE Stelle, die entscheidet,
|
||
welche Mannschaft eine Gegnerseite bekommt. `bin/stadionzeitung-add.php` plant die Seiten damit,
|
||
`stadionzeitung_build_snapshot()` baut die Daten damit — sonst entstünde eine Seite ohne Daten oder
|
||
umgekehrt. `gegner` ist deshalb eine **Map je Team-Key** (wie `tabellen`) statt eines einzelnen
|
||
Objekts; Ausgaben ohne `team` am Seiteneintrag fallen auf die tragende Mannschaft zurück. Ein
|
||
eingefrorener Snapshot mit der alten Ein-Objekt-Form existiert nicht (die einzige veröffentlichte
|
||
Ausgabe ist älter als die Gegnerseite und trägt dort `null`), deshalb gibt es dafür bewusst
|
||
**keinen** Kompatibilitäts-Zweig.
|
||
- **Die Vorschau überspringt jedes Spiel mit eigener Gegnerseite**, nicht mehr nur das Heftspiel:
|
||
Zweite und Frauen stünden sonst zweimal im Heft. Sie zeigt dann die Spiele nach dem Fest.
|
||
- **Programm und Turnierpläne sind live, ohne Snapshot** (wie Kontakt/Aufruf): chat-gepflegte Daten,
|
||
die kein Sync wegräumt. Eine spätere Korrektur am Programm ändert damit auch eine archivierte
|
||
Ausgabe — bewusst in Kauf genommen, dieselbe Abwägung wie bei der Kontakt-Seite.
|
||
- **Die Turnierseite ist zum Beschreiben:** Spielplan mit LEERER Ergebnisspalte (Schreiblinien) und
|
||
leere Abschlusstabelle. Die Zeilenhöhe macht dort die **Schreiblinie**, nicht die Schriftgröße
|
||
(gemessen: eine kleinere Schrift brachte genau einen Pixel) — wer die E-Jugend-Seite mit zehn
|
||
Spielen aufs Blatt bringen muss, geht an `.stadionzeitung-turnier--dicht .__blank`. Die
|
||
Programm-Seite läuft aus demselben Grund mit straffen Abständen: zwölf Punkte plus drei Tagestitel
|
||
liefen mit den üblichen 3.3cqi 59px über die Blattkante. **Nach jeder Änderung Seitenhöhe messen.**
|
||
- **Beim Messen die scrollbaren Seiten mitnehmen.** Vorwort, Termine und Sportheim tragen
|
||
`overflow-y: auto`: dort wächst nicht die Seite, sondern das Kind wird scrollbar — eine Messung
|
||
von `scrollHeight - clientHeight` auf `.stadionzeitung-page` meldet 0, und im Druck fehlt trotzdem
|
||
das Ende des Textes. Genau so fiel ein sechsabsätziges Vorwort erst in der Druckvorschau auf
|
||
(03.09.2026, seitdem vier Absätze). Verlässlich ist: Unterkante der Signatur gegen die Blattkante
|
||
prüfen, im Viewer **und** in `?druck=1`.
|
||
- **Seitenzahl aufs Vielfache von 4 bringen, ohne Inhalt zu erfinden:** eine zeitgebundene
|
||
Eigenanzeige lässt sich per `bis` in `data/anzeigen.json` für diese Ausgabe abschalten (der Filter
|
||
vergleicht mit dem Ausgabedatum). So kam die Sportfest-Ausgabe von 49 auf 48 Seiten: das
|
||
Programmplakat als Anzeige wurde abgelöst, weil die gesetzte Programmseite es ersetzt.
|
||
- **Ablauf ist zweistufig** (wie beim Vorwort): erst `bin/stadionzeitung-add.php` laufen lassen, dann
|
||
`veranstaltung`/`cover` in die Ausgabe eintragen, dann **erneut** laufen lassen. Vorher existiert
|
||
die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder
|
||
Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler.
|
||
|
||
**Gegnerseiten ohne Fest, und der Anzeigename des Gegners (17.09.2026).** Zwei Felder, beide
|
||
chat-gepflegt und von `bin/stadionzeitung-add.php` über erneute Läufe durchgetragen wie `vorwort`:
|
||
|
||
- **`gegner_teams`** (Liste von Team-Keys) gibt weiteren Mannschaften eine Gegnerseite, ohne dass es
|
||
eine Veranstaltung braucht. Der Sportfest-Weg blieb unverändert daneben stehen (Heimspiele im
|
||
Festzeitraum); dieser hier nimmt das nächste Spiel im Fenster **zwei Tage vor bis drei Tage nach**
|
||
dem Ausgabedatum, **auch auswärts** — am normalen Spielwochenende fährt eine der drei Mannschaften
|
||
fast immer weg, und ihr Gegner ist trotzdem eine Seite wert. `stadionzeitung_gegner_fixtures()`
|
||
bleibt die eine entscheidende Stelle. Findet eine benannte Mannschaft kein Spiel im Fenster,
|
||
entsteht keine Seite und das Skript **warnt** — ein Tippfehler im Team-Key sähe sonst aus wie ein
|
||
spielfreies Wochenende. Auf der Gegnerseite steht seitdem **immer unsere Mannschaft links**
|
||
(Klasse `--own`), bei einem Auswärtsspiel läuft die Zeile „… bei …" statt „… gegen …"; vorher war
|
||
die linke Spalte fest die Heimmannschaft und die Hervorhebung lag beim Gegner.
|
||
- **`opponent_short`** ist der gedruckte Anzeigename des Gegners, gelesen über
|
||
**`stadionzeitung_opponent_display()`** (Cover, Archivzeile, Ausgaben-Kopf, Titel, Meta, Alt-Text
|
||
der Rückseite). **`opponent` selbst muss die BFV-Schreibweise behalten** — die Gegnerseite sucht
|
||
damit ihre Tabellenzeile und ihre Begegnung in `matchcenter.json` über einen exakten
|
||
Namensvergleich. Bei Spielgemeinschaften ist das eine Zeile wie „(SG1) TSV Wirsberg I/ FC
|
||
Kupferberg I", die auf einem Cover nichts zu suchen hat; `opponent_short` trägt dann
|
||
„Wirsberg/Kupferberg". Fehlt das Feld, bleibt es beim vollen Namen.
|
||
|
||
Nebenbei löst das oft die Seitenzahl: drei Gegnerseiten statt einer sind **zwei Seiten mehr**, und
|
||
ein Heft, das bei 42 stand, steht damit auf 44.
|
||
|
||
**Galerie-Seite (02.10.2026): Fotos vom letzten Spiel als Bento-Raster.** Optional, hinter dem
|
||
Ergebnis-Rückblick (`stadionzeitung-galerie.php`). Das Feld `galerie` mischt zwei Pfleger, wie bei
|
||
den Veranstaltungsbildern: `kicker`, `heading`, `text` und die **Alt-Texte** sind Chat-Pflege,
|
||
`bilder[]` schreibt `bin/stadionzeitung-add.php` aus `<ordner>/galerie/` (Breiten 640/1200).
|
||
Alt-Texte hängen an `source` (Originaldateiname) und folgen ihrem Bild; neue Fotos bekommen die
|
||
Überschrift als Platzhalter, das Skript nennt sie per WARN. **Der Ordner ist der komplette
|
||
Bestand:** ein Lauf ohne `galerie/` lässt die Seite entfallen, weil der Ausgaben-Ordner geleert
|
||
wurde. Kein Snapshot nötig, die Bilder liegen im Ausgaben-Ordner und kein Sync fasst sie an.
|
||
Raster: 6×4-Gitter, das erste Foto 4×2, die Anordnung je Anzahl (1 bis 7) als Klasse
|
||
`__bento--nN` mit `grid-template-areas`. Entstanden als Füllseite, die eine 43-seitige Ausgabe
|
||
auf 44 brachte.
|
||
|
||
**Redaktioneller Spielrückblick (16.10.2026).** Optionales Feld `rueckblick` einer Ausgabe:
|
||
`kicker`, `heading`, `meta`, `result`, `paragraphs[]`, `credit`, `source_url`
|
||
sind Chat-Pflege; `image` (inklusive chat-gepflegtem Alt-Text) verwaltet das Add-Skript aus
|
||
`<ordner>/rueckblick.*`. Die Live-Seite steht nach den Ergebnissen, vor der Galerie. Das Foto
|
||
liegt dauerhaft im Ausgaben-Ordner. Bei erneutem Erzeugen muss die Quelle wieder mitgeliefert
|
||
werden; fehlt sie, bricht das Skript vor dem Leeren des Zielordners ab. Preflight prüft Foto
|
||
und Bildnachweis. Der gemeinsame Bild-/Textnachweis steht einmal direkt unter dem Foto
|
||
(im Web zur Quelle verlinkt, auch im Druck lesbar). Kein Bild hochskalieren oder fremd einbinden.
|
||
Für die Untersteinach-Ausgabe: öffentliches Derby-Vorschaubild
|
||
`https://www.anpfiff.info/upload/images/Teaser8/843447.jpg` (560 × 270), Bildquelle anpfiff.info.
|
||
Felix hat die Nutzungsgenehmigung im Chat am 16.10.2026 bestätigt. Ein individueller Fotograf
|
||
ist am frei sichtbaren Bild nicht ausgewiesen; deshalb keinen Namen vermuten.
|
||
|
||
**Gegner- und Ergebnisseite neu gestaltet (19.09.2026).** Beide Seiten waren inhaltlich richtig und
|
||
gestalterisch leer, der Umbau lief über mehrere verworfene Entwürfe. Was davon bleibt:
|
||
- **Ergebnisse als Anzeigetafel** (Vorbild kicker/Sofascore): Wappen und Name je Seite, das Ergebnis
|
||
groß in der Mitte. Vorher waren es Listenzeilen, und eine Zeile stellt Heim und Gast untereinander
|
||
statt gegenüber. Verworfen und nicht wieder einzubauen: Tore am rechten Blattrand (halbe
|
||
Seitenbreite Leere zwischen Name und Ziffer), eine getönte Karte je Spiel („altbacken"), ein roter
|
||
Strich unter der Mannschaftszeile (lauter als die Ergebnisse) und graue Verlierer-Ziffern (auf
|
||
Papier schlechter lesbar). Vereinsnamen laufen **einzeilig** und werden sonst ausgepunktet.
|
||
- **Gegnerseite**: Duell symmetrisch und leise (beide Wappen gleich groß, der Name des Gegners steht
|
||
nur in der Überschrift laut da), darunter eine abgeleitete Aufmacher-Zeile („Sieben Siege aus acht
|
||
Spielen", aus den Tabellenwerten gebaut), der Vergleich als **Balkenpaar aus der Mitte** und zum
|
||
Schluss ein **Tabellenausschnitt** über die geteilte `league-table`-Komponente. Der Tabellenplatz
|
||
steht bewusst dort und nicht als Balken (klein ist besser, der Balken läge falsch herum) und auch
|
||
nicht als lose Ziffer im Kopf (war gebaut, wirkte unruhig).
|
||
- **Balkenlängen kommen aus Klassen in 5-Prozent-Stufen**, nie aus einem `style`-Attribut — die CSP
|
||
verwirft inline styles stillschweigend, die Balken wären erst nach dem Deploy unsichtbar.
|
||
- Die Gegnerseite druckt **`opponent_display`** (Kurzname der Ausgabe, wenn die Seite den Gegner der
|
||
Ausgabe zeigt); `opponent` bleibt die BFV-Schreibweise für den Namensvergleich.
|
||
|
||
**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 Bauen (31.07.2026):** ein zusätzlicher Kommentar wurde versehentlich HINTER das
|
||
schließende `*/` des bestehenden gesetzt. Damit stand roher Text vor der Regel, der CSS-Parser
|
||
verwarf den kompletten Regelblock — und die A5-Höhe im Swipe-Viewer war wirkungslos, ohne dass
|
||
irgendetwas einen Fehler meldete. Die Seiten fielen dadurch auf Inhaltshöhe zurück und waren 350
|
||
bis 559px hoch statt einheitlich 515. **`php -l` prüft CSS nicht**; wer hier etwas ändert, zählt
|
||
`/*` gegen `*/` und misst eine Seitenhöhe nach.
|
||
|
||
**Falle beim
|
||
Containment:** `width: auto` + `max-height` kollabiert eine inline-size-contained Box auf ihr
|
||
Padding (Inhalt zählt nicht mehr) — im Swipe-Modus bekommen `.stadionzeitung-page` **und
|
||
`.stadionzeitung-cover`** deshalb eine definite Breite (`min(calc(72vh * 148 / 210), 100%)`),
|
||
nie `width: auto`. **Beide stehen dafür in EINER Regel**, und das ist der Punkt: das Cover hatte
|
||
als eigene Klasse zunächst nur eine „harmlose Höhen-Deckelung" mit `width: auto` und blieb am
|
||
iPhone unsichtbar, während Chrome am Desktop es zeigte (31.07.2026). Der Unterschied entsteht,
|
||
weil `.stadionzeitung-viewer__page` `display: flex` ist: auf einem Flex-Kind heißt `width: auto`
|
||
„richte dich nach dem Inhalt", Chrome rettet sich über `aspect-ratio` plus gestreckte Höhe,
|
||
Safari nicht. **Chrome kann diesen Fehler nicht reproduzieren** — wer hier etwas ändert, prüft
|
||
es an einem echten iOS-Gerät, nicht im Desktop-Browser. Das Cover war der
|
||
Anfang dieses Prinzips:
|
||
Kontrast der Schlagzeile kommt aus der flachen Verlauf-Fläche selbst, bewusst **ohne** `text-shadow`:
|
||
Chrome rendert `text-shadow` auf mehrzeiligem, umbrechendem Text (lange Gegner-Namen) beim
|
||
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). Erreichbar
|
||
**nur über die URL**: an die Ausgaben-Route ein `?druck=1` anhängen. Der frühere
|
||
„Druckfassung"-Button im Viewer ist am 31.07.2026 entfernt worden (Felix): die Druckfassung ist
|
||
Werkzeug für die Heft-Produktion, kein Besucher-Angebot, und als sichtbarer Knopf neben Blättern
|
||
und Vollbild wurde er versehentlich geklickt. **Nicht als Link wieder einbauen** — wer sie
|
||
braucht, kennt die URL.
|
||
`app/components/stadionzeitung-druck.php` montiert dort die Seiten in
|
||
**Broschüren-Reihenfolge** (Sattelheftung: Blatt 1 vorne = letzte Seite + Cover, hinten = Seite 2 +
|
||
vorletzte, …). Die Montage liegt bewusst im PHP, nicht im Druck-CSS: CSS kann Seiten nicht über
|
||
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).
|
||
|
||
**Alle Bögen laufen RANDABFALLEND** (`@page { margin: 0 }`, Beschluss 31.07.2026 — vorher galt hier
|
||
„Bürodrucker mit weißem Rand ist der Anspruch, bewusst gleichmäßiges Passepartout"). Der Wechsel kam
|
||
aus einem gemessenen Fehler: die Innenblätter hatten einen 5mm-Rahmen, die zwei Umschlagbögen nicht,
|
||
und der Blatt-Inhalt war über eine `aspect-ratio` bemaßt und nur **199,1mm** hoch statt 210mm. Die
|
||
fehlenden 10,9mm sammelten sich **immer unten**, weil der Inhalt oben ausgerichtet ist — gemessen an
|
||
Blatt 4: mit `margin: 5mm` oben 4,8 / unten 5,8mm, mit `margin: 0` oben 0,0 / unten 10,9mm. Ein Rand
|
||
kaschierte den Fehler, behob ihn aber nicht. Randabfallend ist die saubere Lösung, weil **zwei A5
|
||
exakt ein A4 quer sind** (2 × 148,5 = 297mm, Überschuss null): es bleibt nichts zu verteilen.
|
||
Nachgemessen im erzeugten PDF: oben und unten je 0,0mm auf jedem Bogen. Verbleibendes Weiß steckt in
|
||
den **Anzeigenmotiven selbst** (viele Sponsoren gestalten auf weißem Grund, 100% weiße Randpixel) und
|
||
ist nicht unser Layout. Den Rand liefert der Bürodrucker aus seinem Hardware-Minimum (~4 bis 5mm);
|
||
unser Fließtext verliert dabei nichts, die Live-Seiten haben 6,7cqi = 9,9mm Innenabstand.
|
||
**Keine `@page`-Randbreite wieder einführen, ohne die Blatt-Höhe gegenzurechnen** — sonst ist der
|
||
Überschuss zurück.
|
||
|
||
Es gibt bewusst auch **keinen Falz-Steg** mehr: die beiden Seiten stoßen mittig aneinander, der
|
||
Innenabstand der Seiten (9,9mm) hält den Inhalt aus dem Knick. Vorher hatten nur die Innenbögen 2%
|
||
Steg, der Umschlagbogen nicht — dadurch waren die Seiten dort 441px statt 448,5px breit, was im Heft
|
||
als „das Programm ist schmaler als das Vorwort drüber" auffiel. **Soll der Steg je zurück, dann für
|
||
ALLE Bögen**, nie nur für einen Teil.
|
||
|
||
**Drei Seiten haben eine eigene `aspect-ratio` und heben per negativem Margin das Seiten-Padding auf,
|
||
damit ihr Foto bis an die Kante läuft: Vorwort, Sportheim und Termine.** Alle drei brauchen deshalb
|
||
`min-height: calc(100% + var(--space-l) * 2)` und stehen dafür in **einer** gemeinsamen Regel — beim
|
||
Vorwort allein behoben fehlte sie den anderen zwei, und die Sportheim-Seite war dadurch 625,7px hoch
|
||
in einer 636,4px hohen Seite (10,7px weißer Streifen unter dem Foto). Eine vierte Seite mit diesem
|
||
Konstrukt gehört in denselben Selektor.
|
||
|
||
`@media print`
|
||
(`:has()`-Scope auf Viewer/Druckfassung, andere Seiten bleiben unangetastet) regelt nur Layout —
|
||
Farben sind digital bereits hell. Der normale Viewer druckt als einfacher Seiten-Stapel (Fallback);
|
||
fürs Heft immer die Druckfassung nehmen. Bei Änderungen an Komponenten, die in einer Ausgabe
|
||
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 (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.
|
||
|
||
## Logo-Downloadseite `/logo`
|
||
|
||
Damit die Abteilungen das Wappen selbst holen können, statt jedes Mal zu fragen: eine
|
||
öffentliche Seite mit jeder Variante in jedem gängigen Format, dazu die Farbwerte und die
|
||
Nutzungsregeln. Verlinkt **nur in der Fußzeile** (`navigation.json` → `legal`), nicht im
|
||
Hauptmenü. Die Dateien werden **einmalig lokal** erzeugt und committet; nichts davon läuft
|
||
auf dem Server, es gibt keinen Cron und keine Laufzeit-Abhängigkeit.
|
||
|
||
**Drei Varianten, alle aus EINER Master-SVG** (`storage/logo-quellen/aktuell.svg`, identisch
|
||
zum Website-Asset `public/assets/img/logo.svg`): `farbig`, `einfarbig-schwarz` und
|
||
`einfarbig-weiss`. Die Umfärbung passiert durch Austausch der Farbklassen im `<defs>`-Block
|
||
der CorelDRAW-Datei (`.fil0`/`.fil3` schwarz, `.fil1` weiß, `.fil2` rot) —
|
||
`bin/logo-build.php` bricht ab, wenn eine erwartete Klasse fehlt, sonst entstünde stillschweigend
|
||
dreimal dasselbe Logo.
|
||
**Die beiden einfarbigen Fassungen sind exakte Negative voneinander:** nur Linien, ALLE
|
||
Flächen frei. Zwei Irrwege dahin, beide am 17.09.2026 korrigiert: eine Fassung „für dunklen
|
||
Grund" behielt das rote Schild und war damit keine einfarbige, sondern ein farbiges Logo mit
|
||
weißen Konturen; und `einfarbig-schwarz` ersetzte Rot durch **Weiß**, wodurch im PNG eine
|
||
deckend weiße Schildfläche steckte (auf farbigem Papier ein weißer Klotz). **Einfarbig heißt
|
||
einfarbig:** ein Vollton, der Rest transparent. Nachweisbar über die Volltonfarben im PNG,
|
||
je Variante darf genau eine vorkommen.
|
||
|
||
**Gerastert wird mit headless Chrome, nicht mit GD.** `bin/img-resize.php` kann keine SVG lesen,
|
||
und Inkscape, rsvg-convert oder ImageMagick gibt es hier nicht (auf dem Shared Hosting erst
|
||
recht nicht). Chrome kann beides, was gebraucht wird: transparent rastern (`--screenshot` +
|
||
`--default-background-color=00000000`) und ein **echtes Vektor-PDF** drucken (`--print-to-pdf`,
|
||
nachgemessen: Kurven-Operatoren im Inhaltsstrom, kein eingebettetes Pixelbild).
|
||
**Kein `--user-data-dir`** — der naheliegende eigene Profilordner lässt Chrome 152 beim ersten
|
||
Aufruf endlos hängen, ohne Bild und ohne Meldung. Ohne den Schalter läuft jeder Aufruf in
|
||
Sekunden durch, auch bei geöffnetem Chrome. Wer das ändert, misst die Laufzeit nach.
|
||
|
||
**Der JPG-Hintergrund gehört zur Variante**, nicht zum Format: JPG kann keine Transparenz, und
|
||
die weiße Fassung auf weißem Grund wäre ein leeres Blatt. `einfarbig-weiss` wird deshalb auf
|
||
Anthrazit (`#222`) flachgelegt, alle anderen auf Weiß (`jpg_bg` je Eintrag in `$OUTPUTS`).
|
||
|
||
**EPS gibt es bewusst nicht.** Dafür bräuchte es Illustrator, Inkscape oder Ghostscript; ein
|
||
Composer-Paket verbietet Regel 1. Druckereien nehmen PDF oder SVG, beides liegt als Vektor
|
||
bereit. Steht die Frage doch im Raum, beantwortet sie die FAQ auf der Seite.
|
||
|
||
**Retro-Logos:** `retro[]` in `data/logo.json` ist vorbereitet und vorerst leer — im Projekt
|
||
existiert kein einziges historisches Wappen (weder im Repo noch in `.context/`, der alten
|
||
CMS-Datenbank oder der Chronik-Sammlung). Kommt ein Scan, wird er eine weitere Datei in
|
||
`storage/logo-quellen/` plus ein Eintrag in `$OUTPUTS`; liegt er nur als Pixelbild vor, erzeugt
|
||
das Skript PNG und JPG über `resize_image_variants()` und lässt SVG und PDF weg.
|
||
|
||
**Die Seite ist bewusst karg** (Felix, 17.09.2026): Einleitungstext, Nutzungshinweis
|
||
(„nicht verzerren, nicht einfärben"), die Abschnittsüberschrift über den Karten und eine
|
||
fünfteilige FAQ waren gebaut und sind alle wieder entfernt worden; das ZIP über alle
|
||
Varianten hatte ebenfalls eine eigene Sektion samt Überschrift „Alles auf einmal" und ist
|
||
jetzt nur noch ein Knopf unter dem Karten-Raster (Prop `paket` der Komponente) — „alles
|
||
davon" gehört direkt unter das, worauf es sich bezieht. Eine Downloadseite ist ein
|
||
Regal, kein Text: Wappen, Knöpfe, Farben, fertig. Die Variantenbeschreibungen sind auf drei
|
||
Wörter gekürzt („Stempel, Gravur, einfarbiger Druck."). Die Überschrift steht noch als
|
||
`visually-hidden` im DOM, damit die Heading-Kette nicht von `h1` auf `h3` springt.
|
||
**Nichts davon wieder hinzuerklären.**
|
||
|
||
`bin/preflight.php` prüft, dass zu jeder Variante die Dateien existieren (**Fehler**, nicht
|
||
Warnung: die Komponente lässt einen Knopf ohne Datei still weg, man sähe es der Seite nicht an)
|
||
und warnt, wenn eine Master-SVG neuer ist als das Erzeugte.
|
||
|
||
## 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.
|