From 0f19729a883654aea1494f1cb2b6042c68c0d554 Mon Sep 17 00:00:00 2001 From: Felix Schneider Date: Thu, 11 Jun 2026 21:16:25 +0200 Subject: [PATCH] Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage - CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first) - Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2) - Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub) - Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt - Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA) - bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern Co-Authored-By: Claude Fable 5 --- .gitignore | 24 ++ .htaccess | 9 + CLAUDE.md | 94 +++++ app/.htaccess | 1 + app/actions/contact-submit.php | 92 +++++ app/bootstrap.php | 24 ++ app/components/contact-form.php | 92 +++++ app/components/cta.php | 10 + app/components/footer.php | 46 +++ app/components/header.php | 18 + app/components/hero.php | 23 ++ app/components/img.php | 41 +++ app/components/instagram-feed.php | 41 +++ app/components/jsonld.php | 31 ++ app/components/meta.php | 52 +++ app/components/nav.php | 33 ++ app/components/partner-grid.php | 33 ++ app/components/section.php | 26 ++ app/components/stats.php | 20 + app/helpers.php | 121 +++++++ app/layout.php | 22 ++ app/pages/404.php | 16 + app/pages/home.php | 28 ++ app/routes.php | 15 + bin/.htaccess | 1 + bin/img-resize.php | 76 ++++ bin/instagram-sync.php | 214 +++++++++++ composer.json | 15 + composer.lock | 106 ++++++ config/.htaccess | 1 + config/config.example.php | 38 ++ data/.htaccess | 1 + data/club.json | 26 ++ data/home.json | 57 +++ data/navigation.json | 17 + data/partners.json | 7 + public/.htaccess | 39 ++ public/assets/css/base.css | 106 ++++++ public/assets/css/components.css | 341 ++++++++++++++++++ public/assets/css/layout.css | 218 +++++++++++ public/assets/css/reset.css | 66 ++++ public/assets/css/tokens.css | 58 +++ public/assets/css/utilities.css | 52 +++ public/assets/fonts/abel.woff2 | Bin 0 -> 10780 bytes public/assets/fonts/coolvetica.woff2 | Bin 0 -> 48316 bytes public/assets/img/background.jpg | Bin 0 -> 111556 bytes public/assets/img/hero/hero-1000.jpg | Bin 0 -> 127077 bytes public/assets/img/hero/hero-1600.jpg | Bin 0 -> 306586 bytes public/assets/img/hero/hero-640.jpg | Bin 0 -> 59686 bytes public/assets/img/instagram/.gitkeep | 0 public/assets/img/logo.svg | 30 ++ public/assets/img/og-default.jpg | Bin 0 -> 145685 bytes public/assets/img/pages/sportheim-1000.jpg | Bin 0 -> 91405 bytes public/assets/img/pages/sportheim-640.jpg | Bin 0 -> 46825 bytes public/assets/img/pages/verein-1000.jpg | Bin 0 -> 139646 bytes public/assets/img/pages/verein-640.jpg | Bin 0 -> 62971 bytes .../assets/img/pages/vereinshistorie-519.jpg | Bin 0 -> 30051 bytes public/assets/img/partners/aoa.png | Bin 0 -> 37315 bytes public/assets/img/partners/downstairs.png | Bin 0 -> 14286 bytes public/assets/img/partners/horn.png | Bin 0 -> 4392 bytes public/assets/img/partners/kulmbacher.png | Bin 0 -> 91075 bytes public/assets/img/partners/rist.png | Bin 0 -> 27324 bytes public/assets/js/form.js | 62 ++++ public/assets/js/nav.js | 40 ++ public/favicon.ico | Bin 0 -> 15086 bytes public/index.php | 59 +++ public/robots.txt | 5 + storage/.htaccess | 1 + storage/cache/.gitkeep | 0 storage/logs/.gitkeep | 0 70 files changed, 2548 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 CLAUDE.md create mode 100644 app/.htaccess create mode 100644 app/actions/contact-submit.php create mode 100644 app/bootstrap.php create mode 100644 app/components/contact-form.php create mode 100644 app/components/cta.php create mode 100644 app/components/footer.php create mode 100644 app/components/header.php create mode 100644 app/components/hero.php create mode 100644 app/components/img.php create mode 100644 app/components/instagram-feed.php create mode 100644 app/components/jsonld.php create mode 100644 app/components/meta.php create mode 100644 app/components/nav.php create mode 100644 app/components/partner-grid.php create mode 100644 app/components/section.php create mode 100644 app/components/stats.php create mode 100644 app/helpers.php create mode 100644 app/layout.php create mode 100644 app/pages/404.php create mode 100644 app/pages/home.php create mode 100644 app/routes.php create mode 100644 bin/.htaccess create mode 100644 bin/img-resize.php create mode 100644 bin/instagram-sync.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/.htaccess create mode 100644 config/config.example.php create mode 100644 data/.htaccess create mode 100644 data/club.json create mode 100644 data/home.json create mode 100644 data/navigation.json create mode 100644 data/partners.json create mode 100644 public/.htaccess create mode 100644 public/assets/css/base.css create mode 100644 public/assets/css/components.css create mode 100644 public/assets/css/layout.css create mode 100644 public/assets/css/reset.css create mode 100644 public/assets/css/tokens.css create mode 100644 public/assets/css/utilities.css create mode 100644 public/assets/fonts/abel.woff2 create mode 100644 public/assets/fonts/coolvetica.woff2 create mode 100644 public/assets/img/background.jpg create mode 100644 public/assets/img/hero/hero-1000.jpg create mode 100644 public/assets/img/hero/hero-1600.jpg create mode 100644 public/assets/img/hero/hero-640.jpg create mode 100644 public/assets/img/instagram/.gitkeep create mode 100644 public/assets/img/logo.svg create mode 100644 public/assets/img/og-default.jpg create mode 100644 public/assets/img/pages/sportheim-1000.jpg create mode 100644 public/assets/img/pages/sportheim-640.jpg create mode 100644 public/assets/img/pages/verein-1000.jpg create mode 100644 public/assets/img/pages/verein-640.jpg create mode 100644 public/assets/img/pages/vereinshistorie-519.jpg create mode 100644 public/assets/img/partners/aoa.png create mode 100644 public/assets/img/partners/downstairs.png create mode 100644 public/assets/img/partners/horn.png create mode 100644 public/assets/img/partners/kulmbacher.png create mode 100644 public/assets/img/partners/rist.png create mode 100644 public/assets/js/form.js create mode 100644 public/assets/js/nav.js create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 storage/.htaccess create mode 100644 storage/cache/.gitkeep create mode 100644 storage/logs/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e6f9f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Secrets — nur config.example.php wird committed +/config/config.php + +# Composer +/vendor/ + +# Laufzeit-Daten +/storage/logs/* +/storage/cache/* +!/storage/logs/.gitkeep +!/storage/cache/.gitkeep + +# Maschinenverwaltet (bin/instagram-sync.php) +/data/instagram.json +/public/assets/img/instagram/* +!/public/assets/img/instagram/.gitkeep + +# Referenz-Material der alten Seite — NIE committen +# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes) +/.context/ +/.context_db/ + +# OS +.DS_Store diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..6abe6f5 --- /dev/null +++ b/.htaccess @@ -0,0 +1,9 @@ +# Fallback, falls der Hosting-Docroot auf das Repo-Root zeigt statt auf public/. +# Nicht-öffentliche Verzeichnisse und Dateien hart sperren, alles andere nach public/ rewriten. +RewriteEngine On + +RewriteRule ^(app|bin|config|data|storage|vendor)(/|$) - [F] +RewriteRule ^(\.git|\.gitignore|composer\.(json|lock)|CLAUDE\.md|\.context|\.context_db) - [F] + +RewriteCond %{REQUEST_URI} !^/public/ +RewriteRule ^(.*)$ public/$1 [L] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3a361b5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# 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, weil `php -S` kein .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) + +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: 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. +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. +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) | `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** | + +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 in `app/routes.php` → Page-Datei setzt `$meta` + und emittiert Body via `component()` → `app/layout.php` rendert die Shell. +- **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()`, `asset()` + (Versionierung via filemtime), `json_load('name')` (liest `data/name.json`, static cache), `component()`. +- **CSS:** 6 Dateien als `` in fester Reihenfolge: tokens → reset → base → layout → components → + utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit + 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/.php` + Eintrag in `app/routes.php` + (+ optional `data/navigation.json`). Sitemap & Canonical folgen automatisch. + +## Formulare + +- Versand via PHPMailer über Brevo SMTP (`smtp-relay.brevo.com:587`, STARTTLS), Config aus `config/config.php`. +- Spam-Schutz ohne externe Dienste: Honeypot-Feld + HMAC-signierter Timestamp (Time-Trap, `app_secret`) + + serverseitige Validierung mit Whitelists. POST-Redirect-GET; `form.js` macht optional fetch. +- Fehler nach `storage/logs/mail.log`, nie an Besucher leaken. + +## 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. +- 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. + +## 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) +- [ ] `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) +- [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen +- [ ] `php -l` sauber; 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. diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/app/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/app/actions/contact-submit.php b/app/actions/contact-submit.php new file mode 100644 index 0000000..4fc06cb --- /dev/null +++ b/app/actions/contact-submit.php @@ -0,0 +1,92 @@ + $ok, 'error' => $error ?: null]); + } else { + header('Location: ' . url('') . ($ok ? '?sent=1' : '?error=' . $error) . '#kontakt', true, 303); + } + exit; +}; + +$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? '')); + +// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) --- +if ($field('website') !== '' || !form_token_valid($field('ft'))) { + $respond(true); +} + +// --- Validierung --- +$name = $field('name'); +$email = $field('email'); +$phone = $field('phone'); +$interest = $field('interest'); +$subject = $field('subject'); +$message = $field('message'); + +$interests = ['', 'Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges']; + +$valid = $name !== '' && mb_strlen($name) <= 200 + && filter_var($email, FILTER_VALIDATE_EMAIL) !== false + && mb_strlen($phone) <= 50 + && in_array($interest, $interests, true) + && $subject !== '' && mb_strlen($subject) <= 200 + && $message !== '' && mb_strlen($message) <= 5000 + && ($_POST['privacy'] ?? '') === '1' + && !preg_match('/[\r\n]/', $name . $subject); + +if (!$valid) { + $respond(false, 'validation'); +} + +// --- Versand --- +$smtp = config('smtp'); + +$body = "Kontaktanfrage über tsv08kulmbach.de\n" + . str_repeat('-', 40) . "\n" + . "Name: {$name}\n" + . "E-Mail: {$email}\n" + . ($phone !== '' ? "Telefon: {$phone}\n" : '') + . ($interest !== '' ? "Interesse: {$interest}\n" : '') + . "Betreff: {$subject}\n" + . str_repeat('-', 40) . "\n\n" + . $message . "\n\n" + . str_repeat('-', 40) . "\n" + . 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n"; + +try { + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $smtp['host']; + $mail->Port = (int) $smtp['port']; + $mail->SMTPAuth = true; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password']; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + + $mail->setFrom($smtp['from'], $smtp['from_name']); + $mail->addAddress($smtp['to']); + $mail->addReplyTo($email, $name); + $mail->Subject = 'Kontaktanfrage: ' . $subject; + $mail->Body = $body; + + $mail->send(); +} catch (Throwable $e) { + error_log('[' . date('c') . '] Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log'); + $respond(false, 'mail'); +} + +$respond(true); diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..a83a503 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,24 @@ + 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.', + 'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.', +]; +?> +
+
+
+

+

+

Oder direkt per Mail an

+
+
+
+ +

Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.

+ +

+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + +

+ +

+
+
+
diff --git a/app/components/cta.php b/app/components/cta.php new file mode 100644 index 0000000..0c532ea --- /dev/null +++ b/app/components/cta.php @@ -0,0 +1,10 @@ + + diff --git a/app/components/footer.php b/app/components/footer.php new file mode 100644 index 0000000..31fc622 --- /dev/null +++ b/app/components/footer.php @@ -0,0 +1,46 @@ + 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube']; +?> +
+ + +
diff --git a/app/components/header.php b/app/components/header.php new file mode 100644 index 0000000..ee7ba91 --- /dev/null +++ b/app/components/header.php @@ -0,0 +1,18 @@ + + diff --git a/app/components/hero.php b/app/components/hero.php new file mode 100644 index 0000000..e194571 --- /dev/null +++ b/app/components/hero.php @@ -0,0 +1,23 @@ + +
+ +
+

+

+
+ + $cta]); ?> + +
+
+
diff --git a/app/components/img.php b/app/components/img.php new file mode 100644 index 0000000..6f717dc --- /dev/null +++ b/app/components/img.php @@ -0,0 +1,41 @@ +-.jpg + * $sizes string (optional, default '100vw') + * $eager bool (optional) — above the fold: eager + fetchpriority high + * $class string (optional) + */ +$sizes = $sizes ?? '100vw'; +$eager = $eager ?? false; +$class = $class ?? ''; + +$widths = $image['widths']; +$largest = max($widths); +$srcset = implode(', ', array_map( + static fn (int $w): string => asset("{$image['base']}-{$w}.jpg") . " {$w}w", + $widths +)); + +// Maße der größten Variante für width/height (CLS-Vermeidung). +[$w, $h] = (function () use ($image, $largest): array { + static $dims = []; + $file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg"; + $key = $file; + if (!isset($dims[$key])) { + $size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0]; + $dims[$key] = [$size[0], $size[1]]; + } + return $dims[$key]; +})(); +?> + + src="" + srcset="" + sizes="" + alt="" + width="" height="" + > diff --git a/app/components/instagram-feed.php b/app/components/instagram-feed.php new file mode 100644 index 0000000..f1c8014 --- /dev/null +++ b/app/components/instagram-feed.php @@ -0,0 +1,41 @@ + +
+
+

Aktuelles

+ +

Unsere neuesten Beiträge auf Instagram (@).

+ + +
+

Was bei uns gerade los ist, siehst du auf unserem Instagram-Kanal.

+

Folge uns auf Instagram

+
+ +
+
diff --git a/app/components/jsonld.php b/app/components/jsonld.php new file mode 100644 index 0000000..d98c88d --- /dev/null +++ b/app/components/jsonld.php @@ -0,0 +1,31 @@ + 'https://schema.org', + '@type' => 'SportsClub', + 'name' => $club['name'], + 'alternateName' => $club['legal_name'], + 'foundingDate' => $club['founded'], + 'description' => $club['description'], + 'sport' => $club['departments'], + 'email' => $club['email'], + 'url' => abs_url(), + 'logo' => rtrim((string) config('base_url'), '/') . '/assets/img/logo.svg', + 'address' => [ + '@type' => 'PostalAddress', + 'streetAddress' => $club['address']['street'], + 'postalCode' => $club['address']['zip'], + 'addressLocality' => $club['address']['city'], + 'addressCountry' => $club['address']['country'], + ], + 'sameAs' => array_values(array_filter($club['social'])), +]; +?> + diff --git a/app/components/meta.php b/app/components/meta.php new file mode 100644 index 0000000..56151cb --- /dev/null +++ b/app/components/meta.php @@ -0,0 +1,52 @@ +-Inhalt. Props: $meta (array aus der Page), $current (Slug). + * $meta: title, description, og_image (optional, Pfad relativ zu assets/), + * title_absolute (bool — Title ohne Suffix), scripts (array zusätzlicher JS-Dateien). + */ +$club = json_load('club'); + +$title = $meta['title'] ?? $club['name']; +if (empty($meta['title_absolute'])) { + $title .= ' | ' . $club['name']; +} +$description = $meta['description'] ?? $club['description']; +$canonical = abs_url($current === '404' ? '' : $current); +$ogImage = rtrim((string) config('base_url'), '/') . '/assets/' . ltrim($meta['og_image'] ?? 'img/og-default.jpg', '/'); +$scripts = array_merge(['nav.js'], $meta['scripts'] ?? []); +?> + + + <?= e($title) ?> + + + + + + + + + + + + + + + + + + + + + + + + "> + + + + + diff --git a/app/components/nav.php b/app/components/nav.php new file mode 100644 index 0000000..d7fc461 --- /dev/null +++ b/app/components/nav.php @@ -0,0 +1,33 @@ + + diff --git a/app/components/partner-grid.php b/app/components/partner-grid.php new file mode 100644 index 0000000..c42f674 --- /dev/null +++ b/app/components/partner-grid.php @@ -0,0 +1,33 @@ + +
+
+

+

+
    + +
  • + + + <?= e($partner['name']) ?> + + + <?= e($partner['name']) ?> + +
  • + +
+ +

$intro['cta']]); ?>

+ +
+
diff --git a/app/components/section.php b/app/components/section.php new file mode 100644 index 0000000..bb7e9cb --- /dev/null +++ b/app/components/section.php @@ -0,0 +1,26 @@ + +
+
+
+ >> +

+ +

$section['cta']]); ?>

+ +
+
+ $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?> +
+
+
diff --git a/app/components/stats.php b/app/components/stats.php new file mode 100644 index 0000000..a7bd501 --- /dev/null +++ b/app/components/stats.php @@ -0,0 +1,20 @@ + +
+
+
    + +
  • + + +
  • + +
+
+
diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 0000000..cd907f8 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,121 @@ +.json) mit Request-weitem Cache. + * Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise. + */ +function json_load(string $name): array +{ + static $cache = []; + if (!array_key_exists($name, $cache)) { + $file = DATA_PATH . '/' . $name . '.json'; + if (!is_file($file)) { + return []; + } + $cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + } + return $cache[$name]; +} + +/** + * Komponente rendern: component('hero', ['title' => …]). + * Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes. + */ +function component(string $name, array $props = []): void +{ + extract($props, EXTR_SKIP); + require APP_PATH . '/components/' . $name . '.php'; +} + +/** + * Page-Datei ausführen: sie setzt $meta und emittiert ihren Body. + * Rückgabe: [$meta, $html]. + */ +function render_page(string $file): array +{ + $meta = []; + ob_start(); + require $file; + return [$meta, (string) ob_get_clean()]; +} + +/** + * Signierten Zeitstempel für die Formular-Time-Trap erzeugen. + */ +function form_token(): string +{ + $ts = (string) time(); + return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret')); +} + +/** + * Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max. + */ +function form_token_valid(string $token, int $min = 3, int $max = 7200): bool +{ + $parts = explode('.', $token); + if (count($parts) !== 2) { + return false; + } + [$ts, $sig] = $parts; + if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) { + return false; + } + $age = time() - (int) $ts; + return $age >= $min && $age <= $max; +} diff --git a/app/layout.php b/app/layout.php new file mode 100644 index 0000000..ee6054c --- /dev/null +++ b/app/layout.php @@ -0,0 +1,22 @@ + + + + + $meta, 'current' => $current]); ?> + + + + $current]); ?> +
+ +
+ + + diff --git a/app/pages/404.php b/app/pages/404.php new file mode 100644 index 0000000..f00bf19 --- /dev/null +++ b/app/pages/404.php @@ -0,0 +1,16 @@ + 'Seite nicht gefunden', + 'description' => 'Die angeforderte Seite existiert nicht.', +]; +?> +
+
+

404 – Seite nicht gefunden

+

Die angeforderte Seite gibt es nicht (mehr). Vielleicht hilft dir die Startseite weiter.

+

Zur Startseite

+
+
diff --git a/app/pages/home.php b/app/pages/home.php new file mode 100644 index 0000000..850defc --- /dev/null +++ b/app/pages/home.php @@ -0,0 +1,28 @@ + 'TSV 08 Kulmbach – Fußball & Turnen in Kulmbach', + 'title_absolute' => true, + 'description' => json_load('club')['description'], + 'scripts' => ['form.js'], +]; + +component('hero', ['hero' => $home['hero']]); +component('instagram-feed'); + +foreach ($home['sections'] as $section) { + component('section', ['section' => $section]); +} + +component('stats', ['stats' => $home['stats']]); +component('partner-grid', ['intro' => $home['partners']]); +component('contact-form', ['intro' => $home['contact']]); diff --git a/app/routes.php b/app/routes.php new file mode 100644 index 0000000..aa607cc --- /dev/null +++ b/app/routes.php @@ -0,0 +1,15 @@ + ['file' => Page-Datei in app/pages/, 'sitemap' => in sitemap.xml aufnehmen?] + * + * Neue Seite = Datei in app/pages/ + Eintrag hier (+ optional data/navigation.json). + * Canonical und Sitemap folgen automatisch. + */ +return [ + '' => ['file' => 'home.php', 'sitemap' => true], + '404' => ['file' => '404.php', 'sitemap' => false], +]; diff --git a/bin/.htaccess b/bin/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/bin/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/bin/img-resize.php b/bin/img-resize.php new file mode 100644 index 0000000..20097bd --- /dev/null +++ b/bin/img-resize.php @@ -0,0 +1,76 @@ + [,...] [qualität=78] + * → ziel-basis-.jpg je Breite + * + * Crop (exaktes Format, mittig beschnitten, z.B. OG-Image): + * php bin/img-resize.php --crop x [qualität=78] + */ +if (PHP_SAPI !== 'cli') { + exit(1); +} + +$args = array_slice($argv, 1); +$crop = false; +if (($args[0] ?? '') === '--crop') { + $crop = true; + array_shift($args); +} + +if (count($args) < 3) { + fwrite(STDERR, "Nutzung: siehe Datei-Kommentar\n"); + exit(1); +} + +[$src, $dest, $spec] = $args; +$quality = (int) ($args[3] ?? 78); + +$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) { + 'jpg', 'jpeg' => imagecreatefromjpeg($src), + 'png' => imagecreatefrompng($src), + 'webp' => imagecreatefromwebp($src), + default => null, +}; +if (!$image) { + fwrite(STDERR, "Kann Quelle nicht lesen: {$src}\n"); + exit(1); +} + +$srcW = imagesx($image); +$srcH = imagesy($image); + +if ($crop) { + [$w, $h] = array_map('intval', explode('x', $spec)); + $srcRatio = $srcW / $srcH; + $dstRatio = $w / $h; + if ($srcRatio > $dstRatio) { + $cropH = $srcH; + $cropW = (int) round($srcH * $dstRatio); + } else { + $cropW = $srcW; + $cropH = (int) round($srcW / $dstRatio); + } + $x = (int) (($srcW - $cropW) / 2); + $y = (int) (($srcH - $cropH) / 2); + $out = imagecreatetruecolor($w, $h); + imagecopyresampled($out, $image, 0, 0, $x, $y, $w, $h, $cropW, $cropH); + imagejpeg($out, $dest, $quality); + echo "{$dest} ({$w}x{$h})\n"; + exit(0); +} + +foreach (explode(',', $spec) as $width) { + $w = min((int) $width, $srcW); // nie hochskalieren + $h = (int) round($srcH * $w / $srcW); + $out = imagecreatetruecolor($w, $h); + imagecopyresampled($out, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH); + $file = "{$dest}-{$w}.jpg"; + imagejpeg($out, $file, $quality); + echo "{$file} ({$w}x{$h}, " . round(filesize($file) / 1024) . " KB)\n"; +} diff --git a/bin/instagram-sync.php b/bin/instagram-sync.php new file mode 100644 index 0000000..28c1fda --- /dev/null +++ b/bin/instagram-sync.php @@ -0,0 +1,214 @@ +> storage/logs/instagram.log 2>&1 + * + * Strategie-Kette (erster Erfolg gewinnt): + * 1. web_profile_info-API (JSON, inkl. der letzten 12 Posts) + * 2. Profil-HTML nach eingebettetem JSON parsen + */ +if (PHP_SAPI !== 'cli') { + exit(1); +} + +require dirname(__DIR__) . '/app/bootstrap.php'; + +$verbose = in_array('-v', $argv, true); +$username = (string) config('instagram.username', 'tsv08kulmbach'); +$maxPosts = (int) config('instagram.max_posts', 9); +$imgDir = PUBLIC_PATH . '/assets/img/instagram'; +$cacheFile = DATA_PATH . '/instagram.json'; + +$log = static function (string $msg) use ($verbose): void { + $line = '[' . date('c') . "] {$msg}\n"; + file_put_contents(STORAGE_PATH . '/logs/instagram.log', $line, FILE_APPEND); + if ($verbose) { + echo $line; + } +}; + +$fail = static function (string $msg) use ($log): never { + $log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet."); + exit(1); +}; + +// --- Lock gegen parallele Läufe --- +$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c'); +if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { + $fail('Läuft bereits (Lock belegt)'); +} + +// --- HTTP-Helfer mit Browser-typischen Headern --- +$fetch = static function (string $url, array $headers = []): string|false { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 20, + CURLOPT_ENCODING => '', + CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + CURLOPT_HTTPHEADER => array_merge(['Accept-Language: de-DE,de;q=0.9'], $headers), + ]); + $body = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + return ($body !== false && $status === 200) ? $body : false; +}; + +/** + * Posts aus der web_profile_info-JSON-Struktur ziehen. + * @return array> + */ +$extractPosts = static function (array $data) use ($maxPosts): array { + $edges = $data['data']['user']['edge_owner_to_timeline_media']['edges'] + ?? $data['graphql']['user']['edge_owner_to_timeline_media']['edges'] + ?? []; + $posts = []; + foreach (array_slice($edges, 0, $maxPosts) as $edge) { + $node = $edge['node'] ?? []; + if (empty($node['shortcode']) || empty($node['display_url'])) { + continue; + } + $caption = trim((string) ($node['edge_media_to_caption']['edges'][0]['node']['text'] ?? '')); + $posts[] = [ + 'shortcode' => (string) $node['shortcode'], + 'caption' => $caption, + 'taken_at' => (int) ($node['taken_at_timestamp'] ?? 0), + 'is_video' => (bool) ($node['is_video'] ?? false), + 'display_url' => (string) $node['display_url'], + ]; + } + return $posts; +}; + +// --- Strategie 1: web_profile_info-API --- +$posts = []; +$apiHeaders = [ + 'x-ig-app-id: 936619743392459', + 'Accept: application/json', + 'Referer: https://www.instagram.com/' . $username . '/', +]; +$body = $fetch("https://www.instagram.com/api/v1/users/web_profile_info/?username={$username}", $apiHeaders); +if ($body !== false) { + $data = json_decode($body, true); + if (is_array($data)) { + $posts = $extractPosts($data); + $log('Strategie 1 (web_profile_info): ' . count($posts) . ' Posts'); + } +} + +// --- Strategie 2: Profil-HTML nach eingebettetem JSON parsen --- +if ($posts === []) { + $html = $fetch("https://www.instagram.com/{$username}/"); + if ($html !== false) { + // Eingebettete JSON-Blöcke (script type application/json) nach Timeline-Daten durchsuchen. + if (preg_match_all('/