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