Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:16:25 +02:00
commit 0f19729a88
70 changed files with 2548 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -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

9
.htaccess Normal file
View File

@@ -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]

94
CLAUDE.md Normal file
View File

@@ -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 `<link>` 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/<slug>.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.

1
app/.htaccess Normal file
View File

@@ -0,0 +1 @@
Require all denied

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden.
* Antwort: PRG-Redirect zu /#kontakt (?sent=1 | ?error=…) oder JSON bei fetch (form.js).
*/
use PHPMailer\PHPMailer\PHPMailer;
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $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);

24
app/bootstrap.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
define('APP_PATH', __DIR__);
define('DATA_PATH', ROOT_PATH . '/data');
define('STORAGE_PATH', ROOT_PATH . '/storage');
define('PUBLIC_PATH', ROOT_PATH . '/public');
// Config laden — ohne config.php läuft die Seite mit der Vorlage weiter (Mailversand schlägt dann fehl).
$configFile = ROOT_PATH . '/config/config.php';
$GLOBALS['__config'] = require (is_file($configFile) ? $configFile : ROOT_PATH . '/config/config.example.php');
error_reporting(E_ALL);
ini_set('display_errors', ($GLOBALS['__config']['env'] ?? 'production') === 'development' ? '1' : '0');
ini_set('log_errors', '1');
ini_set('error_log', STORAGE_PATH . '/logs/php-errors.log');
require APP_PATH . '/helpers.php';
if (is_file(ROOT_PATH . '/vendor/autoload.php')) {
require ROOT_PATH . '/vendor/autoload.php';
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* Kontaktformular (Felder wie auf der alten Seite). Props:
* $intro array{title, text}
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
* Spam-Schutz: Honeypot "website" + signierter Timestamp "ft" (form_token()).
*/
$club = json_load('club');
$interests = ['Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
$errorMessages = [
'validation' => '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'] . '.',
];
?>
<section class="section contact" id="kontakt">
<div class="container contact__inner">
<div class="contact__intro">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
</div>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" data-form-status>
<?php if ($sent): ?>
<p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p>
<?php elseif ($error !== null): ?>
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
<?php endif; ?>
</div>
<div class="form__row">
<div class="form__field">
<label for="contact-name">Name *</label>
<input type="text" id="contact-name" name="name" required autocomplete="name" maxlength="200">
</div>
<div class="form__field">
<label for="contact-email">E-Mail *</label>
<input type="email" id="contact-email" name="email" required autocomplete="email" maxlength="200">
</div>
</div>
<div class="form__row">
<div class="form__field">
<label for="contact-phone">Telefonnummer</label>
<input type="tel" id="contact-phone" name="phone" autocomplete="tel" maxlength="50">
</div>
<div class="form__field">
<label for="contact-interest">Ich interessiere mich für</label>
<select id="contact-interest" name="interest">
<option value="">Bitte wählen</option>
<?php foreach ($interests as $interest): ?>
<option value="<?= e($interest) ?>"><?= e($interest) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form__field">
<label for="contact-subject">Betreff *</label>
<input type="text" id="contact-subject" name="subject" required maxlength="200">
</div>
<div class="form__field">
<label for="contact-message">Nachricht *</label>
<textarea id="contact-message" name="message" rows="6" required maxlength="5000"></textarea>
</div>
<div class="form__field form__field--checkbox">
<input type="checkbox" id="contact-privacy" name="privacy" value="1" required>
<label for="contact-privacy">Ich stimme den <a href="<?= e(url('datenschutz')) ?>">Datenschutzbestimmungen</a> zu. *</label>
</div>
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
<div class="visually-hidden" aria-hidden="true">
<label for="contact-website">Website (bitte leer lassen)</label>
<input type="text" id="contact-website" name="website" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<p class="form__submit">
<button class="btn" type="submit">Nachricht senden</button>
</p>
</form>
</div>
</section>

10
app/components/cta.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
/**
* CTA-Button. Props: $cta array{label: string, slug: string, style?: 'primary'|'outline'}
*/
$style = $cta['style'] ?? 'primary';
?>
<a class="btn<?= $style === 'outline' ? ' btn--outline' : '' ?>" href="<?= e(url($cta['slug'])) ?>"><?= e($cta['label']) ?></a>

46
app/components/footer.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Footer: Vereinsdaten (NAP aus club.json), Social-Links, Rechtliches.
*/
$club = json_load('club');
$nav = json_load('navigation');
$socialLabels = ['instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube'];
?>
<footer class="site-footer">
<div class="container site-footer__grid">
<div>
<h2 class="site-footer__heading"><?= e($club['legal_name']) ?></h2>
<address class="site-footer__address">
<?= e($club['address']['venue']) ?><br>
<?= e($club['address']['street']) ?><br>
<?= e($club['address']['zip']) ?> <?= e($club['address']['city']) ?><br>
<a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
</address>
</div>
<div>
<h2 class="site-footer__heading">Folge uns</h2>
<ul class="site-footer__list">
<?php foreach ($club['social'] as $key => $socialUrl): ?>
<?php if ($socialUrl): ?>
<li><a href="<?= e($socialUrl) ?>" rel="noopener"><?= e($socialLabels[$key] ?? ucfirst($key)) ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
</div>
<div>
<h2 class="site-footer__heading">Rechtliches</h2>
<ul class="site-footer__list">
<?php foreach ($nav['legal'] as $item): ?>
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<div class="container site-footer__copyright">
<p>&copy; <?= e(date('Y')) ?> <?= e($club['legal_name']) ?></p>
</div>
</footer>

18
app/components/header.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* Seitenkopf mit Logo und Navigation. Props: $current (Slug).
*/
$club = json_load('club');
?>
<header class="site-header">
<div class="container site-header__inner">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> Startseite">
<img class="site-header__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<span class="site-header__name"><?= e($club['name']) ?></span>
</a>
<?php component('nav', ['current' => $current]); ?>
</div>
</header>

23
app/components/hero.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Vollflächiger Hero mit Hintergrundbild, Titel, Intro und CTAs.
* Props: $hero (array aus home.json: title, text, image, ctas)
*/
?>
<section class="hero">
<div class="hero__media" aria-hidden="true">
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
</div>
<div class="container hero__content">
<h1 class="hero__title"><?= e($hero['title']) ?></h1>
<p class="hero__text"><?= e($hero['text']) ?></p>
<div class="hero__actions">
<?php foreach ($hero['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
</div>
</section>

41
app/components/img.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Responsives Bild aus Varianten-Set. Props:
* $image array{base: string, widths: int[], alt: string} — base relativ zu assets/, Dateien: <base>-<w>.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];
})();
?>
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>"
srcset="<?= e($srcset) ?>"
sizes="<?= e($sizes) ?>"
alt="<?= e($image['alt']) ?>"
width="<?= e($w) ?>" height="<?= e($h) ?>"
<?= $eager ? 'loading="eager" fetchpriority="high"' : 'loading="lazy" decoding="async"' ?>>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* "Aktuelles": Instagram-Grid aus dem lokalen Cache (data/instagram.json,
* geschrieben von bin/instagram-sync.php). Besucher laden ausschließlich
* lokal gehostete Bilder. Ohne Cache: CTA-Karte als Degradation.
*/
$club = json_load('club');
$feed = json_load('instagram');
$posts = $feed['posts'] ?? [];
$maxPosts = (int) config('instagram.max_posts', 9);
?>
<section class="section instagram" id="aktuelles">
<div class="container">
<h2>Aktuelles</h2>
<?php if ($posts !== []): ?>
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a>.</p>
<ul class="instagram__grid">
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
<li class="instagram__item">
<a class="instagram__link" href="<?= e($post['url']) ?>" rel="noopener">
<img src="<?= e(asset($post['image'])) ?>"
alt="<?= e($post['alt'] ?? 'Instagram-Beitrag des TSV 08 Kulmbach') ?>"
width="640" height="640" loading="lazy" decoding="async">
<?php if (!empty($post['is_video'])): ?>
<span class="instagram__badge" aria-hidden="true">▶</span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<div class="instagram__fallback">
<p>Was bei uns gerade los ist, siehst du auf unserem Instagram-Kanal.</p>
<p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
</div>
<?php endif; ?>
</div>
</section>

31
app/components/jsonld.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SportsClub-Schema aus data/club.json — auf jeder Seite identisch (NAP-Konsistenz für SEO/GEO).
*/
$club = json_load('club');
$schema = [
'@context' => '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'])),
];
?>
<script type="application/ld+json"><?= json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>

52
app/components/meta.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* <head>-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'] ?? []);
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>">
<?php if ($current !== '404'): ?>
<link rel="canonical" href="<?= e($canonical) ?>">
<?php else: ?>
<meta name="robots" content="noindex">
<?php endif; ?>
<meta name="theme-color" content="#222222">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?= e($club['name']) ?>">
<meta property="og:title" content="<?= e($title) ?>">
<meta property="og:description" content="<?= e($description) ?>">
<meta property="og:url" content="<?= e($canonical) ?>">
<meta property="og:image" content="<?= e($ogImage) ?>">
<meta property="og:locale" content="de_DE">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="<?= e(asset('img/logo.svg')) ?>" type="image/svg+xml">
<?php foreach (['fonts/coolvetica.woff2', 'fonts/abel.woff2'] as $font): ?>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $font)): ?>
<link rel="preload" href="<?= e(asset($font)) ?>" as="font" type="font/woff2" crossorigin>
<?php endif; ?>
<?php endforeach; ?>
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>
<link rel="stylesheet" href="<?= e(asset("css/{$css}.css")) ?>">
<?php endforeach; ?>
<?php foreach ($scripts as $js): ?>
<script defer src="<?= e(asset("js/{$js}")) ?>"></script>
<?php endforeach; ?>
<?php component('jsonld'); ?>

33
app/components/nav.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* Hauptnavigation aus data/navigation.json. Props: $current (Slug).
* Burger-Toggle übernimmt assets/js/nav.js (aria-expanded/aria-controls).
*/
$nav = json_load('navigation');
$href = static function (string $slug): string {
// Anker-Slugs ("#aktuelles") zeigen auf Startseiten-Sektionen.
return str_starts_with($slug, '#') ? url() . $slug : url($slug);
};
?>
<nav class="site-nav" aria-label="Hauptnavigation">
<button class="site-nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav-list">
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
<span class="visually-hidden">Menü öffnen</span>
</button>
<ul class="site-nav__list" id="site-nav-list">
<?php foreach ($nav['main'] as $item): ?>
<li>
<a href="<?= e($href($item['slug'])) ?>"<?= $item['slug'] === $current ? ' aria-current="page"' : '' ?>><?= e($item['label']) ?></a>
</li>
<?php endforeach; ?>
<?php if (!empty($nav['cta'])): ?>
<li>
<a class="btn" href="<?= e($href($nav['cta']['slug'])) ?>"><?= e($nav['cta']['label']) ?></a>
</li>
<?php endif; ?>
</ul>
</nav>

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* Sponsoren/Partner-Logogrid. Props:
* $intro array{title, text, cta?} (aus home.json)
* Logos kommen aus data/partners.json (Single Source of Truth).
*/
$partners = json_load('partners')['partners'] ?? [];
?>
<section class="section partners" id="partner">
<div class="container">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<ul class="partners__grid">
<?php foreach ($partners as $partner): ?>
<li class="partners__item">
<?php if (!empty($partner['url'])): ?>
<a href="<?= e($partner['url']) ?>" rel="noopener">
<img src="<?= e(asset($partner['logo'])) ?>" alt="<?= e($partner['name']) ?>" loading="lazy" decoding="async">
</a>
<?php else: ?>
<img src="<?= e(asset($partner['logo'])) ?>" alt="<?= e($partner['name']) ?>" loading="lazy" decoding="async">
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if (!empty($intro['cta'])): ?>
<p><?php component('cta', ['cta' => $intro['cta']]); ?></p>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* Alternierende Bild/Text-Sektion. Props:
* $section array{id, title, text, image, flip?, cta?} (aus home.json o.ä.)
* $level int Überschriften-Ebene (default 2)
*/
$level = $level ?? 2;
$flip = !empty($section['flip']);
?>
<section class="section split<?= $flip ? ' split--flip' : '' ?>" id="<?= e($section['id']) ?>">
<div class="container split__inner">
<div class="split__text">
<h<?= $level ?>><?= e($section['title']) ?></h<?= $level ?>>
<p><?= e($section['text']) ?></p>
<?php if (!empty($section['cta'])): ?>
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
<?php endif; ?>
</div>
<div class="split__media">
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
</div>
</div>
</section>

20
app/components/stats.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Kennzahlen-Band. Props: $stats array{items: array{value, label}[]}
*/
?>
<section class="section stats" aria-label="Der Verein in Zahlen">
<div class="container">
<ul class="stats__list">
<?php foreach ($stats['items'] as $item): ?>
<li class="stats__item">
<span class="stats__value"><?= e($item['value']) ?></span>
<span class="stats__label"><?= e($item['label']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>

121
app/helpers.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
*/
function config(?string $key = null, mixed $default = null): mixed
{
$value = $GLOBALS['__config'];
if ($key === null) {
return $value;
}
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
/**
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
*/
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/**
* Interner Link aus Slug: url('fussball') → '/fussball', url('') → '/'.
*/
function url(string $slug = ''): string
{
return '/' . trim($slug, '/');
}
/**
* Absolute URL für Canonical, OG und Sitemap.
*/
function abs_url(string $slug = ''): string
{
return rtrim((string) config('base_url'), '/') . url($slug);
}
/**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
*/
function asset(string $path): string
{
$path = ltrim($path, '/');
$file = PUBLIC_PATH . '/assets/' . $path;
$version = is_file($file) ? (string) filemtime($file) : '0';
return '/assets/' . $path . '?v=' . $version;
}
/**
* JSON-Datendatei laden (data/<name>.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;
}

22
app/layout.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Master-Shell. Erwartet: $meta (array), $content (string), $current (Slug der aktiven Route).
*/
?>
<!doctype html>
<html lang="de">
<head>
<?php component('meta', ['meta' => $meta, 'current' => $current]); ?>
</head>
<body>
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<?php component('header', ['current' => $current]); ?>
<main id="main">
<?= $content ?>
</main>
<?php component('footer'); ?>
</body>
</html>

16
app/pages/404.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
$meta = [
'title' => 'Seite nicht gefunden',
'description' => 'Die angeforderte Seite existiert nicht.',
];
?>
<section class="section section--center">
<div class="container">
<h1>404 Seite nicht gefunden</h1>
<p>Die angeforderte Seite gibt es nicht (mehr). Vielleicht hilft dir die Startseite weiter.</p>
<p><a class="btn" href="<?= e(url()) ?>">Zur Startseite</a></p>
</div>
</section>

28
app/pages/home.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* Startseite — Sektions-Reihenfolge wie auf der alten Seite:
* Hero → Aktuelles (Instagram) → Historie → Sportheim → Zahlen → Partner → Kontakt.
* Inhalte: data/home.json (Single Source of Truth).
*/
$home = json_load('home');
$meta = [
'title' => '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']]);

15
app/routes.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
/**
* EINZIGES Slug-Register der Seite (siehe CLAUDE.md).
* Slug => ['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],
];

1
bin/.htaccess Normal file
View File

@@ -0,0 +1 @@
Require all denied

76
bin/img-resize.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Responsive Bild-Varianten erzeugen (GD, läuft auch auf Shared Hosting).
*
* Resize (proportional auf Zielbreiten):
* php bin/img-resize.php <quelle> <ziel-basis> <breite>[,<breite>...] [qualität=78]
* → ziel-basis-<breite>.jpg je Breite
*
* Crop (exaktes Format, mittig beschnitten, z.B. OG-Image):
* php bin/img-resize.php --crop <quelle> <ziel.jpg> <breite>x<höhe> [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";
}

214
bin/instagram-sync.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
/**
* Instagram-Sync (Juicer-Ersatz): holt die letzten Beiträge des öffentlichen
* Profils, lädt die Bilder lokal nach public/assets/img/instagram/ und schreibt
* data/instagram.json atomar. Bei JEDEM Fehler bleibt der letzte gute Cache
* unangetastet — die Website degradiert nur, sie bricht nie.
*
* Aufruf: php bin/instagram-sync.php [-v]
* Cron: 17 6,18 * * * cd /pfad/zur/site && /usr/bin/php bin/instagram-sync.php >> 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<int, array<string, mixed>>
*/
$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('/<script type="application\/json"[^>]*>(.*?)<\/script>/s', $html, $m)) {
foreach ($m[1] as $jsonBlob) {
if (!str_contains($jsonBlob, 'edge_owner_to_timeline_media')) {
continue;
}
$data = json_decode($jsonBlob, true);
if (!is_array($data)) {
continue;
}
// Struktur variiert — rekursiv nach edge_owner_to_timeline_media suchen.
$stack = [$data];
while ($stack !== [] && $posts === []) {
$item = array_pop($stack);
if (isset($item['edge_owner_to_timeline_media'])) {
$posts = $extractPosts(['data' => ['user' => $item]]);
break;
}
foreach ($item as $value) {
if (is_array($value)) {
$stack[] = $value;
}
}
}
if ($posts !== []) {
break;
}
}
}
$log('Strategie 2 (HTML-Parsing): ' . count($posts) . ' Posts');
}
}
if ($posts === []) {
$fail('Keine Posts ermittelbar (Instagram blockt vermutlich die Server-IP oder hat die Struktur geändert)');
}
// --- Bilder lokal laden, auf 640px-Quadrat bringen ---
if (!is_dir($imgDir)) {
mkdir($imgDir, 0755, true);
}
$result = [];
foreach ($posts as $post) {
$file = "{$imgDir}/{$post['shortcode']}.jpg";
if (!is_file($file)) {
$img = $fetch($post['display_url']);
if ($img === false) {
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen");
continue;
}
$src = imagecreatefromstring($img);
if ($src === false) {
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen");
continue;
}
$w = imagesx($src);
$h = imagesy($src);
$side = min($w, $h);
$out = imagecreatetruecolor(640, 640);
imagecopyresampled($out, $src, 0, 0, (int) (($w - $side) / 2), (int) (($h - $side) / 2), 640, 640, $side, $side);
imagejpeg($out, $file, 80);
}
// Alt-Text aus der Caption: erste Zeile, gekürzt.
$alt = trim(strtok($post['caption'], "\n") ?: '');
$alt = $alt !== '' ? mb_substr($alt, 0, 120) : 'Instagram-Beitrag des TSV 08 Kulmbach';
$result[] = [
'shortcode' => $post['shortcode'],
'caption' => $post['caption'],
'alt' => $alt,
'taken_at' => $post['taken_at'],
'is_video' => $post['is_video'],
'image' => "img/instagram/{$post['shortcode']}.jpg",
'url' => "https://www.instagram.com/p/{$post['shortcode']}/",
];
}
if ($result === []) {
$fail('Posts gefunden, aber kein einziges Bild ladbar');
}
// --- Nicht mehr aktuelle Bilder aufräumen ---
$keep = array_map(static fn (array $p): string => "{$p['shortcode']}.jpg", $result);
foreach (glob("{$imgDir}/*.jpg") ?: [] as $existing) {
if (!in_array(basename($existing), $keep, true)) {
unlink($existing);
}
}
// --- Cache atomar schreiben (tmp + rename) ---
$payload = json_encode([
'fetched_at' => time(),
'username' => $username,
'posts' => $result,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $payload);
rename($tmp, $cacheFile);
$log('OK: ' . count($result) . ' Posts gecached, Bilder lokal in assets/img/instagram/');

15
composer.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "tsv08kulmbach/website",
"description": "Website des TSV 1908 Kulmbach e. V. — natives HTML/CSS/JS, Vanilla PHP",
"license": "proprietary",
"require": {
"php": "^8.1",
"phpmailer/phpmailer": "^7.1"
},
"config": {
"platform": {
"php": "8.1.0"
},
"optimize-autoloader": true
}
}

106
composer.lock generated Normal file
View File

@@ -0,0 +1,106 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "eaae188d87dee61018b8b2f29302b977",
"packages": [
{
"name": "phpmailer/phpmailer",
"version": "v7.1.1",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^10.0.0@dev",
"squizlabs/php_codesniffer": "^3.13.5",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
},
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"time": "2026-05-18T08:06:14+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.1"
},
"platform-dev": {},
"platform-overrides": {
"php": "8.1.0"
},
"plugin-api-version": "2.6.0"
}

1
config/.htaccess Normal file
View File

@@ -0,0 +1 @@
Require all denied

38
config/config.example.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
/**
* Vorlage für config/config.php — kopieren und Werte eintragen:
* cp config/config.example.php config/config.php
*
* config.php ist gitignored und liegt außerhalb des Webroots (zusätzlich per .htaccess gesperrt).
*/
return [
// Kanonische Basis-URL ohne trailing slash (für Canonical, OG, Sitemap).
'base_url' => 'https://www.tsv08kulmbach.de',
// 'production' oder 'development' (steuert Fehleranzeige).
'env' => 'production',
// Geheimer Schlüssel für die HMAC-Time-Trap der Formulare.
// Generieren mit: php -r "echo bin2hex(random_bytes(32));"
'app_secret' => 'CHANGE_ME',
// Brevo SMTP (Login + SMTP-Schlüssel aus dem Brevo-Dashboard, nicht das Konto-Passwort).
'smtp' => [
'host' => 'smtp-relay.brevo.com',
'port' => 587, // STARTTLS
'username' => 'BREVO_LOGIN',
'password' => 'BREVO_SMTP_KEY',
// Absender muss in Brevo als Sender verifiziert sein.
'from' => 'info@tsv08kulmbach.de',
'from_name' => 'TSV 08 Kulmbach',
// Empfänger der Formular-Mails.
'to' => 'info@tsv08kulmbach.de',
],
// Instagram-Scraper (bin/instagram-sync.php).
'instagram' => [
'username' => 'tsv08kulmbach',
'max_posts' => 9,
],
];

1
data/.htaccess Normal file
View File

@@ -0,0 +1 @@
Require all denied

26
data/club.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "TSV 08 Kulmbach",
"legal_name": "TSV 1908 Kulmbach e. V.",
"founded": "1908",
"description": "TSV 08 Kulmbach bietet dir in Kulmbach in den Bereichen Fußball und Turnen die Möglichkeit aktiv zu werden.",
"departments": ["Fußball", "Turnen", "Trampolin"],
"address": {
"venue": "Hans-Rausch-Sportanlage",
"street": "Thurnauer Str. 51",
"zip": "95326",
"city": "Kulmbach",
"country": "DE"
},
"email": "info@tsv08kulmbach.de",
"email_turnen": "turnabteilung.tsv08kulmbach@gmail.com",
"memberships": [
"Bayerischer Landes-Sportverband",
"Bayerischer Fußball-Verband",
"Bayerischer Turnverband"
],
"social": {
"instagram": "https://www.instagram.com/tsv08kulmbach/",
"facebook": null,
"youtube": null
}
}

57
data/home.json Normal file
View File

@@ -0,0 +1,57 @@
{
"hero": {
"title": "TSV 08 Kulmbach",
"text": "Egal ob Fußball oder Turnen wir sind als Kulmbacher Sportverein deine Möglichkeit, im Landkreis aktiv zu werden! Wir sind stolz, dir auf unserer Website einen Eindruck geben zu können, was dich bei uns erwartet. Du willst selbst aktiv werden? Dann zögere nicht, uns zu kontaktieren.",
"image": {
"base": "img/hero/hero",
"widths": [640, 1000, 1600],
"alt": "Fußballplatz des TSV 08 Kulmbach im Katzbachtal mit Vereinsfahnen"
},
"ctas": [
{ "label": "Jugendfußball", "slug": "jugendfussball", "style": "primary" },
{ "label": "Mitglied werden", "slug": "mitglied-werden", "style": "outline" }
]
},
"sections": [
{
"id": "historie",
"title": "Damals wie Heute: entdecke den Katzbachtaler Weg",
"text": "Seit über 100 Jahren lebt der TSV 08 Kulmbach den Sportgedanken in Kulmbach. Dabei entwickelte sich der Verein vom Standort an der Bayreuther Straße hin ins Katzbachtal, wo heutzutage sowohl die Fußball- als auch die Turnabteilung zuhause ist.",
"image": {
"base": "img/pages/vereinshistorie",
"widths": [519],
"alt": "Historische Luftaufnahme der Sportanlage an der Bayreuther Straße"
},
"flip": false,
"cta": { "label": "Zur Vereinshistorie", "slug": "historie", "style": "primary" }
},
{
"id": "sportheim",
"title": "Feiern Sie Veranstaltungen im Katzbachtal",
"text": "Unser Sportheim ist die perfekte Location für Veranstaltungen wie Geburtstage, betriebliche Feiern und Partys. Sie suchen noch einen Veranstaltungsort in Kulmbach? Zögern Sie nicht, unsere Räumlichkeiten im Katzbachtal anzufragen.",
"image": {
"base": "img/pages/sportheim",
"widths": [640, 1000],
"alt": "Sportheim des TSV 08 Kulmbach im Katzbachtal"
},
"flip": true,
"cta": { "label": "Zur Buchungsübersicht", "slug": "sportheim", "style": "primary" }
}
],
"stats": {
"items": [
{ "value": "650+", "label": "Mitglieder" },
{ "value": "10+", "label": "Mannschaften" },
{ "value": "20+", "label": "Partner" }
]
},
"partners": {
"title": "Unsere Sponsoren & Partner",
"text": "Wir sind stolz darauf, mit vielen Kulmbacher Unternehmen und Partnern regelmäßig zusammenzuarbeiten und so aus Ideen handfeste Projekte umzusetzen. Hier seht ihr eine Übersicht über unsere aktuellen Sponsoren und Partner.",
"cta": { "label": "Partner werden", "slug": "kontakt", "style": "outline" }
},
"contact": {
"title": "Kontaktiere uns gern!",
"text": "Du hast Fragen zu unseren Abteilungen, möchtest selbst aktiv werden oder uns unterstützen? Schreib uns wir melden uns so schnell wie möglich."
}
}

17
data/navigation.json Normal file
View File

@@ -0,0 +1,17 @@
{
"main": [
{ "label": "Startseite", "slug": "" },
{ "label": "Aktuelles", "slug": "#aktuelles" },
{ "label": "Jugendfußball", "slug": "jugendfussball" },
{ "label": "Fußball", "slug": "fussball" },
{ "label": "Turnen", "slug": "turnen" },
{ "label": "Der Verein", "slug": "der-verein" },
{ "label": "Matchcenter", "slug": "matchcenter" },
{ "label": "Kontakt", "slug": "kontakt" }
],
"cta": { "label": "Mitglied werden", "slug": "mitglied-werden" },
"legal": [
{ "label": "Impressum", "slug": "impressum" },
{ "label": "Datenschutz", "slug": "datenschutz" }
]
}

7
data/partners.json Normal file
View File

@@ -0,0 +1,7 @@
{
"partners": [
{ "name": "Kulmbacher Brauerei", "logo": "img/partners/kulmbacher.png", "url": null },
{ "name": "Downstairs", "logo": "img/partners/downstairs.png", "url": null },
{ "name": "Richter & Stegner Steuerungstechnik", "logo": "img/partners/rist.png", "url": null }
]
}

39
public/.htaccess Normal file
View File

@@ -0,0 +1,39 @@
# --- Front Controller ---
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
# --- Security-Header ---
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
# Streng möglich, weil ausnahmslos alles self-hosted ist (CLAUDE.md Regel 1).
Header always set Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
</IfModule>
# --- Kompression ---
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json image/svg+xml
</IfModule>
# --- Caching: Assets 1 Jahr (Busting via ?v=filemtime), HTML nie ---
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
ExpiresByType video/mp4 "access plus 1 year"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
AddType font/woff2 .woff2
AddDefaultCharset utf-8
Options -Indexes

106
public/assets/css/base.css Normal file
View File

@@ -0,0 +1,106 @@
/* ============================================================
BASE — Fonts, Typografie, Links, Fokus, Skip-Link
============================================================ */
@font-face {
font-family: 'Coolvetica';
src: url('../fonts/coolvetica.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Abel';
src: url('../fonts/abel.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
body {
font-family: var(--ff-body);
font-size: var(--fs-600);
font-weight: var(--fw-regular);
line-height: var(--lh-body);
color: var(--clr-text);
background-color: var(--clr-bg);
background-image: url('../img/background.jpg');
background-size: 100% auto;
background-attachment: fixed;
}
@media (max-width: 768px) {
body {
/* fixed-Attachment ruckelt auf Mobile */
background-attachment: scroll;
background-size: auto;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--ff-heading);
font-weight: var(--fw-regular);
line-height: var(--lh-heading);
text-transform: uppercase;
text-wrap: balance;
}
h1 { font-size: var(--fs-900); }
h2 { font-size: var(--fs-800); }
h3 { font-size: var(--fs-700); }
h4 { font-size: 1.625rem; }
h5,
h6 { font-size: var(--fs-600); }
p {
max-width: 70ch;
}
a {
color: inherit;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--clr-text-muted);
}
:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 3px;
border-radius: 2px;
}
::selection {
background: var(--clr-accent);
color: var(--clr-text);
}
.skip-link {
position: absolute;
top: -100%;
left: 1rem;
z-index: 100;
padding: 0.75em 1.25em;
background: var(--clr-accent);
color: var(--clr-text);
text-decoration: none;
border-radius: 0 0 var(--radius-btn) var(--radius-btn);
}
.skip-link:focus-visible {
top: 0;
color: var(--clr-text);
}
/* Scrollbar dezent dunkel */
html {
scrollbar-width: thin;
scrollbar-color: var(--clr-bg-corporate) transparent;
}

View File

@@ -0,0 +1,341 @@
/* ============================================================
COMPONENTS — eine Sektion pro Komponente, Banner-Kommentare
============================================================ */
/* === hero === */
.hero {
position: relative;
display: grid;
align-items: end;
min-height: min(92vh, 900px);
padding-block: calc(var(--header-h) + 80px) var(--space-section);
overflow: hidden;
}
.hero__media {
position: absolute;
inset: 0;
z-index: -1;
}
.hero__img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Dunkler Verlauf wie auf der alten Seite: oben transparent → unten Seitenhintergrund */
.hero__media::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgb(20 20 20 / 0.25) 0%, rgb(20 20 20 / 0.55) 55%, var(--clr-bg) 100%);
}
.hero__content {
width: 100%;
}
.hero__title {
text-shadow: 0 2px 24px rgb(0 0 0 / 0.5);
}
.hero__text {
margin-top: 1rem;
max-width: 60ch;
text-shadow: 0 1px 12px rgb(0 0 0 / 0.6);
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.75rem;
}
/* === instagram === */
.instagram__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-top: 2rem;
}
@media (max-width: 600px) {
.instagram__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.instagram__link {
position: relative;
display: block;
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.instagram__link img {
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
transition: transform var(--transition);
}
.instagram__link:hover img,
.instagram__link:focus-visible img {
transform: scale(1.05);
}
.instagram__badge {
position: absolute;
top: 0.75rem;
right: 0.75rem;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
background: rgb(0 0 0 / 0.6);
border-radius: 50%;
font-size: 0.8rem;
color: var(--clr-text);
}
.instagram__fallback {
margin-top: 1.5rem;
padding: 2.5rem;
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
text-align: center;
}
.instagram__fallback p {
margin-inline: auto;
}
.instagram__fallback .btn {
margin-top: 1rem;
}
/* === split (Bild/Text-Sektion) === */
.split__inner {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: 3rem;
}
.split--flip .split__text {
order: 2;
}
.split--flip .split__media {
order: 1;
}
.split__text p {
margin-top: 1rem;
}
.split__cta {
margin-top: 1.5rem;
}
.split__img {
width: 100%;
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
@media (max-width: 992px) {
.split__inner {
grid-template-columns: 1fr;
gap: 2rem;
}
.split--flip .split__text {
order: 0;
}
.split--flip .split__media {
order: 0;
}
}
/* === stats === */
.stats {
background: var(--clr-bg-deep);
}
.stats__list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 2rem;
text-align: center;
}
@media (max-width: 600px) {
.stats__list {
grid-template-columns: 1fr;
}
}
.stats__item {
display: grid;
gap: 0.25rem;
}
.stats__value {
font-family: var(--ff-heading);
font-size: var(--fs-800);
line-height: var(--lh-heading);
color: var(--clr-accent);
text-shadow: 0 0 30px rgb(var(--clr-accent-rgb) / 0.35);
}
.stats__label {
font-size: var(--fs-500);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--clr-text-muted);
}
/* === partners === */
.partners__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 220px));
justify-content: center;
align-items: center;
gap: 2rem;
margin-block: 2.5rem;
}
.partners__item {
display: grid;
place-items: center;
padding: 1.5rem;
/* Dunkles Glas — die Partner-Logos sind weiße Varianten */
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
min-height: 120px;
}
.partners__item img {
max-height: 80px;
width: auto;
max-width: 100%;
object-fit: contain;
}
/* === contact form === */
.contact__inner {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
gap: 3rem;
align-items: start;
}
@media (max-width: 992px) {
.contact__inner {
grid-template-columns: 1fr;
}
}
.contact__intro p {
margin-top: 1rem;
}
.form {
display: grid;
gap: 1.25rem;
padding: 2rem;
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
}
.form__row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.25rem;
}
@media (max-width: 600px) {
.form__row {
grid-template-columns: 1fr;
}
}
.form__field {
display: grid;
gap: 0.4rem;
}
.form__field label {
font-size: var(--fs-500);
}
.form__field input,
.form__field select,
.form__field textarea {
width: 100%;
padding: 10px 12px;
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-btn);
color: var(--clr-text);
}
.form__field select option {
background: var(--clr-bg);
color: var(--clr-text);
}
.form__field textarea {
resize: vertical;
min-height: 8rem;
}
.form__field--checkbox {
grid-template-columns: auto 1fr;
align-items: start;
gap: 0.6rem;
}
.form__field--checkbox input {
width: 1.25rem;
height: 1.25rem;
margin-top: 0.2rem;
accent-color: var(--clr-accent);
}
.form__field--checkbox label {
font-size: var(--fs-400);
color: var(--clr-text-muted);
}
.form__status:empty {
display: none;
}
.form__success {
padding: 0.75rem 1rem;
border: 1px solid var(--clr-success);
border-radius: var(--radius-btn);
color: var(--clr-success);
}
.form__error {
padding: 0.75rem 1rem;
border: 1px solid var(--clr-error);
border-radius: var(--radius-btn);
color: var(--clr-error);
}
.form__submit {
margin: 0;
}

View File

@@ -0,0 +1,218 @@
/* ============================================================
LAYOUT — Container, Header, Navigation, Footer, Sektionen
============================================================ */
.container {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-pad);
}
.section {
padding-block: var(--space-section);
}
.section--center {
text-align: center;
}
.section--center p {
margin-inline: auto;
}
/* --- Header --- */
.site-header {
position: absolute;
inset: 20px 0 auto 0;
z-index: 10;
}
.site-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
min-height: var(--header-h);
}
.site-header__brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--clr-text);
}
.site-header__logo {
height: var(--logo-h);
width: auto;
filter: drop-shadow(var(--shadow));
}
.site-header__name {
font-family: var(--ff-heading);
font-size: var(--fs-500);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* --- Navigation --- */
.site-nav__toggle {
display: none;
}
.site-nav__list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1.5rem;
font-size: var(--fs-500);
}
.site-nav__list a:not(.btn) {
color: var(--clr-text);
text-decoration: none;
padding-block: 0.25em;
border-bottom: 2px solid transparent;
transition: border-color var(--transition);
}
.site-nav__list a:not(.btn):hover,
.site-nav__list a:not(.btn)[aria-current="page"] {
border-bottom-color: var(--clr-accent);
}
@media (max-width: 1249px) {
.site-nav__toggle {
display: grid;
place-items: center;
width: 48px;
height: 48px;
background: transparent;
border: 0;
cursor: pointer;
}
/* Burger: Mittelbalken + Pseudo-Balken, animiert zum X */
.site-nav__toggle-bar,
.site-nav__toggle-bar::before,
.site-nav__toggle-bar::after {
display: block;
width: 28px;
height: 3px;
background: var(--clr-text);
border-radius: 2px;
transition: transform var(--transition), opacity var(--transition);
}
.site-nav__toggle-bar {
position: relative;
}
.site-nav__toggle-bar::before,
.site-nav__toggle-bar::after {
content: '';
position: absolute;
left: 0;
}
.site-nav__toggle-bar::before { top: -9px; }
.site-nav__toggle-bar::after { top: 9px; }
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar {
background: transparent;
}
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar::before {
transform: translateY(9px) rotate(45deg);
}
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar::after {
transform: translateY(-9px) rotate(-45deg);
}
.site-nav__list {
position: fixed;
inset: 0;
z-index: 20;
flex-direction: column;
justify-content: center;
gap: 1.75rem;
background: rgb(0 0 0 / 0.91);
font-size: var(--fs-700);
visibility: hidden;
opacity: 0;
transition: opacity var(--transition), visibility var(--transition);
}
.site-nav__list a:not(.btn) {
font-family: var(--ff-heading);
text-transform: uppercase;
}
body.nav-open .site-nav__list {
visibility: visible;
opacity: 1;
}
body.nav-open {
overflow: hidden;
}
/* Toggle über dem Overlay halten */
.site-nav__toggle {
position: relative;
z-index: 30;
}
}
/* --- Footer --- */
.site-footer {
margin-top: var(--space-section);
padding: 70px 0 25px;
background: var(--clr-bg-footer);
font-size: var(--fs-400);
}
.site-footer__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2.5rem;
}
@media (max-width: 768px) {
.site-footer__grid {
grid-template-columns: 1fr;
}
}
.site-footer__heading {
font-size: var(--fs-600);
margin-bottom: 0.75rem;
}
.site-footer__address {
font-style: normal;
color: var(--clr-text-muted);
}
.site-footer__list {
display: grid;
gap: 0.4rem;
}
.site-footer__list a,
.site-footer__address a {
color: var(--clr-text-muted);
}
.site-footer__list a:hover,
.site-footer__address a:hover {
color: var(--clr-text);
}
.site-footer__copyright {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--clr-glass-border);
color: var(--clr-text-faint);
}

View File

@@ -0,0 +1,66 @@
/* ============================================================
RESET — moderner Minimal-Reset + reduced-motion Kill-Switch
============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
}
img,
picture,
video,
svg {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
ul[class],
ol[class] {
list-style: none;
padding: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
/* Sektion-Anker landen nicht unter dem Header */
[id] {
scroll-margin-top: calc(var(--header-h) + 60px);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,58 @@
/* ============================================================
DESIGN-TOKENS — EINZIGE Quelle für Farben, Typo, Spacing etc.
Werte extrahiert und verifiziert aus der alten Seite
(CSS bc-style + DB main_layout). Siehe CLAUDE.md.
============================================================ */
:root {
/* --- Farben --- */
--clr-accent: #e20612; /* Marken-Rot (alte DB: primary_accent) */
--clr-accent-dark: #b00510; /* Hover/Active auf Rot */
--clr-accent-rgb: 226 6 18; /* für rgb(var(--clr-accent-rgb) / α) */
--clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */
--clr-bg-deep: #161616; /* abgesetzte Sektionen */
--clr-bg-footer: #000000;
--clr-bg-corporate: #383838;
--clr-bg-light: #f5f5f5;
--clr-text: #ffffff;
--clr-text-muted: #b3b3b3; /* 7.7:1 auf --clr-bg */
--clr-text-faint: #8c8c8c; /* 4.5:1 auf --clr-bg (AA) */
--clr-glass-bg: rgb(255 255 255 / 0.11);
--clr-glass-border: rgb(255 255 255 / 0.22);
--clr-error: #ff8a8a; /* Formularfehler, lesbar auf dunkel */
--clr-success: #8ce99a;
/* --- Typografie --- */
--ff-heading: 'Coolvetica', 'Arial Narrow', system-ui, sans-serif;
--ff-body: 'Abel', 'Helvetica Neue', system-ui, sans-serif;
--fs-300: 0.8125rem; /* 13px Buttons klein */
--fs-400: 0.9375rem; /* 15px Footer, Meta */
--fs-500: 1.125rem; /* 18px Navigation */
--fs-600: 1.3125rem; /* 21px Body */
--fs-700: clamp(2rem, 1.2rem + 3.5vw, 3.125rem); /* 50px h3 */
--fs-800: clamp(2.25rem, 1.3rem + 4.5vw, 3.75rem); /* 60px h2 */
--fs-900: clamp(2.625rem, 1.4rem + 5.5vw, 4.375rem); /* 70px h1 */
--lh-heading: 0.9;
--lh-body: 1.5;
--fw-regular: 400;
--fw-bold: 700;
/* --- Form & Tiefe --- */
--radius-btn: 8px;
--radius-card: 15px;
--shadow: 0 0 14px rgb(0 0 0 / 0.15);
--shadow-card: 0 7px 29px rgb(0 0 0 / 0.33);
/* --- Layout --- */
--container-max: 1200px;
--container-pad: 1rem;
--space-section: 5rem;
--header-h: 60px;
--logo-h: 97px;
/* --- Bewegung --- */
--transition: 300ms ease;
}
/* Breakpoints (Referenz — Custom Properties funktionieren nicht in Media Queries,
Werte müssen in den CSS-Dateien synchron gehalten werden):
1249px Burger-Menü | 1024px | 992px | 768px | 600px | 500px */

View File

@@ -0,0 +1,52 @@
/* ============================================================
UTILITIES — Buttons, visually-hidden, kleine Helfer
============================================================ */
.btn {
display: inline-block;
padding: 10px 18px;
background: var(--clr-accent);
color: var(--clr-text);
font-size: var(--fs-500);
text-decoration: none;
text-align: center;
border: 1px solid var(--clr-accent);
border-radius: var(--radius-btn);
cursor: pointer;
transition: background var(--transition), border-color var(--transition);
}
.btn:hover {
background: var(--clr-accent-dark);
border-color: var(--clr-accent-dark);
color: var(--clr-text);
}
.btn--outline {
background: transparent;
border-color: var(--clr-text);
}
.btn--outline:hover {
background: var(--clr-glass-bg);
border-color: var(--clr-text);
}
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--clr-text-muted);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="150mm" height="194mm" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 150 194">
<defs>
<style type="text/css">
<![CDATA[
.fil3 {fill:black}
.fil2 {fill:red}
.fil1 {fill:white}
.fil0 {fill:black;fill-rule:nonzero}
]]>
</style>
</defs>
<g id="Ebene_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_167224168">
<path id="_167474240" class="fil0" d="M23.1558 19.1779c1.089,0 1.9737,0.5516 2.6474,1.6517 0.6738,1.1 1.0091,3.0258 1.0091,5.7775 0,2.7417 -0.3353,4.6641 -1.0091,5.7642 -0.6737,1.1034 -1.5552,1.6517 -2.6378,1.6517 -1.0921,0 -1.9768,-0.5449 -2.6506,-1.6348 -0.6738,-1.0898 -1.0091,-3.0293 -1.0091,-5.8115 0,-2.7314 0.3353,-4.6471 1.0091,-5.7471 0.6738,-1.1001 1.5552,-1.6517 2.641,-1.6517zm0 2.3117c-1.6919,0 -1.3955,4.2952 -1.3955,5.1175 0,0.8223 -0.2973,5.1076 1.3955,5.1076 1.6957,0 1.3924,-4.2803 1.3924,-5.1076 0,-0.8271 0.305,-5.1175 -1.3924,-5.1175zm6.9491 4.4441c-1.1338,-0.6227 -1.6,-1.7542 -1.6,-3.0057 0,-2.0696 1.1984,-3.7501 3.3883,-3.7501 2.1621,0 3.3659,1.6833 3.3659,3.7299 0,1.2051 -0.46,2.4086 -1.552,3.0259 1.398,0.715 1.9193,2.0948 1.9193,3.5979 0,2.2221 -1.2159,4.5016 -3.6502,4.5016 -2.4764,0 -3.7523,-2.1539 -3.7523,-4.4136 0,-1.4738 0.5233,-2.9474 1.881,-3.6859zm0.4407 -2.806c0,0.8326 0.3661,1.7635 1.338,1.7635 0.9742,0 1.3541,-0.923 1.3541,-1.7635 0,-0.8177 -0.3778,-1.7363 -1.3285,-1.7363 -0.9608,0 -1.3636,0.8967 -1.3636,1.7363zm-0.198 6.2245c0,0.9633 0.3646,2.4404 1.5903,2.4404 1.2359,0 1.5457,-1.4577 1.5457,-2.4202 0,-0.9502 -0.4133,-2.2507 -1.5744,-2.2507 -1.1375,0 -1.5616,1.299 -1.5616,2.2305zm11.5061 4.4237l0 -14.5371 2.2642 0 0 6.4545 4.5954 -6.4545 3.0593 0 -4.2377 5.6625 4.4612 8.8746 -2.9379 0 -3.0976 -6.8235 -1.8427 2.4303 0 4.3932 -2.2642 0zm11.2953 -14.5371l2.2642 0 0 7.8727c0,1.7451 -0.1758,4.4035 2.2897,4.4035 2.361,0 2.1429,-2.5424 2.1429,-4.2343l0 -8.0419 2.2736 0 0 7.6358c0,3.3026 -0.0452,7.1484 -4.3494,7.1484 -4.3563,0 -4.621,-3.4858 -4.621,-7.0299l0 -7.7543zm11.423 14.5371l0 -14.4187 2.2642 0 0 11.9683 5.6492 0 0 2.4504 -7.9134 0zm9.5071 0l0 -14.5371 3.3946 0 2.0406 9.917 2.0215 -9.917 3.4043 0 0 14.5371 -2.1045 0 0 -11.4436 -2.2355 11.4436 -2.1907 0 -2.2163 -11.4436 0 11.4436 -2.114 0zm13.1156 -14.5371l4.5028 0c1.2263,0 2.0918,0.1184 2.5899,0.3587 1.2018,0.5714 1.7789,2.0417 1.7789,3.3001 0,1.1974 -0.5004,2.5268 -1.5809,3.1545 1.4794,0.5549 2.1333,2.0081 2.1333,3.5099 0,1.571 -0.7273,3.5873 -2.4239,4.0346 -0.4566,0.1184 -1.5136,0.1793 -3.1711,0.1793l-3.829 0 0 -14.5371zm2.2642 2.42l0 3.3609c1.0234,0 2.1598,0.108 3.1648,-0.0507 0.8617,-0.1434 1.2327,-0.8463 1.2327,-1.6653 0,-0.6164 -0.241,-1.3671 -0.9102,-1.5467 -0.2523,-0.0643 -0.9772,-0.0982 -2.1748,-0.0982l-1.3125 0zm0 5.781l0 3.8857 2.1077 0c0.923,0 1.5137,-0.0508 1.7756,-0.1524 0.6879,-0.2667 0.9261,-1.0814 0.9261,-1.7499 0,-0.7472 -0.2848,-1.5327 -1.0411,-1.8108 -1.0519,-0.3827 -2.6392,-0.1726 -3.7683,-0.1726zm19.2183 6.3361l-2.4686 0 -0.9836 -3.3034 -4.49 0 -0.9261 3.3034 -2.4142 0 4.3782 -14.5371 2.4047 0 4.4996 14.5371zm-4.1802 -5.7505l-1.5457 -5.3952 -1.5233 5.3952 3.069 0zm12.5821 0.4061l2.2004 0.9004c-0.5251,2.4791 -1.8553,4.6911 -4.6592,4.6911 -3.8583,0 -5.1383,-4.2104 -5.1383,-7.3853 0,-3.3303 1.2233,-7.646 5.2788,-7.646 2.5922,0 3.981,1.9434 4.49,4.2545l-2.245 0.6939c-0.2243,-1.2308 -0.9778,-2.4404 -2.3664,-2.4404 -2.4907,0 -2.8166,3.1176 -2.8166,4.9484 0,1.7985 0.2552,5.0669 2.7592,5.0669 1.6341,0 2.2592,-1.7542 2.4971,-3.0835zm4.1452 5.3444l0 -14.5371 2.2642 0 0 5.72 4.4548 0 0 -5.72 2.2738 0 0 14.5371 -2.2738 0 0 -6.3564 -4.4548 0 0 6.3564 -2.2642 0z"/>
<path id="_167474168" class="fil1" d="M13.9297 13.9292l122.141 0 0 25.6694 -122.141 0 0 -25.6694zm9.2261 5.2487c1.089,0 1.9737,0.5516 2.6474,1.6517 0.6738,1.1 1.0091,3.0258 1.0091,5.7775 0,2.7417 -0.3353,4.6641 -1.0091,5.7642 -0.6737,1.1034 -1.5552,1.6517 -2.6378,1.6517 -1.0921,0 -1.9768,-0.5449 -2.6506,-1.6348 -0.6738,-1.0898 -1.0091,-3.0293 -1.0091,-5.8115 0,-2.7314 0.3353,-4.6471 1.0091,-5.7471 0.6738,-1.1001 1.5552,-1.6517 2.641,-1.6517zm0 2.3117c-1.6919,0 -1.3955,4.2952 -1.3955,5.1175 0,0.8223 -0.2973,5.1076 1.3955,5.1076 1.6957,0 1.3924,-4.2803 1.3924,-5.1076 0,-0.8271 0.305,-5.1175 -1.3924,-5.1175zm6.9491 4.4441c-1.1338,-0.6227 -1.6,-1.7542 -1.6,-3.0057 0,-2.0696 1.1984,-3.7501 3.3883,-3.7501 2.1621,0 3.3659,1.6833 3.3659,3.7299 0,1.2051 -0.46,2.4086 -1.552,3.0259 1.398,0.715 1.9193,2.0948 1.9193,3.5979 0,2.2221 -1.2159,4.5016 -3.6502,4.5016 -2.4764,0 -3.7523,-2.1539 -3.7523,-4.4136 0,-1.4738 0.5233,-2.9474 1.881,-3.6859zm0.4407 -2.806c0,0.8326 0.3661,1.7635 1.338,1.7635 0.9742,0 1.3541,-0.923 1.3541,-1.7635 0,-0.8177 -0.3778,-1.7363 -1.3285,-1.7363 -0.9608,0 -1.3636,0.8967 -1.3636,1.7363zm-0.198 6.2245c0,0.9633 0.3646,2.4404 1.5903,2.4404 1.2359,0 1.5457,-1.4577 1.5457,-2.4202 0,-0.9502 -0.4133,-2.2507 -1.5744,-2.2507 -1.1375,0 -1.5616,1.299 -1.5616,2.2305zm11.5061 4.4237l0 -14.5371 2.2642 0 0 6.4545 4.5954 -6.4545 3.0593 0 -4.2377 5.6625 4.4612 8.8746 -2.9379 0 -3.0976 -6.8235 -1.8427 2.4303 0 4.3932 -2.2642 0zm11.2953 -14.5371l2.2642 0 0 7.8727c0,1.7451 -0.1758,4.4035 2.2897,4.4035 2.361,0 2.1429,-2.5424 2.1429,-4.2343l0 -8.0419 2.2736 0 0 7.6358c0,3.3026 -0.0452,7.1484 -4.3494,7.1484 -4.3563,0 -4.621,-3.4858 -4.621,-7.0299l0 -7.7543zm11.423 14.5371l0 -14.4187 2.2642 0 0 11.9683 5.6492 0 0 2.4504 -7.9134 0zm9.5071 0l0 -14.5371 3.3946 0 2.0406 9.917 2.0215 -9.917 3.4043 0 0 14.5371 -2.1045 0 0 -11.4436 -2.2355 11.4436 -2.1907 0 -2.2163 -11.4436 0 11.4436 -2.114 0zm13.1156 -14.5371l4.5028 0c1.2263,0 2.0918,0.1184 2.5899,0.3587 1.2018,0.5714 1.7789,2.0417 1.7789,3.3001 0,1.1974 -0.5004,2.5268 -1.5809,3.1545 1.4794,0.5549 2.1333,2.0081 2.1333,3.5099 0,1.571 -0.7273,3.5873 -2.4239,4.0346 -0.4566,0.1184 -1.5136,0.1793 -3.1711,0.1793l-3.829 0 0 -14.5371zm2.2642 2.42l0 3.3609c1.0234,0 2.1598,0.108 3.1648,-0.0507 0.8617,-0.1434 1.2327,-0.8463 1.2327,-1.6653 0,-0.6164 -0.241,-1.3671 -0.9102,-1.5467 -0.2523,-0.0643 -0.9772,-0.0982 -2.1748,-0.0982l-1.3125 0zm0 5.781l0 3.8857 2.1077 0c0.923,0 1.5137,-0.0508 1.7756,-0.1524 0.6879,-0.2667 0.9261,-1.0814 0.9261,-1.7499 0,-0.7472 -0.2848,-1.5327 -1.0411,-1.8108 -1.0519,-0.3827 -2.6392,-0.1726 -3.7683,-0.1726zm19.2183 6.3361l-2.4686 0 -0.9836 -3.3034 -4.49 0 -0.9261 3.3034 -2.4142 0 4.3782 -14.5371 2.4047 0 4.4996 14.5371zm-4.1802 -5.7505l-1.5457 -5.3952 -1.5233 5.3952 3.069 0zm12.5821 0.4061l2.2004 0.9004c-0.5251,2.4791 -1.8553,4.6911 -4.6592,4.6911 -3.8583,0 -5.1383,-4.2104 -5.1383,-7.3853 0,-3.3303 1.2233,-7.646 5.2788,-7.646 2.5922,0 3.981,1.9434 4.49,4.2545l-2.245 0.6939c-0.2243,-1.2308 -0.9778,-2.4404 -2.3664,-2.4404 -2.4907,0 -2.8166,3.1176 -2.8166,4.9484 0,1.7985 0.2552,5.0669 2.7592,5.0669 1.6341,0 2.2592,-1.7542 2.4971,-3.0835zm4.1452 5.3444l0 -14.5371 2.2642 0 0 5.72 4.4548 0 0 -5.72 2.2738 0 0 14.5371 -2.2738 0 0 -6.3564 -4.4548 0 0 6.3564 -2.2642 0z"/>
<path id="_167474096" class="fil2" d="M13.9297 43.5278l122.141 0 0 76.9075c0,32.7997 -26.4097,59.6355 -58.6886,59.6355l-4.7634 0c-32.2789,0 -58.6886,-26.8358 -58.6886,-59.6355l0 -76.9075z"/>
<path id="_167474024" class="fil3" d="M66.4036 121.944l0 -7.2161 3.9298 0 0 7.2161 -3.9298 0zm0 -24.4065l0 -6.8761 3.9298 0 0 6.8761 -3.9298 0zm0 -24.0665l0 -7.8585 -29.865 0c0,-5.7301 0,-11.4603 0,-17.1904l76.9228 0c0,5.7301 0,11.4603 0,17.1904l-29.865 0 0 7.8585 -3.9298 0 0 -11.7877 29.8649 0 0 -9.3319 -69.063 0 0 9.3319 29.8649 0 0 11.7877 -3.9298 0zm17.1928 17.1904l0 6.8761 -3.9298 0 0 -6.8761 3.9298 0zm0 24.0665l0 7.2161 -3.9298 0 0 -7.2161 3.9298 0z"/>
<path id="_167473952" class="fil1" d="M40.4685 61.6829l29.8649 0 0 11.7877 9.3332 0 0 -11.7877 29.8649 0 0 -9.3319 -69.063 0 0 9.3319zm29.8649 28.9781l0 6.8761 9.3332 0 0 -6.8761 -9.3332 0zm0 24.0665l0 7.2161 9.3332 0 0 -7.2161 -9.3332 0z"/>
<path id="_167473880" class="fil3" d="M36.5391 73.4706c25.6409,0 51.2819,0 76.9228,0 0,5.7301 0,11.4603 0,17.1904l-59.5718 0 0 6.8761 59.5718 0 0 30.7212c-1.3099,0 -2.6195,0 -3.9294,0l0 -26.7919 -59.5722 -0.0001 0 -14.7346 59.5722 0 0 -9.3319 -68.9054 0 0 33.3985 59.7299 0.0001c0,5.0248 0,10.0497 0,15.0745l-59.8885 0 0 2.3854c-1.2568,0 -2.6725,0 -3.9294,0l0 -6.3147 59.8881 0 0 -7.2161 -59.8881 0 0 -41.2569zm77.0809 58.4224l0 7.241 -4.1811 0 4.1811 -7.241zm-21.8781 7.241l-32.5917 0 -2.4556 -3.9292 37.3161 0 -2.2688 3.9292zm-50.6639 0l-4.6981 0 0 -7.5176 4.6981 7.5176z"/>
<path id="_167473808" class="fil1" d="M49.9603 86.7316l59.5712 0.0001 0 -9.3319 -69.063 0 0 0.0001 -0.0001 0 0.0001 33.3984 59.7299 0 0 15.0746 -59.7299 0 0 2.3854c4.0145,0 7.8704,0 11.8848,0l4.3413 6.9465 37.3161 0 4.0112 -6.9465 11.5097 0 -0.0001 -26.7919 -59.5712 -0.0001 0 -14.7347z"/>
<path id="_167473736" class="fil3" d="M10 10.0001l130 0 0 111.544c0,34.351 -28.1092,62.4562 -62.465,62.4562l-5.07 0c-34.3558,0 -62.465,-28.1052 -62.465,-62.4562 0,-37.1811 0,-74.3625 0,-111.544zm105.719 118.258l-29.5021 51.1322c28.1264,-4.3629 49.8536,-29.2053 49.8536,-58.9552l0 -76.9075 -122.141 0 0 76.9075c0,30.6652 23.0848,56.115 52.4676,59.2976l-32.1161 -51.4746c6.0239,0 12.0482,0 18.0721,0l23.7368 37.9814 21.9318 -37.9814c5.8989,0 11.798,0 17.6969,0zm-34.351 51.6709l27.5446 -47.7417 -8.6219 0c-8.0222,13.8933 -16.0449,27.7867 -24.0671,41.6799l-26.0482 -41.6799 -0.0001 0 -8.8044 0 29.8602 47.8652c0.4613,0.0111 0.9236,0.0181 1.3874,0.0181l4.7634 0c1.3397,0 2.6682,-0.0505 3.9861,-0.1416zm-67.4381 -140.331l122.141 0 0 -25.6694 -122.141 0 0 25.6694z"/>
<path id="_167473664" class="fil1" d="M41.3707 132.188l8.8044 0 26.0483 41.6799 24.0671 -41.6799 8.6219 0 -27.5446 47.7417c-1.3179,0.0911 -2.6464,0.1416 -3.9861,0.1416l-4.7634 0c-0.4638,0 -0.9261,-0.007 -1.3874,-0.0181l-29.8602 -47.8652z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

62
public/assets/js/form.js Normal file
View File

@@ -0,0 +1,62 @@
/* Kontaktformular: Progressive Enhancement — ohne JS normales POST + Redirect.
Mit JS: fetch-Submit, Status in aria-live-Region, Button-Sperre während des Sendens. */
(function () {
'use strict';
var form = document.querySelector('.form');
if (!form || !window.fetch) {
return;
}
var status = form.querySelector('[data-form-status]');
var button = form.querySelector('button[type="submit"]');
var MESSAGES = {
success: 'Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.',
validation: '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.'
};
function show(type, text) {
var p = document.createElement('p');
p.className = type === 'success' ? 'form__success' : 'form__error';
p.textContent = text;
status.replaceChildren(p);
status.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
form.addEventListener('submit', function (event) {
// Native Validierung zuerst (novalidate ist gesetzt, daher manuell).
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
return;
}
event.preventDefault();
button.disabled = true;
button.textContent = 'Wird gesendet …';
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' }
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.ok) {
show('success', MESSAGES.success);
form.reset();
} else {
show('error', MESSAGES[data.error] || MESSAGES.mail);
}
})
.catch(function () {
show('error', MESSAGES.mail);
})
.finally(function () {
button.disabled = false;
button.textContent = 'Nachricht senden';
});
});
})();

40
public/assets/js/nav.js Normal file
View File

@@ -0,0 +1,40 @@
/* Burger-Navigation: Toggle mit aria-expanded, Escape schließt, Klick auf Link schließt. */
(function () {
'use strict';
var toggle = document.querySelector('.site-nav__toggle');
var list = document.getElementById('site-nav-list');
if (!toggle || !list) {
return;
}
function setOpen(open) {
toggle.setAttribute('aria-expanded', String(open));
document.body.classList.toggle('nav-open', open);
toggle.querySelector('.visually-hidden').textContent = open ? 'Menü schließen' : 'Menü öffnen';
}
toggle.addEventListener('click', function () {
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});
list.addEventListener('click', function (event) {
if (event.target.closest('a')) {
setOpen(false);
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
setOpen(false);
toggle.focus();
}
});
// Beim Vergrößern auf Desktop offenes Overlay zurücksetzen.
window.addEventListener('resize', function () {
if (window.innerWidth > 1249 && toggle.getAttribute('aria-expanded') === 'true') {
setOpen(false);
}
});
})();

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

59
public/index.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
// php -S Router-Modus: existierende Dateien (Assets) direkt ausliefern.
if (PHP_SAPI === 'cli-server') {
$file = __DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (is_file($file)) {
return false;
}
}
require dirname(__DIR__) . '/app/bootstrap.php';
$path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
// Kanonisch ohne trailing slash (Root ausgenommen).
if ($path !== '/' && str_ends_with($path, '/')) {
$query = $_SERVER['QUERY_STRING'] ?? '';
header('Location: ' . rtrim($path, '/') . ($query !== '' ? '?' . $query : ''), true, 301);
exit;
}
$slug = trim($path, '/');
// Formular-Submits.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $slug === 'kontakt-senden') {
require APP_PATH . '/actions/contact-submit.php';
exit;
}
$routes = require APP_PATH . '/routes.php';
// Sitemap aus dem Routen-Register generieren.
if ($slug === 'sitemap.xml') {
header('Content-Type: application/xml; charset=utf-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($routes as $routeSlug => $route) {
if (empty($route['sitemap'])) {
continue;
}
$lastmod = date('Y-m-d', (int) filemtime(APP_PATH . '/pages/' . $route['file']));
echo " <url><loc>" . e(abs_url($routeSlug)) . "</loc><lastmod>{$lastmod}</lastmod></url>\n";
}
echo '</urlset>';
exit;
}
if (isset($routes[$slug]) && $slug !== '404') {
$current = $slug;
} else {
http_response_code(404);
$current = '404';
}
[$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
require APP_PATH . '/layout.php';

5
public/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
# tsv08kulmbach.de — alle Crawler willkommen, inkl. AI-Crawler (GEO).
User-agent: *
Allow: /
Sitemap: https://www.tsv08kulmbach.de/sitemap.xml

1
storage/.htaccess Normal file
View File

@@ -0,0 +1 @@
Require all denied

0
storage/cache/.gitkeep vendored Normal file
View File

0
storage/logs/.gitkeep Normal file
View File