8.6 KiB
8.6 KiB
TSV 08 Kulmbach — Website
Neubau von tsv08kulmbach.de. Vereinsseite (Fußball + Turnen, gegründet 1908) als sauberer, selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
Stack & Umgebung
- Frontend: Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node.
- Backend: Vanilla PHP ≥ 8.1, Composer nur für
phpmailer/phpmailer. Keine Template-Engine. - Hosting-Ziel: Apache Shared Hosting, PHP 8.x,
.htaccess, Cronjobs verfügbar. - Lokales Dev:
php -S localhost:8000 -t public public/index.php(index.php fungiert als Router-Script, weilphp -Skein .htaccess kennt). - 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)
- 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: Brevo SMTP (Mailversand, serverseitig) und der Instagram-Scraper (CLI/Cron, nie im Request-Pfad).
Besucher-Browser kontaktieren ausschließlich unsere Domain. Die CSP (
default-src 'self') erzwingt das — nicht aufweichen. .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 nachpublic/assets/kopieren/optimieren.- Kein Admin-/Pflege-Backend bauen. Inhalte werden per Chat gepflegt: Claude editiert
data/*.jsonbzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints. - Sensible Altdaten nie übernehmen: Aus dem DB-Dump keine Mail-Logs, Formulareinträge, Passwort-Hashes, Tokens oder personenbezogene Daten migrieren.
- Secrets nur in
config/config.php(gitignored, außerhalb des Webroots, per .htaccess denied). Niemals hardcoden, loggen oder ausgeben.config/config.example.phpdokumentiert 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) | data/club.json |
Adresse/E-Mail irgendwo als Text duplizieren |
| Startseiten-Inhalte | data/home.json |
Texte in pages/home.php |
| Partner/Sponsoren | data/partners.json |
— |
| Instagram-Cache | data/instagram.json + public/assets/img/instagram/ |
maschinenverwaltet von bin/instagram-sync.php — nie von Hand editieren |
| 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.
Architektur-Muster
- Front Controller:
public/index.php→ Slug-Lookup inapp/routes.php→ Page-Datei setzt$metaund emittiert Body viacomponent()→app/layout.phprendert die Shell. - Komponenten:
app/components/*.phpsind dumme Includes, bekommen Props viacomponent('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(),asset()(Versionierung via filemtime),json_load('name')(liestdata/name.json, static cache),component(),icon('name', 'klasse', 'label?')(Inline-SVG auspublic/assets/icons/, siehe Abschnitt Icons). - CSS: 6 Dateien als
<link>in fester Reihenfolge: tokens → reset → base → layout → components → utilities. Kein@import, kein Inline-Style. Komponenten-Styles incomponents.cssmit Banner-Kommentaren (/* === hero === */). - JS: Vanilla,
defer, progressive enhancement — alles muss ohne JS funktionieren (Formulare = normales POST + Redirect). Ein Feature = eine Datei = eine selbst-initialisierende IIFE. - Neue Seite anlegen = genau 3 Schritte:
app/pages/<slug>.php+ Eintrag inapp/routes.php(+ optionaldata/navigation.json). Sitemap & Canonical folgen automatisch.
Formulare
- Felder ausschließlich über
component('form-field', ['field' => …])(app/components/form-field.php) — nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext und das Fehler-Element#<id>-error(peraria-describedbyverknüpft) in einheitlichem Markup. - Jedes neue Formular braucht: (1) Status-Region
role="status" aria-live="polite" tabindex="-1" data-form-statusals erstes Form-Kind, (2) Hinweiszeile „Pflichtfelder sind mit * markiert“, (3) Honeypot-Feldwebsite(visually-hidden) + Hidden-Fieldftmitform_token(), (4) serverseitige Action unterapp/actions/mit Whitelist-Validierung + PRG-Redirect. form.jsübernimmt automatisch Inline-Feldfehler (Browser-Validierungsmeldung,aria-invalid, Fokus aufs erste ungültige Feld) — Konvention dafür ist die#<id>-error-ID aus der Komponente.- 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 perdisplay:noneverstecken (fliegt aus dem Accessibility-Tree). - Versand via PHPMailer über Brevo SMTP (
smtp-relay.brevo.com:587, STARTTLS), Config ausconfig/config.php. - Spam-Schutz ohne externe Dienste: Honeypot-Feld + HMAC-signierter Timestamp (Time-Trap,
app_secret)- serverseitige Validierung mit Whitelists. POST-Redirect-GET;
form.jsmacht optional fetch.
- serverseitige Validierung mit Whitelists. POST-Redirect-GET;
- Fehler nach
storage/logs/mail.log, nie an Besucher leaken.
Design (aus der alten Seite extrahiert, verifiziert)
- Dunkles sportliches Theme: BG
#222mit 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. mitletter-spacing+ uppercase. - Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
- Alle Werte als CSS Custom Properties in
tokens.css; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
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-sizedes Elements (Icon ist1em), Farbe =currentColor. Basisklasse.iconinutilities.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-visiblesichtbar, sinnvolle Tab-Reihenfolge - Kontrast ≥ 4.5:1 (Fließtext) / 3:1 (große Schrift, UI)
prefers-reduced-motionrespektiert (globaler Kill-Switch in reset.css)$metagesetzt: title, description (+ og_image falls abweichend)- Bilder:
srcset/sizes,width/height,loading="lazy"(above-the-fold: eager + fetchpriority) - Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
php -lsauber; Seite lokal geprüft
Instagram-Feed (Juicer-Ersatz)
bin/instagram-sync.php (nur CLI) scraped das öffentliche Profil @tsv08kulmbach, schreibt
data/instagram.json atomar und lädt Bilder nach public/assets/img/instagram/. Bei Fehlern bleibt
der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente
eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.