Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
This commit is contained in:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Secrets — nur config.example.php wird committed
|
||||
/config/config.php
|
||||
/config/config.production.php
|
||||
|
||||
# Composer
|
||||
/vendor/
|
||||
@@ -8,6 +9,10 @@
|
||||
/storage/logs/*
|
||||
/storage/cache/*
|
||||
/storage/ratelimit/*
|
||||
# Druck-QR-Codes (bin/qr.php) — Zwischenprodukte für Canva, kein Repo-Inhalt.
|
||||
# Wichtig: sie enthalten die base_url fest eingebacken, ein versehentlich
|
||||
# eingecheckter localhost-Code wäre eine Falle.
|
||||
/storage/qr/
|
||||
!/storage/logs/.gitkeep
|
||||
!/storage/cache/.gitkeep
|
||||
!/storage/ratelimit/.gitkeep
|
||||
@@ -22,6 +27,15 @@
|
||||
/public/assets/img/crests/*
|
||||
!/public/assets/img/crests/.gitkeep
|
||||
|
||||
# Dateiablage /dateien — Nutzerdaten und Laufzeit-Kram der Files Gallery.
|
||||
# Committed bleiben nur system/users/*/config.php (Rechte, keine Passwörter) und
|
||||
# system/include/* (Skin). system/config/config.php generiert die App selbst und
|
||||
# überschreibt sie bei jedem Update — unsere Werte stehen in _filesconfig.php.
|
||||
/storage/dateien/uploads/*
|
||||
!/storage/dateien/uploads/.gitkeep
|
||||
/storage/dateien/system/cache/
|
||||
/storage/dateien/system/config/
|
||||
|
||||
# Referenz-Material der alten Seite — NIE committen
|
||||
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)
|
||||
/.context/
|
||||
|
||||
@@ -7,7 +7,12 @@ declare(strict_types=1);
|
||||
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…),
|
||||
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
|
||||
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
|
||||
* Das Formular kommt von / wie auch von /kontakt.
|
||||
* Das Formular kommt von / wie auch von /kontakt und /turnen.
|
||||
*
|
||||
* Eine Action für alle Varianten: `department` (Hidden-Feld) wählt über
|
||||
* contact_variant() Auswahlliste, Feldregeln und Empfänger. Eine zweite Action
|
||||
* für die Turnabteilung hätte den kompletten Spam-Schutz verdoppelt — und damit
|
||||
* eine zweite Stelle, die man bei jeder Änderung mitziehen müsste.
|
||||
*/
|
||||
|
||||
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
|
||||
@@ -36,8 +41,7 @@ $token = $field('ft');
|
||||
|
||||
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
|
||||
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
|
||||
if (config_problems() !== []) {
|
||||
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
if (!config_ready($route)) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
@@ -60,7 +64,16 @@ $subject = $field('subject');
|
||||
$message = $field('message');
|
||||
$privacy = ($_POST['privacy'] ?? '') === '1';
|
||||
|
||||
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
|
||||
// Variante bestimmen (unbekannter Wert fällt auf die allgemeine zurück, siehe
|
||||
// contact_variant()) — dieselbe Quelle, aus der die Komponente ihr Formular baut.
|
||||
$variant = contact_variant($field('department'));
|
||||
$interests = $variant['interests'];
|
||||
if (!$variant['interest_required']) {
|
||||
$interests[] = ''; // die allgemeine Variante darf das Feld leer lassen
|
||||
}
|
||||
if (!$variant['ask_subject']) {
|
||||
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
|
||||
}
|
||||
|
||||
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
||||
$errors = [];
|
||||
@@ -74,12 +87,14 @@ if (mb_strlen($phone) > 50) {
|
||||
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
|
||||
}
|
||||
if (!in_array($interest, $interests, true)) {
|
||||
$errors['contact-interest'] = 'Bitte wähle einen der angebotenen Bereiche.';
|
||||
$errors['contact-interest'] = $variant['interest_required']
|
||||
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
|
||||
: 'Bitte wähle einen der angebotenen Bereiche.';
|
||||
}
|
||||
if ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject)) {
|
||||
if ($variant['ask_subject'] && ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject))) {
|
||||
$errors['contact-subject'] = 'Bitte gib einen Betreff an (max. 200 Zeichen, einzeilig).';
|
||||
}
|
||||
if ($message === '' || mb_strlen($message) > 5000) {
|
||||
if (($variant['message_required'] && $message === '') || mb_strlen($message) > 5000) {
|
||||
$errors['contact-message'] = 'Bitte schreib uns eine Nachricht (max. 5000 Zeichen).';
|
||||
}
|
||||
if (!$privacy) {
|
||||
@@ -124,23 +139,28 @@ if (!rate_limit_ok('daily', 200, 86400)) {
|
||||
}
|
||||
|
||||
// --- Versand ---
|
||||
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
|
||||
// Ohne Betreff-Feld trägt die Auswahl den Mail-Betreff.
|
||||
$mailSubject = $variant['subject_prefix'] . ': ' . ($subject !== '' ? $subject : $interest);
|
||||
|
||||
$body = $variant['context'] . " ü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"
|
||||
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
|
||||
. str_repeat('-', 40) . "\n\n"
|
||||
. $message . "\n\n"
|
||||
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
|
||||
|
||||
$sent = send_mail([
|
||||
'context' => 'Kontaktanfrage',
|
||||
'subject' => 'Kontaktanfrage: ' . $subject,
|
||||
'context' => $variant['context'],
|
||||
'subject' => $mailSubject,
|
||||
'body' => $body,
|
||||
'reply_to' => ['email' => $email, 'name' => $name],
|
||||
// Leer heißt: Standardempfänger aus config('smtp.to') (send_mail()).
|
||||
'to' => $variant['to'] !== '' ? $variant['to'] : null,
|
||||
]);
|
||||
|
||||
$respond($sent, $sent ? '' : 'mail');
|
||||
|
||||
@@ -30,8 +30,7 @@ $token = $field('ft');
|
||||
|
||||
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
|
||||
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
|
||||
if (config_problems() !== []) {
|
||||
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
if (!config_ready($route)) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
@@ -156,7 +155,7 @@ $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
|
||||
. "Satzung anerkannt: ja\n"
|
||||
. "Datenschutz: ja\n"
|
||||
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
|
||||
. "Hinweis: Bankdaten werden online nicht erhoben – Beitrittserklärung\n"
|
||||
. "Hinweis: Bankdaten werden online nicht erhoben. Beitrittserklärung\n"
|
||||
. "per Post zur Unterschrift zusenden.\n";
|
||||
|
||||
// Mail 1 (intern) — erfolgskritisch.
|
||||
|
||||
@@ -29,8 +29,7 @@ $token = $field('ft');
|
||||
|
||||
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
|
||||
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
|
||||
if (config_problems() !== []) {
|
||||
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
if (!config_ready($route)) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ declare(strict_types=1);
|
||||
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
|
||||
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
|
||||
* Startseite springt.
|
||||
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
|
||||
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
|
||||
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
|
||||
* Betreff, weil die Disziplin-Auswahl ihn ersetzt. Bewusst dieselbe Komponente
|
||||
* und dieselbe Action: ein zweites Formular hieße ein zweiter Spam-Schutz.
|
||||
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
||||
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
|
||||
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
|
||||
@@ -17,26 +22,29 @@ $club = json_load('club');
|
||||
$return = $return ?? '';
|
||||
$intro = $intro ?? null;
|
||||
|
||||
$interests = ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
|
||||
$variant = contact_variant((string) ($department ?? ''));
|
||||
$interests = $variant['interests'];
|
||||
// Sichtbare Direkt-Adresse: bei einer Abteilungsanfrage die der Abteilung.
|
||||
$directMail = $variant['to'] !== '' ? $variant['to'] : $club['email'];
|
||||
|
||||
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
|
||||
// serverseitigen Feldfehler aus der Action.
|
||||
$sent = isset($_GET['sent']);
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
||||
$errorMessages = [
|
||||
'validation' => 'Bitte prüfe deine Eingaben – die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $directMail . '.',
|
||||
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $directMail . '.',
|
||||
];
|
||||
|
||||
$f = [
|
||||
'name' => ['type' => 'text', 'name' => 'name', 'id' => 'contact-name', 'label' => 'Name', 'required' => true, 'autocomplete' => 'name', 'maxlength' => 200],
|
||||
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
|
||||
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
|
||||
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests],
|
||||
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
|
||||
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
|
||||
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => true, 'maxlength' => 5000, 'rows' => 6],
|
||||
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true,
|
||||
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
|
||||
@@ -50,7 +58,7 @@ $f = [
|
||||
<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>
|
||||
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
|
||||
@@ -73,7 +81,11 @@ $f = [
|
||||
<?php component('form-field', ['field' => $f['interest']]); ?>
|
||||
</div>
|
||||
|
||||
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
|
||||
worum es geht, und die Action baut den Mail-Betreff daraus. */ ?>
|
||||
<?php if ($variant['ask_subject']): ?>
|
||||
<?php component('form-field', ['field' => $f['subject']]); ?>
|
||||
<?php endif; ?>
|
||||
<?php component('form-field', ['field' => $f['message']]); ?>
|
||||
<?php component('form-field', ['field' => $f['privacy']]); ?>
|
||||
|
||||
@@ -84,6 +96,7 @@ $f = [
|
||||
</div>
|
||||
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
||||
<input type="hidden" name="return" value="<?= e($return) ?>">
|
||||
<input type="hidden" name="department" value="<?= e($variant['department']) ?>">
|
||||
|
||||
<p class="form__submit">
|
||||
<button class="btn" type="submit">Nachricht senden</button>
|
||||
|
||||
@@ -5,11 +5,16 @@ declare(strict_types=1);
|
||||
/**
|
||||
* CTA-Button. Props: $cta array{label: string, slug: string,
|
||||
* style?: 'primary'|'outline', size?: 'sm'|'lg'}
|
||||
* Alternativ zu 'slug': 'href' für nicht-Seiten-Ziele (mailto:, tel:) — gewinnt
|
||||
* gegen slug/hash. Externe http-Ziele gehören hier nicht rein (siehe Regel 1).
|
||||
*/
|
||||
$style = $cta['style'] ?? 'primary';
|
||||
$size = $cta['size'] ?? null;
|
||||
$class = 'btn'
|
||||
. ($style === 'outline' ? ' btn--outline' : '')
|
||||
. ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : ''));
|
||||
$href = !empty($cta['href'])
|
||||
? $cta['href']
|
||||
: url($cta['slug']) . (!empty($cta['hash']) ? '#' . $cta['hash'] : '');
|
||||
?>
|
||||
<a class="<?= e($class) ?>" href="<?= e(url($cta['slug'])) . (!empty($cta['hash']) ? '#' . e($cta['hash']) : '') ?>"><?= e($cta['label']) ?></a>
|
||||
<a class="<?= e($class) ?>" href="<?= e($href) ?>"><?= e($cta['label']) ?></a>
|
||||
|
||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
|
||||
* Props:
|
||||
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor}]}
|
||||
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor, image?}]}
|
||||
* $anchor string (optional, Sektions-ID)
|
||||
* $title_hidden bool — Titel nur für Screenreader ausgeben (hält die Heading-Kette
|
||||
* h1 → h2 → h3 intakt, wenn die Karten optisch für sich sprechen)
|
||||
* $item['image'] optional: {base, widths, alt} — Bildband oben, wie team-cards.
|
||||
* Bewusst eigenes <a href="#…">-Markup (nicht die cta-Komponente): cta() schickt
|
||||
* jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen.
|
||||
*/
|
||||
$anchor = $anchor ?? null;
|
||||
$title_hidden = !empty($title_hidden);
|
||||
if (empty($choice['items'])) {
|
||||
return;
|
||||
}
|
||||
@@ -18,7 +22,7 @@ if (empty($choice['items'])) {
|
||||
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
|
||||
<div class="container">
|
||||
<?php if (!empty($choice['title'])): ?>
|
||||
<h2 class="department-choice__title"><?= e($choice['title']) ?></h2>
|
||||
<h2 class="<?= $title_hidden ? 'visually-hidden' : 'department-choice__title' ?>"><?= e($choice['title']) ?></h2>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($choice['intro'])): ?>
|
||||
<p class="department-choice__intro"><?= e($choice['intro']) ?></p>
|
||||
@@ -26,12 +30,19 @@ if (empty($choice['items'])) {
|
||||
<ul class="department-choice__grid" role="list">
|
||||
<?php foreach ($choice['items'] as $item): ?>
|
||||
<li class="department-choice__card">
|
||||
<?php if (!empty($item['image']['base'])): ?>
|
||||
<div class="department-choice__media">
|
||||
<?php component('img', ['image' => $item['image'], 'sizes' => '(max-width: 600px) 100vw, 50vw', 'class' => 'department-choice__img']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="department-choice__body">
|
||||
<h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
|
||||
<?php if (!empty($item['subtitle'])): ?>
|
||||
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="department-choice__text"><?= e($item['text']) ?></p>
|
||||
<a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
64
app/components/detail-head.php
Normal file
64
app/components/detail-head.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Schmaler Kopf einer Detailseite (statt hero): Rückweg, h1, optionaler
|
||||
* Entwurf-Badge, Meta-Zeile. Props:
|
||||
* $back array{href, label} — Rückweg auf die Übersicht (Pflicht)
|
||||
* $title string — die h1 der Seite (Pflicht)
|
||||
* $badge string (optional) — Auszeichnung neben dem Titel, z. B. 'Entwurf'
|
||||
* $date_iso string (optional) — für <time datetime>
|
||||
* $date_text string (optional) — sichtbares Datum (german_date()/german_date_range())
|
||||
* $location string (optional) — Ort, erscheint mit Nadel-Icon hinter dem Datum
|
||||
*
|
||||
* Gemeinsam genutzt von der Veranstaltungs- und der News-Artikel-Seite. Beide
|
||||
* haben bewusst KEINEN hero: ein Archiv-Eintrag hat kein eigenes Titelbild, und
|
||||
* beim News-Artikel trägt der Aufmacher im Artikelkörper das Bild (Blog-Optik)
|
||||
* statt eines cover-beschnittenen Hintergrunds.
|
||||
*
|
||||
* Der Kopf der Stadionzeitungs-Ausgabe (.stadionzeitung-issue-head) bleibt
|
||||
* bewusst eine eigene Fassung und wird hier NICHT mitbedient: er ist
|
||||
* Viewer-Chrome, kein Artikelkopf — Drei-Zonen-Grid, Titel als <p> (die
|
||||
* Seitenbilder tragen die Ausgabe), Druck-Knopf, zwei Badge-Varianten und zwei
|
||||
* eigene @media-print-Regeln. Ihn in dieselbe Komponente zu zwingen, hieße eine
|
||||
* Prop-Matrix für zwei Layouts zu bauen.
|
||||
*/
|
||||
$back = $back ?? [];
|
||||
$title = (string) ($title ?? '');
|
||||
$badge = (string) ($badge ?? '');
|
||||
$dateIso = (string) ($date_iso ?? '');
|
||||
$dateText = (string) ($date_text ?? '');
|
||||
$location = (string) ($location ?? '');
|
||||
$hasMeta = $dateText !== '' || $location !== '';
|
||||
?>
|
||||
<header class="detail-head">
|
||||
<div class="container detail-head__inner">
|
||||
<?php if (!empty($back['href'])): ?>
|
||||
<a class="detail-head__back" href="<?= e($back['href']) ?>">
|
||||
<?= icon('chevron-left') ?> <?= e($back['label'] ?? 'Zurück') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<h1 class="detail-head__title">
|
||||
<?= e($title) ?>
|
||||
<?php if ($badge !== ''): ?>
|
||||
<span class="detail-head__badge"><?= e($badge) ?></span>
|
||||
<?php endif; ?>
|
||||
</h1>
|
||||
<?php if ($hasMeta): ?>
|
||||
<p class="detail-head__meta">
|
||||
<?php if ($dateText !== ''): ?>
|
||||
<time<?= $dateIso !== '' ? ' datetime="' . e($dateIso) . '"' : '' ?>><?= e($dateText) ?></time>
|
||||
<?php endif; ?>
|
||||
<?php if ($location !== ''): ?>
|
||||
<?php if ($dateText !== ''): ?>
|
||||
<span class="detail-head__sep" aria-hidden="true">·</span>
|
||||
<?php endif; ?>
|
||||
<?php /* Icon und Ort in EINEM span, sonst bricht die Zeile am Handy zwischen
|
||||
Nadel und Ortsname um. */ ?>
|
||||
<span class="detail-head__ort"><?= icon('geo-alt') ?> <?= e($location) ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
53
app/components/event-feature.php
Normal file
53
app/components/event-feature.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Aufmacher für einen Vereins-Termin — das Gegenstück zu match-feature für
|
||||
* alles, was kein Spiel ist (data/events.json). Gleiches Bauprinzip: drei
|
||||
* Schriftrollen (Meta/Ort in Abel · Titel in Coolvetica · Zeitzeile in Abel),
|
||||
* keine Badges, keine Icons, die Wirkung kommt aus der Größe. Statt des
|
||||
* Fixtures mit Wappen steht links das Datums-Chip (Tag über Monat, dieselbe
|
||||
* Optik wie in der termine-list).
|
||||
* Props:
|
||||
* $event Eintrag aus data/events.json (title, date_start, date_end?, time?, location?)
|
||||
*/
|
||||
$e = $event;
|
||||
$dateStart = (string) ($e['date_start'] ?? '');
|
||||
$dateEnd = $e['date_end'] ?? null;
|
||||
$time = (string) ($e['time'] ?? '');
|
||||
$chip = german_date_chip($dateStart);
|
||||
$dateText = german_date_range($dateStart, $dateEnd);
|
||||
|
||||
// Ganztägige Events (ohne time) sollen im Countdown nicht „heute, 00:00 Uhr"
|
||||
// behaupten — deshalb der zweite Parameter.
|
||||
$when = match_when($dateStart !== '' ? $dateStart . 'T' . ($time !== '' ? $time : '00:00') : '', $time !== '');
|
||||
$dateLine = trim($dateText . ($time !== '' ? ' · ' . $time . ' Uhr' : ''));
|
||||
?>
|
||||
<article class="event-feature">
|
||||
<div class="event-feature__head">
|
||||
<?php if ($chip['day'] !== ''): ?>
|
||||
<span class="event-feature__chip" aria-hidden="true">
|
||||
<span class="event-feature__chip-day"><?= e($chip['day']) ?></span>
|
||||
<span class="event-feature__chip-month"><?= e($chip['month']) ?></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<span class="event-feature__body">
|
||||
<span class="event-feature__title"><?= e($e['title'] ?? '') ?></span>
|
||||
<?php if (!empty($e['location'])): ?>
|
||||
<span class="event-feature__where"><?= e($e['location']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
<p class="event-feature__when">
|
||||
<?php if ($dateStart !== ''): ?>
|
||||
<time datetime="<?= e($dateStart) ?>"><?= e($dateLine) ?></time>
|
||||
<?php else: ?>
|
||||
<span><?= e($dateLine) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($when['countdown'] !== ''): ?>
|
||||
<span class="event-feature__sep" aria-hidden="true">·</span>
|
||||
<span class="event-feature__countdown" data-countdown="<?= e($when['iso']) ?>"><?= e($when['countdown']) ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</article>
|
||||
@@ -41,7 +41,7 @@ if ($faq === [] && !$hasPerson) {
|
||||
<?php foreach ($faq as $item): ?>
|
||||
<details class="faq__item">
|
||||
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
|
||||
<div class="faq__a"><p><?= e($item['a']) ?></p></div>
|
||||
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ if ($faq === []) {
|
||||
<?php foreach ($faq as $item): ?>
|
||||
<details class="faq__item">
|
||||
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
|
||||
<div class="faq__a"><p><?= e($item['a']) ?></p></div>
|
||||
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ $nav = json_load('navigation');
|
||||
?>
|
||||
<header class="site-header">
|
||||
<div class="container site-header__inner">
|
||||
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> – Startseite">
|
||||
<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" aria-hidden="true"><?= e($club['name']) ?></span>
|
||||
</a>
|
||||
|
||||
74
app/components/hero-upcoming.php
Normal file
74
app/components/hero-upcoming.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Glas-Karte im Startseiten-Hero: die nächsten Termine des Vereins als
|
||||
* automatisch wechselnder Slider. Props:
|
||||
* $items aus upcoming_highlights() (app/helpers.php) — Spiele und
|
||||
* Vereins-Events chronologisch gemischt, je Eintrag type/label/href/
|
||||
* link_text plus die rohen Daten für die Innenvariante
|
||||
*
|
||||
* Zwei Innenvarianten, eine Karte: 'spiel' rendert match-feature (Heim über
|
||||
* Gast mit Wappen, wie im Matchcenter-Band), 'event' rendert event-feature
|
||||
* (Datums-Chip + Titel + Ort). Die Beschriftung („Nächstes Spiel · 1. Mannschaft")
|
||||
* kommt fertig aus dem Helper; die Meta-Zeile von match-feature bleibt in der
|
||||
* Karte per CSS aus, damit Mannschaft/Liga nicht zweimal dastehen.
|
||||
*
|
||||
* Antrieb ist der generische assets/js/carousel.js (dieselben data-Hooks wie
|
||||
* banner-slider): Crossfade, Fortschrittsring im aktiven Indikator, Pause-Taste
|
||||
* (WCAG 2.2.2), Pause bei Hover/Fokus, kein Autoplay bei prefers-reduced-motion.
|
||||
* Ohne JS ist nur der nächste Termin sichtbar und verlinkt (die weiteren Folien
|
||||
* tragen hidden, die Steuerung bleibt verborgen, weil sie funktionslos wäre).
|
||||
*
|
||||
* Die Karten-Wurzel ist bewusst ein div und nicht selbst der Link: die
|
||||
* Steuer-Buttons liegen in der Karte, verschachtelte interaktive Elemente wären
|
||||
* ungültig. Verlinkt ist deshalb der Inhalt jeder Folie.
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
if ($items === []) {
|
||||
return;
|
||||
}
|
||||
$total = count($items);
|
||||
?>
|
||||
<div class="hero__upcoming"<?= $total > 1 ? ' data-carousel aria-roledescription="Karussell" aria-label="Nächste Termine"' : '' ?>>
|
||||
<ul class="hero__upcoming-track" id="hero-upcoming-track" data-carousel-track aria-live="off">
|
||||
<?php foreach ($items as $i => $item): ?>
|
||||
<li class="hero__upcoming-slide"<?= $total > 1 ? ' data-carousel-slide role="group" aria-roledescription="Folie" aria-label="' . ($i + 1) . ' von ' . $total . '"' : '' ?><?= $i === 0 ? '' : ' hidden' ?>>
|
||||
<a class="hero__upcoming-link" href="<?= e($item['href']) ?>">
|
||||
<p class="hero__upcoming-label"><?= e($item['label']) ?></p>
|
||||
<?php if ($item['type'] === 'spiel'): ?>
|
||||
<?php component('match-feature', [
|
||||
'match' => $item['match'],
|
||||
'team_name' => $item['team_name'],
|
||||
'league' => $item['league'],
|
||||
]); ?>
|
||||
<?php else: ?>
|
||||
<?php component('event-feature', ['event' => $item['event']]); ?>
|
||||
<?php endif; ?>
|
||||
<span class="visually-hidden"><?= e($item['link_text']) ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($total > 1): ?>
|
||||
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
|
||||
<div class="hero__upcoming-controls" data-carousel-controls hidden>
|
||||
<div class="hero__upcoming-indicators" role="group" aria-label="Termin wählen">
|
||||
<?php foreach ($items as $i => $item): ?>
|
||||
<button class="hero__upcoming-dot" type="button" data-carousel-dot aria-controls="hero-upcoming-track" aria-label="Termin <?= ($i + 1) ?> von <?= $total ?>: <?= e($item['label']) ?>">
|
||||
<span class="hero__upcoming-progress" data-carousel-progress aria-hidden="true"></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
|
||||
<?php /* Kein btn--sm: dessen 44px kommen über ::after, die Fläche würde
|
||||
in dieser Reihe den letzten Indikator überlappen (siehe CLAUDE.md). */ ?>
|
||||
<button class="btn btn--icon hero__upcoming-toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="hero-upcoming-track" hidden>
|
||||
<?= icon('pause-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--pause') ?>
|
||||
<?= icon('play-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--play') ?>
|
||||
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -5,21 +5,30 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Vollflächiger Hero mit Hintergrundbild/-video, Titel, Intro und CTAs.
|
||||
* Props: $hero (array aus home.json: title, text, image, video?, ctas, align?, compact?,
|
||||
* display?, kicker?, scroll_target?)
|
||||
* align-Varianten: 'center' (mittig zentriert), 'bottom-left' (Default).
|
||||
* display?, kicker?, scroll_target?), $upcoming (optional, s. home.php:
|
||||
* die nächsten Termine aus upcoming_highlights() — Spiele und Vereins-Events
|
||||
* gemischt, gerendert über die hero-upcoming-Komponente)
|
||||
* align-Varianten: 'center' (mittig zentriert, Konvention für alle Unterseiten seit 27.07.2026),
|
||||
* 'bottom-left' (ältere Variante, technischer Fallback — für neue Seiten nicht mehr verwenden).
|
||||
* compact: true → niedrigere Variante für Unterseiten-Header.
|
||||
* display: true → großer Editorial-Look (Riesen-Titel, Outline-Tagline, Kicker, Scroll-Cue).
|
||||
*
|
||||
* KONVENTION (siehe CLAUDE.md → Hero-Regel): display + kicker + tagline + video sind der
|
||||
* Startseite vorbehalten. Unterseiten-Heros sind einheitlich „Überschrift + Teaser":
|
||||
* nur title + text, dazu compact: true und align: 'bottom-left'. Kein kicker/tagline,
|
||||
* nur title + text, dazu compact: true und align: 'center'. Kein kicker/tagline,
|
||||
* ctas nur als Ausnahme.
|
||||
*
|
||||
* Liegt mindestens ein Termin in $upcoming, läuft der Display-Hero zweispaltig statt
|
||||
* zentriert („hero--duo"): links Titel/Text/CTAs linksbündig, rechts die Termin-Karte als
|
||||
* Live-Aufhänger. Ohne Termine (Sommerpause) bleibt es beim zentrierten Hero ohne Karte.
|
||||
*/
|
||||
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'bottom-left';
|
||||
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'center';
|
||||
$compact = !empty($hero['compact']);
|
||||
$display = !empty($hero['display']);
|
||||
$upcoming = $upcoming ?? [];
|
||||
$duo = $display && $upcoming !== [];
|
||||
?>
|
||||
<section class="hero<?= $align === 'center' ? ' hero--center' : '' ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?>">
|
||||
<section class="hero<?= $duo ? '' : ($align === 'center' ? ' hero--center' : '') ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?><?= $duo ? ' hero--duo' : '' ?>">
|
||||
<div class="hero__media" aria-hidden="true">
|
||||
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
|
||||
<?php if (!empty($hero['video'])): ?>
|
||||
@@ -27,7 +36,9 @@ $display = !empty($hero['display']);
|
||||
<video class="hero__video" src="<?= e(asset($hero['video'])) ?>" autoplay muted loop playsinline preload="none" tabindex="-1"></video>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="container hero__content">
|
||||
<div class="container hero__content<?= $duo ? ' hero__content--duo' : '' ?>">
|
||||
<?php if ($duo): ?>
|
||||
<div class="hero__primary">
|
||||
<?php if (!empty($hero['kicker'])): ?>
|
||||
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
|
||||
<?php endif; ?>
|
||||
@@ -41,6 +52,24 @@ $display = !empty($hero['display']);
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
|
||||
<?php else: ?>
|
||||
<?php if (!empty($hero['kicker'])): ?>
|
||||
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
|
||||
<?php endif; ?>
|
||||
<h1 class="hero__title"><?= e($hero['title']) ?><?php if (!empty($hero['tagline'])): ?> <span class="hero__tagline"><?= e($hero['tagline']) ?></span><?php endif; ?></h1>
|
||||
<?php if (!empty($hero['text'])): ?>
|
||||
<p class="hero__text"><?= e($hero['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($hero['ctas'])): ?>
|
||||
<div class="hero__actions">
|
||||
<?php foreach ($hero['ctas'] as $cta): ?>
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($display): ?>
|
||||
|
||||
@@ -16,7 +16,7 @@ $maxPosts = (int) config('instagram.max_posts', 9);
|
||||
<div class="container">
|
||||
<h2>Aktuelles aus dem Fußball</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><?php if (!empty($club['social']['facebook'])): ?> – folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
|
||||
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?>. Folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
|
||||
<ul class="instagram__grid">
|
||||
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
|
||||
<li class="instagram__item">
|
||||
@@ -37,5 +37,6 @@ $maxPosts = (int) config('instagram.max_posts', 9);
|
||||
<p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<p class="instagram__more"><a class="btn btn--outline" href="<?= e(url('news')) ?>">Alle News ansehen</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -22,9 +22,11 @@ $hasReleg = in_array('relegation', $zones, true);
|
||||
|
||||
// Vor dem 1. Spieltag liefert der BFV für JEDE Mannschaft Platz 1 und dieselbe
|
||||
// Auf-/Abstiegszone. Beides ist dann bedeutungslos und lässt die Tabelle kaputt
|
||||
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken und
|
||||
// stattdessen sagen, dass noch nichts gewertet ist. Testspiele zählen hier nicht
|
||||
// mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also zu Recht nicht.
|
||||
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken.
|
||||
// Ein erklärender Hinweissatz stand hier mal und ist bewusst raus (Felix,
|
||||
// 29.07.2026): eine Tabelle mit 0 Spielen erklärt sich selbst. Testspiele
|
||||
// zählen nicht mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also
|
||||
// zu Recht nicht.
|
||||
$notStarted = array_sum(array_map(
|
||||
static fn (array $r): int => (int) ($r['played'] ?? 0),
|
||||
$table
|
||||
@@ -33,9 +35,6 @@ if ($notStarted) {
|
||||
$hasPromo = $hasPromoPlayoff = $hasRelegPlayoff = $hasReleg = false;
|
||||
}
|
||||
?>
|
||||
<?php if ($notStarted): ?>
|
||||
<p class="league-table__note">Die Liga hat noch nicht begonnen – gewertet wird ab dem 1. Spieltag.</p>
|
||||
<?php endif; ?>
|
||||
<div class="table-scroll-frame">
|
||||
<div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0">
|
||||
<table class="league-table">
|
||||
|
||||
41
app/components/lightbox-overlay.php
Normal file
41
app/components/lightbox-overlay.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Overlay für die Bild-Großansicht. Props:
|
||||
* $label string Bezeichnung des Dialogs (Screenreader)
|
||||
* $count int Anzahl der Bilder — bei genau einem gibt es weder
|
||||
* Zähler noch Vor/Zurück (wäre beides sinnlos)
|
||||
*
|
||||
* Gehört IMMER in denselben [data-lightbox-gallery]-Container wie die
|
||||
* zugehörigen Bildlinks (assets/js/lightbox.js sucht von dort aus). Steht
|
||||
* `hidden` im Markup, bis JS es übernimmt — ohne JS bleiben die
|
||||
* Vorschaubilder normale Links auf die Bilddatei.
|
||||
*/
|
||||
$label = $label ?? 'Bild in Großansicht';
|
||||
$count = $count ?? 0;
|
||||
$mehrere = $count > 1;
|
||||
?>
|
||||
<div class="lightbox" role="dialog" aria-modal="true" aria-label="<?= e($label) ?>" data-lightbox-dialog hidden>
|
||||
<?php /* Keine sichtbare Bildunterschrift (Beschluss 30.07.2026): der Alt-Text
|
||||
trägt die Beschreibung. sizes steht statisch, das srcset erbt das Bild
|
||||
vom angeklickten Vorschaubild (lightbox.js). */ ?>
|
||||
<img class="lightbox__img" src="" alt="" sizes="100vw" data-lightbox-img>
|
||||
<?php if ($mehrere): ?>
|
||||
<p class="lightbox__counter" aria-live="polite">
|
||||
<span data-lightbox-current>1</span> von <?= (int) $count ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn--icon lightbox__btn lightbox__close" type="button" data-lightbox-close aria-label="Ansicht schließen">
|
||||
<?= icon('x-lg') ?>
|
||||
</button>
|
||||
<?php if ($mehrere): ?>
|
||||
<button class="btn btn--icon lightbox__btn lightbox__prev" type="button" data-lightbox-prev aria-label="Vorheriges Bild">
|
||||
<?= icon('chevron-left') ?>
|
||||
</button>
|
||||
<button class="btn btn--icon lightbox__btn lightbox__next" type="button" data-lightbox-next aria-label="Nächstes Bild">
|
||||
<?= icon('chevron-right') ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@ $fee_options = $fee_options ?? [];
|
||||
$sent = isset($_GET['sent']);
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
||||
$errorMessages = [
|
||||
'validation' => 'Bitte prüfe deine Eingaben – die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'mail' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
@@ -50,7 +50,7 @@ $f = [
|
||||
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
|
||||
<?php if ($sent): ?>
|
||||
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen – wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
|
||||
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen. Wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
|
||||
<?php elseif ($error !== null): ?>
|
||||
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -22,7 +22,10 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($title) ?></title>
|
||||
<meta name="description" content="<?= e($description) ?>">
|
||||
<?php if ($current !== '404'): ?>
|
||||
<?php /* noindex: 404-Seiten und alles, was eine Page ausdrücklich anfordert
|
||||
($meta['noindex'], z. B. Stadionzeitungs-Entwürfe) — dann auch kein
|
||||
Self-Canonical, das wäre ein Widerspruch zur Nicht-Indexierung. */ ?>
|
||||
<?php if ($current !== '404' && empty($meta['noindex'])): ?>
|
||||
<link rel="canonical" href="<?= e($canonical) ?>">
|
||||
<?php else: ?>
|
||||
<meta name="robots" content="noindex">
|
||||
@@ -39,9 +42,12 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
|
||||
<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 /* Preload ohne ?v= (asset(..., false)): base.css fordert dieselbe Datei per
|
||||
@font-face ohne Query an, mit Version wären es für den Browser zwei URLs
|
||||
und der Preload bliebe ungenutzt. */ ?>
|
||||
<?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>
|
||||
<link rel="preload" href="<?= e(asset($font, false)) ?>" as="font" type="font/woff2" crossorigin>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>
|
||||
|
||||
@@ -7,7 +7,10 @@ declare(strict_types=1);
|
||||
* Burger-Toggle + Dropdowns übernimmt assets/js/nav.js.
|
||||
* Dropdowns funktionieren auch ohne JS (CSS :hover / :focus-within); JS ergänzt
|
||||
* nur aria-expanded, Escape und Klick-außerhalb-schließt.
|
||||
* CTAs rendert der Header (site-header__actions) — nicht hier.
|
||||
* CTAs: am Desktop rendert sie der Header (site-header__actions), im
|
||||
* Burger-Modus stehen sie am Ende des Menü-Overlays (site-nav__ctas) —
|
||||
* dieselbe Quelle (navigation.json → ctas), CSS blendet je Breakpoint
|
||||
* genau eine der beiden Stellen ein.
|
||||
*/
|
||||
$nav = json_load('navigation');
|
||||
|
||||
@@ -50,5 +53,12 @@ $href = static function (string $slug): string {
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty($nav['ctas'])): ?>
|
||||
<li class="site-nav__ctas">
|
||||
<?php foreach ($nav['ctas'] as $cta): ?>
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
84
app/components/news-artikel.php
Normal file
84
app/components/news-artikel.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Artikelkörper einer News-Detailseite. Props:
|
||||
* $image array (optional) — Aufmacher {base, widths, alt}, above the fold
|
||||
* $blocks array — data/news.json → artikel[], Blöcke mit `typ`:
|
||||
* text {absaetze: string[]} Fließtext
|
||||
* zwischentitel {text} h2 (h1 steht im detail-head)
|
||||
* bild {base, widths, alt, caption?}
|
||||
* zitat {text, quelle?}
|
||||
*
|
||||
* Ein unbekannter `typ` wird stillschweigend übersprungen; gemeldet wird er von
|
||||
* bin/preflight.php, damit ein Tippfehler vor dem Deploy auffällt und nicht erst
|
||||
* durch einen fehlenden Absatz auf der Seite.
|
||||
*
|
||||
* Fließtext läuft durch inline_links_html(): interne Deeplinks [Label](slug)
|
||||
* werden zu <a>, alles andere escaped. Kein rohes HTML in der JSON.
|
||||
*
|
||||
* Bilder sind wie bei den Veranstaltungen ohne JS normale Links auf die größte
|
||||
* Variante; mit JS fängt assets/js/lightbox.js den Klick ab. Das Overlay kommt
|
||||
* nur dazu, wenn der Artikel überhaupt Bilder hat.
|
||||
*/
|
||||
$image = $image ?? null;
|
||||
$blocks = $blocks ?? [];
|
||||
|
||||
$bilder = array_values(array_filter(
|
||||
$blocks,
|
||||
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild' && !empty($b['base']) && !empty($b['widths'])
|
||||
));
|
||||
$bildAnzahl = count($bilder);
|
||||
$bildIndex = 0;
|
||||
?>
|
||||
<article class="section news-article"<?= $bildAnzahl > 0 ? ' data-lightbox-gallery' : '' ?>>
|
||||
<div class="container">
|
||||
<?php if (!empty($image['base'])): ?>
|
||||
<figure class="news-article__lead">
|
||||
<?php component('img', [
|
||||
'image' => $image,
|
||||
'sizes' => '(max-width: 768px) 100vw, 72ch',
|
||||
'class' => 'news-article__lead-img',
|
||||
'eager' => true,
|
||||
]); ?>
|
||||
</figure>
|
||||
<?php endif; ?>
|
||||
<div class="news-article__body prose">
|
||||
<?php foreach ($blocks as $block): ?>
|
||||
<?php $typ = $block['typ'] ?? ''; ?>
|
||||
<?php if ($typ === 'text'): ?>
|
||||
<?php foreach ($block['absaetze'] ?? [] as $absatz): ?>
|
||||
<p><?= inline_links_html((string) $absatz) ?></p>
|
||||
<?php endforeach; ?>
|
||||
<?php elseif ($typ === 'zwischentitel' && !empty($block['text'])): ?>
|
||||
<h2 class="news-article__heading"><?= e($block['text']) ?></h2>
|
||||
<?php elseif ($typ === 'bild' && !empty($block['base']) && !empty($block['widths'])): ?>
|
||||
<figure class="news-article__figure">
|
||||
<a class="news-article__figure-link" href="<?= e(asset($block['base'] . '-' . max($block['widths']) . '.jpg')) ?>" data-lightbox="<?= $bildIndex++ ?>">
|
||||
<?php component('img', [
|
||||
'image' => $block,
|
||||
'sizes' => '(max-width: 768px) 100vw, 72ch',
|
||||
'class' => 'news-article__img',
|
||||
]); ?>
|
||||
<span class="visually-hidden">(größer ansehen)</span>
|
||||
</a>
|
||||
<?php if (!empty($block['caption'])): ?>
|
||||
<figcaption class="news-article__caption"><?= e($block['caption']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php elseif ($typ === 'zitat' && !empty($block['text'])): ?>
|
||||
<figure class="news-article__quote">
|
||||
<blockquote><p><?= e($block['text']) ?></p></blockquote>
|
||||
<?php if (!empty($block['quelle'])): ?>
|
||||
<figcaption class="news-article__quote-source"><?= e($block['quelle']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($bildAnzahl > 0): ?>
|
||||
<?php component('lightbox-overlay', ['label' => 'Bild aus dem Artikel', 'count' => $bildAnzahl]); ?>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
146
app/components/news-feed.php
Normal file
146
app/components/news-feed.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zeilen-Liste für die News-Seite: mischt redaktionelle Meldungen, Instagram-
|
||||
* Posts und Ergebnisse. Props:
|
||||
* $items array{date, title, text, image?, href?, link_text?, source}
|
||||
* `link_text` (optional) ist die sichtbare Weiterlesen-Zeile —
|
||||
* gesetzt vom Normalizer, nicht hier abgeleitet: die Komponente
|
||||
* soll die Quellen nicht kennen (gleiche Prop wie in
|
||||
* upcoming_highlights()/event-feature)
|
||||
* $empty_text string (optional)
|
||||
* $featured bool (optional) — neuester Eintrag als großer Aufmacher über
|
||||
* der Liste (gleiche Hierarchie-Idee wie das „Nächste Spiele"-
|
||||
* Band: ein Aufmacher, darunter Zeilen)
|
||||
* $filterable bool (optional) — Filter-Leiste nach Quelle (Alle/Meldungen/
|
||||
* Ergebnisse/Instagram), gleiche Bausteine wie der Stellen-Filter
|
||||
* auf /mitmachen (.filter-bar + .tab, assets/js/news-filter.js).
|
||||
* Ohne JS sind alle Einträge sichtbar — reine Progressive
|
||||
* Enhancement. Die Seite muss news-filter.js laden.
|
||||
* Kennt nichts von den drei Quellstrukturen — bekommt nur die normalisierte
|
||||
* Form (app/pages/news.php baut sie).
|
||||
*
|
||||
* Bewusst eine Zeilen-Liste statt eines Karten-Grids (wie termine-list.php):
|
||||
* die Einträge sind zu unterschiedlich hoch (hohe Bild-Posts neben kurzen
|
||||
* Text-Ergebnissen) — ein Grid reißt dabei Löcher, weil kurze Karten den
|
||||
* Platz neben einer hohen Karte nicht auffüllen können. Eine einspaltige
|
||||
* Liste hat dieses Problem nicht, jede Zeile ist einfach so hoch wie ihr
|
||||
* eigener Inhalt.
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$emptyText = $empty_text ?? 'Aktuell keine News.';
|
||||
$featured = !empty($featured);
|
||||
$filterable = !empty($filterable);
|
||||
|
||||
$sourceLabels = [
|
||||
'news' => 'Meldung',
|
||||
'instagram' => 'Instagram',
|
||||
'ergebnis' => 'Ergebnis',
|
||||
];
|
||||
$filters = [
|
||||
'all' => 'Alle',
|
||||
'news' => 'Meldungen',
|
||||
'ergebnis' => 'Ergebnisse',
|
||||
'instagram' => 'Instagram',
|
||||
];
|
||||
?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted"><?= e($emptyText) ?></p>
|
||||
<?php else: ?>
|
||||
<?php if ($filterable): ?>
|
||||
<div class="filter-bar" role="group" aria-label="News nach Quelle filtern" data-news-filter>
|
||||
<?php $i = 0; foreach ($filters as $value => $label): ?>
|
||||
<button type="button" class="tab filter-bar__tab" data-filter="<?= e($value) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($featured):
|
||||
$feature = array_shift($items);
|
||||
$featureTag = !empty($feature['href']) ? 'a' : 'div';
|
||||
$featureExternal = $feature['source'] === 'instagram';
|
||||
$featureAttrs = !empty($feature['href'])
|
||||
? ' href="' . e($feature['href']) . '"' . ($featureExternal ? ' target="_blank" rel="noopener"' : '')
|
||||
: '';
|
||||
$featureHasMedia = !empty($feature['image']) || !empty($feature['crest']);
|
||||
?>
|
||||
<div class="news-feature" data-source="<?= e($feature['source']) ?>">
|
||||
<<?= $featureTag ?> class="news-feature__link<?= $featureHasMedia ? ' news-feature__link--media' : '' ?>"<?= $featureAttrs ?>>
|
||||
<?php if (!empty($feature['image'])): ?>
|
||||
<div class="news-feature__media">
|
||||
<?php component('img', ['image' => $feature['image'], 'sizes' => '(max-width: 768px) 100vw, 40vw', 'class' => 'news-feature__img', 'eager' => true]); ?>
|
||||
</div>
|
||||
<?php elseif (!empty($feature['crest'])): ?>
|
||||
<div class="news-feature__media news-feature__media--crest">
|
||||
<?php component('crest', ['src' => $feature['crest']['src'], 'name' => $feature['crest']['name']]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="news-feature__body">
|
||||
<p class="news-row__meta">
|
||||
<span class="news-row__badge news-row__badge--<?= e($feature['source']) ?>"><?= e($sourceLabels[$feature['source']] ?? $feature['source']) ?></span>
|
||||
<?php if ($feature['date'] !== ''): ?>
|
||||
<time datetime="<?= e($feature['date']) ?>"><?= e(german_date($feature['date'])) ?></time>
|
||||
<?php endif; ?>
|
||||
<?php if ($featureExternal): ?>
|
||||
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="news-feature__title"><?= e($feature['title']) ?></p>
|
||||
<?php if (!empty($feature['text']) && $feature['text'] !== $feature['title']): ?>
|
||||
<p class="news-feature__text"><?= e($feature['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($feature['link_text'])): ?>
|
||||
<p class="news-feature__more"><?= e($feature['link_text']) ?> <?= icon('arrow-right') ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</<?= $featureTag ?>>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<ul class="news-list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$tag = !empty($item['href']) ? 'a' : 'div';
|
||||
$external = $item['source'] === 'instagram';
|
||||
$attrs = '';
|
||||
if (!empty($item['href'])) {
|
||||
$attrs = ' href="' . e($item['href']) . '"' . ($external ? ' target="_blank" rel="noopener"' : '');
|
||||
}
|
||||
?>
|
||||
<li class="news-row" data-source="<?= e($item['source']) ?>">
|
||||
<<?= $tag ?> class="news-row__link"<?= $attrs ?>>
|
||||
<?php if (!empty($item['image'])): ?>
|
||||
<div class="news-row__media">
|
||||
<?php component('img', ['image' => $item['image'], 'sizes' => '96px', 'class' => 'news-row__img']); ?>
|
||||
</div>
|
||||
<?php elseif (!empty($item['crest'])): ?>
|
||||
<div class="news-row__media news-row__media--crest">
|
||||
<?php component('crest', ['src' => $item['crest']['src'], 'name' => $item['crest']['name']]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="news-row__body">
|
||||
<p class="news-row__meta">
|
||||
<span class="news-row__badge news-row__badge--<?= e($item['source']) ?>"><?= e($sourceLabels[$item['source']] ?? $item['source']) ?></span>
|
||||
<?php if ($item['date'] !== ''): ?>
|
||||
<time datetime="<?= e($item['date']) ?>"><?= e(german_date($item['date'])) ?></time>
|
||||
<?php endif; ?>
|
||||
<?php if ($external): ?>
|
||||
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="news-row__title"><?= e($item['title']) ?></p>
|
||||
<?php if (!empty($item['text']) && $item['text'] !== $item['title']): ?>
|
||||
<p class="news-row__text"><?= e($item['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($item['link_text'])): ?>
|
||||
<p class="news-row__more"><?= e($item['link_text']) ?> <?= icon('arrow-right') ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</<?= $tag ?>>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($filterable): ?>
|
||||
<p class="text-muted" data-news-empty hidden>In dieser Kategorie gibt es gerade nichts Neues.</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
@@ -4,12 +4,31 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sponsoren/Partner-Logogrid. Props:
|
||||
* $intro array{title, text, cta?} (aus home.json)
|
||||
* $intro array{title, text, cta?, only?} (aus home.json bzw. dem
|
||||
* partner-Block einer Veranstaltung)
|
||||
* $anchor string (optional, Default 'partner') — Sektions-ID
|
||||
* Logos kommen aus data/partners.json (Single Source of Truth).
|
||||
*
|
||||
* only: Liste von Partnernamen (exakt wie in partners.json) — für Seiten, die
|
||||
* nur einen Teil der Partner zeigen, etwa die Unterstützer eines einzelnen
|
||||
* Fests. Fehlt only, stehen alle da (Verhalten der Startseite). Passt kein
|
||||
* Name (Tippfehler, umbenannter Partner), stehen bewusst wieder ALLE da statt
|
||||
* einer leeren Logo-Wand.
|
||||
*/
|
||||
$anchor = $anchor ?? 'partner';
|
||||
$partners = json_load('partners')['partners'] ?? [];
|
||||
|
||||
if (!empty($intro['only'])) {
|
||||
$auswahl = array_values(array_filter(
|
||||
$partners,
|
||||
static fn (array $p): bool => in_array($p['name'] ?? '', $intro['only'], true)
|
||||
));
|
||||
if ($auswahl !== []) {
|
||||
$partners = $auswahl;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<section class="section partners" id="partner">
|
||||
<section class="section partners" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
<h2><?= e($intro['title']) ?></h2>
|
||||
<p class="partners__intro text-muted"><?= e($intro['text']) ?></p>
|
||||
|
||||
@@ -5,54 +5,83 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Visitenkarte für eine Person. Props:
|
||||
* $person array{department, name, phone?, email?, image?, whatsapp?}
|
||||
* $level string — Überschriften-Ebene des department-Headings (Default 'h3').
|
||||
* $level string — Überschriften-Ebene des NAMENS (Default 'h3'; der Name ist
|
||||
* das Heading der Karte, die Abteilung nur eine Kicker-Zeile darüber).
|
||||
* Eingebettet (z. B. in position-card unter einem h3) auf 'h4' setzen,
|
||||
* damit die Heading-Outline nicht abflacht. Styling bleibt klassenbasiert.
|
||||
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg).
|
||||
* Kontakt-Link: phone → tel:, sonst email → mailto:.
|
||||
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person).
|
||||
* $hide_department bool — Kicker ausblenden, wenn der Kontext die Abteilung
|
||||
* schon trägt (person-grid mit group_by_department).
|
||||
* Ohne image rendert ein Initialen-Avatar im Vereins-Look (dunkle Fläche,
|
||||
* große Initialen — dasselbe Fallback-Prinzip wie crest.php), kein generisches
|
||||
* Silhouetten-Bild.
|
||||
* Kontaktwege als Buttons (mobil sofort tippbar): phone → Anrufen (+ WhatsApp
|
||||
* bei whatsapp=true), sonst email → E-Mail. Nummer bzw. Adresse stehen
|
||||
* zusätzlich als Textzeile (ablesbar).
|
||||
* Es sind reine Icon-Knöpfe im globalen .btn--icon-Format (44px, rund) und sie
|
||||
* sitzen rechts NEBEN dem Text, nicht darunter — das spart pro Karte eine ganze
|
||||
* Button-Zeile Höhe. Weil das Label wegfällt, trägt jeder Knopf den
|
||||
* Personennamen im aria-label: im Ansprechpartner-Grid stünden sonst zwei
|
||||
* Dutzend Links namens „Anrufen" untereinander.
|
||||
*/
|
||||
$level = $level ?? 'h3';
|
||||
if (!in_array($level, ['h2', 'h3', 'h4', 'h5'], true)) {
|
||||
$level = 'h3';
|
||||
}
|
||||
$hideDepartment = !empty($hide_department);
|
||||
$image = $person['image'] ?? null;
|
||||
$src = $image ?: 'img/persons/platzhalter.jpg';
|
||||
|
||||
if (!empty($person['phone'])) {
|
||||
$contactHref = 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']);
|
||||
$contactLabel = $person['phone'];
|
||||
$contactIcon = 'telephone';
|
||||
} elseif (!empty($person['email'])) {
|
||||
$contactHref = 'mailto:' . $person['email'];
|
||||
$contactLabel = $person['email'];
|
||||
$contactIcon = 'envelope';
|
||||
} else {
|
||||
$contactHref = null;
|
||||
$initials = '';
|
||||
foreach (preg_split('/[\s.-]+/', trim((string) ($person['name'] ?? ''))) ?: [] as $word) {
|
||||
if ($word !== '') {
|
||||
$initials .= mb_substr($word, 0, 1);
|
||||
}
|
||||
if (mb_strlen($initials) >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$initials = mb_strtoupper($initials !== '' ? $initials : '?');
|
||||
|
||||
$telHref = !empty($person['phone']) ? 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']) : null;
|
||||
$mailHref = ($telHref === null && !empty($person['email'])) ? 'mailto:' . $person['email'] : null;
|
||||
$waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
|
||||
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
|
||||
: null;
|
||||
$detail = $person['phone'] ?? $person['email'] ?? '';
|
||||
?>
|
||||
<article class="person-card">
|
||||
<?php if ($image): ?>
|
||||
<div class="person-card__media">
|
||||
<img src="<?= e(asset($src)) ?>"
|
||||
alt="<?= $image ? e($person['name']) : '' ?>"
|
||||
<img src="<?= e(asset($image)) ?>"
|
||||
alt="<?= e($person['name']) ?>"
|
||||
width="480" height="600" loading="lazy" decoding="async">
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="person-card__media person-card__media--initials" aria-hidden="true">
|
||||
<span class="person-card__initials"><?= e($initials) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="person-card__body">
|
||||
<<?= $level ?> class="person-card__department"><?= e($person['department']) ?></<?= $level ?>>
|
||||
<p class="person-card__name"><?= e($person['name']) ?></p>
|
||||
<?php if ($contactHref !== null): ?>
|
||||
<a class="person-card__contact" href="<?= e($contactHref) ?>">
|
||||
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span>
|
||||
</a>
|
||||
<div class="person-card__text">
|
||||
<?php if (!$hideDepartment): ?>
|
||||
<p class="person-card__department"><?= e($person['department']) ?></p>
|
||||
<?php endif; ?>
|
||||
<<?= $level ?> class="person-card__name"><?= e($person['name']) ?></<?= $level ?>>
|
||||
<?php if ($detail !== ''): ?>
|
||||
<p class="person-card__detail"><?= e($detail) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($telHref !== null || $mailHref !== null): ?>
|
||||
<div class="person-card__actions">
|
||||
<?php if ($telHref !== null): ?>
|
||||
<a class="btn btn--icon" href="<?= e($telHref) ?>" aria-label="<?= e($person['name']) ?> anrufen"><?= icon('telephone') ?></a>
|
||||
<?php endif; ?>
|
||||
<?php if ($waHref !== null): ?>
|
||||
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener">
|
||||
<?= icon('whatsapp') ?><span>WhatsApp</span>
|
||||
</a>
|
||||
<a class="btn btn--outline btn--icon" href="<?= e($waHref) ?>" target="_blank" rel="noopener" aria-label="<?= e($person['name']) ?> per WhatsApp schreiben"><?= icon('whatsapp') ?></a>
|
||||
<?php endif; ?>
|
||||
<?php if ($mailHref !== null): ?>
|
||||
<a class="btn btn--icon" href="<?= e($mailHref) ?>" aria-label="<?= e($person['name']) ?> eine E-Mail schreiben"><?= icon('envelope') ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -11,12 +11,18 @@ declare(strict_types=1);
|
||||
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
|
||||
* $anchor string — Sektions-ID (Default 'ansprechpartner').
|
||||
* $tinted bool — aufgehelltes Vollbreite-Band (Default true).
|
||||
* $columns int — Spaltenanzahl auf Desktop (null = automatisch bis 5).
|
||||
* $group_by_department bool — Karten nach Abteilung untergruppieren
|
||||
* (Zwischenüberschrift je Abteilung, Karten dann ohne eigenen
|
||||
* Abteilungs-Kicker; löst die Waisen-Optik bei 5 Karten im
|
||||
* 4er-Raster). Reihenfolge = JSON-Reihenfolge.
|
||||
* $embedded bool — nur Panel (div + h2 + Grid) statt eigener section,
|
||||
* für Seiten, die mehrere Grids in EINER Sektion bündeln
|
||||
* (/ansprechpartner mit Bereichs-Tabs). tinted/anchor der
|
||||
* Sektion liefert dann die Seite.
|
||||
*/
|
||||
$data = json_load('ansprechpartner');
|
||||
$anchor = $anchor ?? 'ansprechpartner';
|
||||
$tinted = $tinted ?? true;
|
||||
$columns = $columns ?? null;
|
||||
$title = $title ?? $data['title'];
|
||||
$intro = $intro ?? ($data['intro'] ?? '');
|
||||
|
||||
@@ -37,14 +43,11 @@ if (!empty($names)) {
|
||||
if ($persons === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
|
||||
if (!empty($embedded)): ?>
|
||||
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<?php if ($intro !== ''): ?>
|
||||
<p class="persons__intro"><?= e($intro) ?></p>
|
||||
<?php endif; ?>
|
||||
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>">
|
||||
<ul class="persons__grid">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person]); ?>
|
||||
@@ -52,4 +55,38 @@ if ($persons === []) {
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php return; endif; ?>
|
||||
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<?php if ($intro !== ''): ?>
|
||||
<p class="persons__intro"><?= e($intro) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($group_by_department)): ?>
|
||||
<?php
|
||||
$byDepartment = [];
|
||||
foreach ($persons as $person) {
|
||||
$byDepartment[$person['department'] ?? ''][] = $person;
|
||||
}
|
||||
?>
|
||||
<?php foreach ($byDepartment as $departmentLabel => $departmentPersons): ?>
|
||||
<h3 class="persons__group-title"><?= e($departmentLabel) ?></h3>
|
||||
<ul class="persons__grid persons__grid--grouped">
|
||||
<?php foreach ($departmentPersons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person, 'level' => 'h4', 'hide_department' => true]); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<ul class="persons__grid">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person]); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,25 +4,68 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Alternierende Bild/Text-Sektion. Props:
|
||||
* $section array{id, title, text, image, flip?, tinted?, cta?} (aus home.json o.ä.)
|
||||
* $section array{id, title, text, image?, gallery?, flip?, tinted?, polaroid?, cta?} (aus home.json o.ä.)
|
||||
* $level int Überschriften-Ebene (default 2)
|
||||
* tinted: sanft aufgehelltes Panel hinter dem Inhalt (Abgrenzung vom Seitenhintergrund).
|
||||
* polaroid: Bild als Polaroid (dicker heller Rahmen, leicht gekippt; flip kippt gegenläufig).
|
||||
* Trägt ein Bild ein optionales caption-Feld, erscheint es als Bildunterschrift
|
||||
* auf dem weißen Unterrand des Rahmens (Abel-Versalien, wie Historie-Captions).
|
||||
* gallery: Array von Bildern statt image — erstes Bild groß, weitere darunter
|
||||
* halbbreit nebeneinander (Collage). Mit polaroid kippen die Rahmen abwechselnd.
|
||||
* text: String oder Liste von Absätzen (mehr als zwei sollten hier nicht stehen —
|
||||
* dann ist es keine Bild/Text-Sektion mehr).
|
||||
*/
|
||||
$level = $level ?? 2;
|
||||
$flip = !empty($section['flip']);
|
||||
$tinted = !empty($section['tinted']);
|
||||
$polaroid = !empty($section['polaroid']);
|
||||
// Ohne image UND ohne gallery läuft die Sektion einspaltig („split--plain") —
|
||||
// für Textabschnitte, für die es (noch) kein Foto gibt, z. B. „Anfahrt und
|
||||
// Parken" auf einer Veranstaltungs-Seite. Ohne diesen Zweig liefe img.php
|
||||
// unten auf ein fehlendes Bild.
|
||||
$hasMedia = !empty($section['image']) || !empty($section['gallery']);
|
||||
$paragraphs = is_array($section['text']) ? $section['text'] : [$section['text']];
|
||||
?>
|
||||
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($section['id']) ?>">
|
||||
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?><?= $polaroid ? ' split--polaroid' : '' ?><?= $hasMedia ? '' : ' split--plain' ?>" 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 foreach ($paragraphs as $paragraph): ?>
|
||||
<p><?= e($paragraph) ?></p>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty($section['cta'])): ?>
|
||||
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($hasMedia): ?>
|
||||
<div class="split__media">
|
||||
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
|
||||
<?php if (!empty($section['gallery'])): ?>
|
||||
<div class="split__gallery">
|
||||
<?php foreach ($section['gallery'] as $i => $galleryImage): ?>
|
||||
<?php $sizes = $i === 0 ? '(max-width: 992px) 100vw, 50vw' : '(max-width: 992px) 50vw, 25vw'; ?>
|
||||
<?php if ($polaroid): ?>
|
||||
<figure class="polaroid">
|
||||
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
|
||||
<?php if (!empty($galleryImage['caption'])): ?>
|
||||
<figcaption class="polaroid__caption"><?= e($galleryImage['caption']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php elseif ($polaroid): ?>
|
||||
<figure class="polaroid">
|
||||
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
|
||||
<?php if (!empty($section['image']['caption'])): ?>
|
||||
<figcaption class="polaroid__caption"><?= e($section['image']['caption']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -15,7 +15,7 @@ $club = json_load('club');
|
||||
$sent = isset($_GET['sent']);
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
||||
$errorMessages = [
|
||||
'validation' => 'Bitte prüfe deine Eingaben – die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'mail' => 'Deine Anfrage konnte gerade nicht gesendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
|
||||
47
app/components/stadionzeitung-cards.php
Normal file
47
app/components/stadionzeitung-cards.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ausgaben-Archiv der Stadionzeitung, ganze Karte verlinkt auf die Ausgabe.
|
||||
* Props: $issues array aus data/stadionzeitung.json (bereits neueste zuerst).
|
||||
* Leer → freundlicher Hinweis statt leerer Sektion (nie brechen).
|
||||
*/
|
||||
$issues = $issues ?? [];
|
||||
?>
|
||||
<section class="section stadionzeitung-cards">
|
||||
<div class="container">
|
||||
<h2 class="visually-hidden">Ausgaben der Stadionzeitung</h2>
|
||||
<?php if ($issues === []): ?>
|
||||
<p class="text-muted">Noch keine Ausgabe digital verfügbar — zur nächsten Heimspiel-Stadionzeitung schauen wir wieder vorbei.</p>
|
||||
<?php else: ?>
|
||||
<ul class="stadionzeitung-cards__grid">
|
||||
<?php foreach ($issues as $issue): ?>
|
||||
<li>
|
||||
<a class="stadionzeitung-card" href="<?= e(url('stadionzeitung/' . $issue['slug'])) ?>">
|
||||
<div class="stadionzeitung-card__media">
|
||||
<?php $firstPage = $issue['images'][0] ?? null; ?>
|
||||
<?php if ($firstPage !== null && ($firstPage['type'] ?? 'image') === 'live' && ($firstPage['page'] ?? '') === 'cover'): ?>
|
||||
<?php /* Seit dem automatischen Cover (Phase 3) beginnt jede Ausgabe mit der
|
||||
echten, live gerenderten Titelseite statt einem Canva-Bild — die
|
||||
dient hier direkt als Karten-Vorschau, keine zweite Bildquelle. */ ?>
|
||||
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
|
||||
<?php elseif ($firstPage !== null): ?>
|
||||
<?php component('img', ['image' => $firstPage, 'sizes' => '(max-width: 600px) 100vw, 360px', 'class' => 'stadionzeitung-card__img']); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="stadionzeitung-card__body">
|
||||
<h3 class="stadionzeitung-card__name">Gegen <?= e($issue['opponent']) ?></h3>
|
||||
<p class="stadionzeitung-card__meta">
|
||||
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
||||
· <?= (int) $issue['pages'] ?> Seiten
|
||||
</p>
|
||||
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
129
app/components/stadionzeitung-cover.php
Normal file
129
app/components/stadionzeitung-cover.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Automatisch generiertes Stadionzeitung-Cover (Phase 3) — ersetzt das bisherige
|
||||
* Canva-Titelbild. Einzige fotografische Stadionzeitungs-Seite: Vereins-Hero-Bild
|
||||
* als Hintergrund (dieselbe Quelle wie die Startseite, data/home.json — nie
|
||||
* duplizieren) mit Abdunklungs-Verlauf, das freigestellte Spielerfoto ist der
|
||||
* Star (sehr großformatig, rechts randabfallend). Kopf wie ein echtes
|
||||
* Zeitschriften-Titelblatt: kleine rote Kicker-Zeile (Saison/Datum), riesiger
|
||||
* Masthead mit Logo auf gleicher Höhe, dezente Tagline darunter. Unten links
|
||||
* gestapelt: Begegnung als große freie Typografie (kein Kasten) über einer
|
||||
* roten Trennlinie und Slogan + QR-Plakette. Dünne Passepartout-Rahmenlinie
|
||||
* gibt den "druckfertig"-Effekt.
|
||||
*
|
||||
* Selbst-Containment: alle Maße hier laufen über CSS Container Queries (cqi,
|
||||
* relativ zur eigenen Breite von .stadionzeitung-cover), NICHT über die
|
||||
* Website-Schriftgrößen-Tokens aus tokens.css — das Cover sieht dadurch in
|
||||
* jedem Kontext (Karten-Vorschau, Swipe-Viewer, Druck) proportional exakt
|
||||
* gleich aus, unabhängig von Viewport/Root-Schriftgröße. Einzige Ausnahme
|
||||
* ganz bewusst: die crest-Komponente (geteiltes Bauteil, bekommt ihre Größe
|
||||
* über --crest-size lokal in cqi mitgegeben, keine eigene Kopie nötig).
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json: opponent, date, team_key,
|
||||
* player_image?, qr_image?)
|
||||
*
|
||||
* Gegner-Wappen wird zur Laufzeit über den Anstoß-Tag in data/matchcenter.json
|
||||
* gesucht (next_matches/last_results des Teams) — nie im JSON dupliziert. Kein
|
||||
* Treffer (z. B. Freundschaftsspiel gegen eine Spielgemeinschaft, die der BFV
|
||||
* nicht führt) → das eigene Wappen kommt trotzdem aus dem lokalen Vereinslogo,
|
||||
* der Gegner läuft über den Initialen-Fallback der crest-Komponente.
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$opponent = (string) ($issue['opponent'] ?? '');
|
||||
$date = (string) ($issue['date'] ?? '');
|
||||
$teamKey = (string) ($issue['team_key'] ?? '');
|
||||
$playerImage = $issue['player_image'] ?? null;
|
||||
$qrImage = $issue['qr_image'] ?? null;
|
||||
|
||||
$club = json_load('club');
|
||||
$heroImage = json_load('home')['hero']['image'] ?? null;
|
||||
|
||||
$team = null;
|
||||
foreach (json_load('matchcenter')['teams'] ?? [] as $t) {
|
||||
if (($t['key'] ?? '') === $teamKey) {
|
||||
$team = $t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$teamName = (string) ($team['name'] ?? '');
|
||||
$league = (string) ($team['league'] ?? '');
|
||||
$season = (string) ($team['season'] ?? '');
|
||||
|
||||
$match = null;
|
||||
foreach (array_merge($team['next_matches'] ?? [], $team['last_results'] ?? []) as $candidate) {
|
||||
if (str_starts_with((string) ($candidate['kickoff'] ?? ''), $date)) {
|
||||
$match = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$ownCrest = 'img/logo.svg';
|
||||
$opponentCrest = null;
|
||||
if ($match !== null) {
|
||||
$ownCrest = $match['is_home'] ? $match['home_crest'] : $match['away_crest'];
|
||||
$opponentCrest = $match['is_home'] ? $match['away_crest'] : $match['home_crest'];
|
||||
}
|
||||
|
||||
$linkText = preg_replace('~^https?://(www\.)?~', '', rtrim((string) config('base_url'), '/'));
|
||||
$kicker = trim($season . ($date !== '' ? ' · ' . german_date($date) : ''));
|
||||
|
||||
// Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —
|
||||
// erscheint automatisch, sobald die Ausgabe ein Interview trägt
|
||||
// (interview-Feld, chat-gepflegt wie das Vorwort).
|
||||
$teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
||||
?>
|
||||
<div class="stadionzeitung-cover">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($playerImage)): ?>
|
||||
<img class="stadionzeitung-cover__player" src="<?= e(asset($playerImage)) ?>" alt="" loading="lazy">
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-cover__inner">
|
||||
<header class="stadionzeitung-cover__masthead">
|
||||
<?php if ($kicker !== ''): ?>
|
||||
<p class="stadionzeitung-cover__kicker"><?= e($kicker) ?></p>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-cover__masthead-row">
|
||||
<img class="stadionzeitung-cover__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<p class="stadionzeitung-cover__wordmark">Stadionzeitung</p>
|
||||
</div>
|
||||
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
|
||||
</header>
|
||||
<div class="stadionzeitung-cover__corner">
|
||||
<div class="stadionzeitung-cover__headline">
|
||||
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
|
||||
ruhiger) — die beiden Logos sprechen für sich. */ ?>
|
||||
<div class="stadionzeitung-cover__crests">
|
||||
<?php component('crest', ['src' => $ownCrest, 'name' => $club['name'] ?? '']); ?>
|
||||
<?php component('crest', ['src' => $opponentCrest, 'name' => $opponent]); ?>
|
||||
</div>
|
||||
<p class="stadionzeitung-cover__matchup">Heimspiel gegen <?= e($opponent) ?></p>
|
||||
<?php if ($teamName !== '' || $league !== ''): ?>
|
||||
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($teaser !== ''): ?>
|
||||
<p class="stadionzeitung-cover__teaser">
|
||||
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>
|
||||
<?= e($teaser) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php /* Bewusst ohne Vereins-Slogan (Felix, 29.07.2026: ruhiger) — der
|
||||
Slogan bleibt Footer/JSON-LD der Website vorbehalten. */ ?>
|
||||
<footer class="stadionzeitung-cover__footer">
|
||||
<?php /* QR bewusst minimal (Felix, 29.07.2026): Code + eine Zeile,
|
||||
keine Plakette/kein Rahmen mehr. */ ?>
|
||||
<?php if (!empty($qrImage)): ?>
|
||||
<div class="stadionzeitung-cover__digital">
|
||||
<img class="stadionzeitung-cover__qr" src="<?= e(asset($qrImage)) ?>" alt="" width="60" height="60" loading="lazy">
|
||||
<span class="stadionzeitung-cover__link">Digital lesen · <?= e($linkText) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
98
app/components/stadionzeitung-druck.php
Normal file
98
app/components/stadionzeitung-druck.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Druckfassung einer Stadionzeitung-Ausgabe (Route ?druck=1, siehe
|
||||
* app/pages/stadionzeitung-ausgabe.php) — die Seiten in BROSCHÜREN-Reihenfolge
|
||||
* statt Lesereihenfolge: das Heft wird als A4 quer beidseitig gedruckt, die
|
||||
* Blätter werden gestapelt und mittig gefaltet. Damit dabei vorn das Cover
|
||||
* und innen die richtige Abfolge liegt, trägt Blatt 1 vorne [letzte Seite |
|
||||
* Seite 1], hinten [Seite 2 | vorletzte Seite] usw. — die klassische
|
||||
* Sattelheftungs-Montage. Deshalb liegt die Montage HIER im PHP (die
|
||||
* DOM-Reihenfolge selbst ist die Druckreihenfolge), nicht in Druck-CSS:
|
||||
* CSS kann Seiten nicht über Blattgrenzen hinweg umsortieren.
|
||||
*
|
||||
* Die Seitenzahl wird dafür auf ein Vielfaches von 4 gebracht: fehlende
|
||||
* Seiten füllt eine weiße Leerseite VOR der Rückseite auf (die Rückseite
|
||||
* bleibt immer außen). Die Regel fürs Heft bleibt trotzdem: Lücken beim
|
||||
* Erzeugen gemeinsam sauber lösen, Leerseiten sind der Notnagel.
|
||||
*
|
||||
* Auf dem Bildschirm zeigt die Route eine Blatt-Vorschau (jedes Blatt als
|
||||
* A4-quer-Fläche mit Beschriftung „Blatt 1 · Vorderseite") plus
|
||||
* Druck-Anleitung; assets/js/stadionzeitung-druck.js liefert den
|
||||
* „Jetzt drucken"-Button (progressive enhancement — ohne JS druckt der
|
||||
* Browser-Dialog dieselbe Ansicht).
|
||||
*
|
||||
* Props: $issue Eintrag aus data/stadionzeitung.json
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$pages = $issue['images'] ?? [];
|
||||
if ($pages === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasLive = false;
|
||||
foreach ($pages as $entry) {
|
||||
if (($entry['type'] ?? 'image') === 'live') {
|
||||
$hasLive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
|
||||
|
||||
// Auf Vielfaches von 4 auffüllen — Leerseiten direkt vor der letzten Seite,
|
||||
// damit die gelieferte Rückseite die äußere Rückseite des Hefts bleibt.
|
||||
$fill = (4 - count($pages) % 4) % 4;
|
||||
if ($fill > 0) {
|
||||
array_splice($pages, count($pages) - 1, 0, array_fill(0, $fill, ['type' => 'leer']));
|
||||
}
|
||||
$n = count($pages);
|
||||
|
||||
// Sattelheftung: Blatt k (0-basiert) trägt vorne [n-1-2k | 2k] und hinten
|
||||
// [2k+1 | n-2-2k] (0-basierte Seitenindizes). Beim beidseitigen Druck an der
|
||||
// KURZEN Kante spiegeln, sonst steht jede Rückseite kopf.
|
||||
$sides = [];
|
||||
for ($k = 0; $k < intdiv($n, 4); $k++) {
|
||||
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Vorderseite', 'pages' => [$n - 1 - 2 * $k, 2 * $k]];
|
||||
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Rückseite', 'pages' => [2 * $k + 1, $n - 2 - 2 * $k]];
|
||||
}
|
||||
?>
|
||||
<section class="stadionzeitung-druck">
|
||||
<ol class="stadionzeitung-druck__blaetter">
|
||||
<?php foreach ($sides as $side): ?>
|
||||
<li class="stadionzeitung-druck__blatt">
|
||||
<p class="stadionzeitung-druck__blatt-label" aria-hidden="true"><?= e($side['label']) ?></p>
|
||||
<div class="stadionzeitung-druck__flaeche">
|
||||
<?php foreach ($side['pages'] as $pageIndex): ?>
|
||||
<div class="stadionzeitung-druck__slot">
|
||||
<?php component('stadionzeitung-seite', [
|
||||
'entry' => $pages[$pageIndex],
|
||||
'issue' => $issue,
|
||||
'data' => $liveData,
|
||||
// In der Druckfassung wird JEDE Seite sofort geladen:
|
||||
// lazy geladene Bilder, die nie im Sichtfeld waren,
|
||||
// druckt Chrome sonst als leere Flächen.
|
||||
'eager' => true,
|
||||
]); ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
<?php /* Anleitung + Druck-Button bewusst UNTER den Blättern (Wunsch Felix,
|
||||
29.07.2026): erst die Vorschau komplett durchsehen, ganz unten
|
||||
drucken. */ ?>
|
||||
<div class="stadionzeitung-druck__hilfe">
|
||||
<h2 class="stadionzeitung-druck__hilfe-title">Drucken</h2>
|
||||
<p>
|
||||
<?= intdiv($n, 4) ?> Blätter A4 quer<?= $fill > 0 ? ', mit ' . $fill . ' weißen Füllseiten auf ' . $n . ' Seiten aufgefüllt' : ' (' . $n . ' Seiten)' ?>.
|
||||
Beidseitig drucken und dabei <strong>an der kurzen Kante spiegeln</strong>,
|
||||
danach alle Blätter aufeinanderlegen und mittig falten — fertig ist das Heft.
|
||||
</p>
|
||||
<button class="btn stadionzeitung-druck__print-btn" type="button" data-druck-print hidden>
|
||||
<?= icon('printer') ?> Jetzt drucken
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
72
app/components/stadionzeitung-ergebnisse.php
Normal file
72
app/components/stadionzeitung-ergebnisse.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ergebnis-Rückblick der Stadionzeitung (Live-Seite) — die letzten Ergebnisse
|
||||
* aller Mannschaften als gestaltete Seite (Wappen + Ergebnis aus
|
||||
* data/matchcenter.json), bewusst KEINE Instagram-Post-Bilder: die Seite
|
||||
* funktioniert damit für jedes Spiel, auch ohne passenden Post.
|
||||
*
|
||||
* Zwei Zeilen pro Spiel (Heim über Gast) statt einer langen Einzelzeile —
|
||||
* Vereinsnamen wie „(SG) 1.FC Michelau / 1.FC Fortuna Roth" passen auf der
|
||||
* schmalen A5-Seite nie nebeneinander. Dasselbe Bauprinzip wie match-feature
|
||||
* im „Nächste Spiele"-Band.
|
||||
*
|
||||
* Props: $groups stadionzeitung_live_data()['ergebnisse'] — je Team:
|
||||
* team_name/league/matches[] (Matchcenter-Rohform inkl.
|
||||
* goals_home/goals_away, nur Spiele mit erfasstem Ergebnis).
|
||||
*/
|
||||
$groups = $groups ?? [];
|
||||
|
||||
// Matchcenter-Ticket wohnt hier (nicht auf der volleren Vorschau-Seite):
|
||||
// geteilter QR, von bin/stadionzeitung-add.php mit der aktuellen base_url
|
||||
// erzeugt — fehlt er, erscheint nur der Text-Link.
|
||||
$matchcenterQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-matchcenter.png')
|
||||
? 'img/stadionzeitung/qr-matchcenter.png'
|
||||
: null;
|
||||
$matchcenterLink = preg_replace('~^https?://(www\.)?~', '', abs_url('matchcenter'));
|
||||
?>
|
||||
<div class="stadionzeitung-ergebnisse">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Rückblick</p>
|
||||
<h2 class="stadionzeitung-page__heading">Letzte Ergebnisse</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($groups === []): ?>
|
||||
<p class="text-muted">Aktuell keine Ergebnisse.</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<section class="stadionzeitung-ergebnisse__team">
|
||||
<h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3>
|
||||
<?php foreach ($group['matches'] ?? [] as $match): ?>
|
||||
<div class="stadionzeitung-ergebnisse__match">
|
||||
<p class="stadionzeitung-ergebnisse__meta">
|
||||
<?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?>
|
||||
</p>
|
||||
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null],
|
||||
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null]] as $side): ?>
|
||||
<div class="stadionzeitung-ergebnisse__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__row--own' : '' ?>">
|
||||
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
|
||||
<span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span>
|
||||
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $side['goals']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Alle Spiele, Ergebnisse und Tabellen im Matchcenter</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($matchcenterLink) ?></p>
|
||||
</div>
|
||||
<?php if ($matchcenterQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($matchcenterQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
58
app/components/stadionzeitung-historie.php
Normal file
58
app/components/stadionzeitung-historie.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Historie-Seite der Stadionzeitung (Live-Seite) — teasert die neu auf der
|
||||
* Website veröffentlichte Vereinsgeschichte (/historie) an und ruft dazu auf,
|
||||
* Material an die Historie-Mailadresse zu schicken (Zulauf für die Chronik
|
||||
* zum 120-jährigen Bestehen 2028, siehe CLAUDE.md → Chronik). Fester
|
||||
* Baustein jeder Ausgabe (Felix, 29.07.2026).
|
||||
*
|
||||
* Bilder und Mailadresse kommen aus den geteilten Quellen (data/historie.json
|
||||
* liegt dasselbe Luftbild zugrunde, club.json → email_historie) — nie
|
||||
* duplizieren. QR: geteilter qr-historie.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$emailHistorie = (string) (json_load('club')['email_historie'] ?? '');
|
||||
$historieQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-historie.png')
|
||||
? 'img/stadionzeitung/qr-historie.png'
|
||||
: null;
|
||||
$historieLink = preg_replace('~^https?://(www\.)?~', '', abs_url('historie'));
|
||||
?>
|
||||
<div class="stadionzeitung-historie">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Historie</p>
|
||||
<h2 class="stadionzeitung-page__heading">Unsere Geschichte lebt</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<p class="stadionzeitung-historie__text">Von der Gründung 1908 bis heute: Wir haben unsere Vereinsgeschichte neu aufgearbeitet und erzählen sie jetzt auf unserer Website, mit Fotos und Geschichten aus fast 120 Jahren.</p>
|
||||
<?php /* Archiv-Anmutung passend zum Aufruf („Fotoalben und Schuhkartons"):
|
||||
die Fotos als leicht gedrehte Polaroids, der Aufruf als Notizzettel. */ ?>
|
||||
<div class="stadionzeitung-historie__bilder">
|
||||
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--links">
|
||||
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/luftbild-historisch.jpg')) ?>" alt="Historische Luftaufnahme unserer Sportanlage" loading="lazy" decoding="async">
|
||||
<figcaption class="stadionzeitung-historie__caption">Unsere Sportanlage von oben</figcaption>
|
||||
</figure>
|
||||
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--rechts">
|
||||
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/1972-fcbayern.png')) ?>" alt="Unser Freundschaftsspiel gegen den FC Bayern München 1972" loading="lazy" decoding="async">
|
||||
<figcaption class="stadionzeitung-historie__caption">1972: Zu Gast bei uns, der FC Bayern</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p class="stadionzeitung-historie__aufruf">
|
||||
<strong>Und jetzt bist du dran:</strong> Ein großer Teil unserer Geschichte steckt noch in Fotoalben und Schuhkartons. Wenn du Fotos, Zeitungsausschnitte oder Erinnerungen hast, schreib uns an
|
||||
<span class="stadionzeitung-historie__mail"><?= e($emailHistorie) ?></span>
|
||||
</p>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Die ganze Geschichte auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($historieLink) ?></p>
|
||||
</div>
|
||||
<?php if ($historieQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($historieQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
57
app/components/stadionzeitung-impressum.php
Normal file
57
app/components/stadionzeitung-impressum.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Impressums-Seite der Stadionzeitung (Live-Seite) — ersetzt das bisherige
|
||||
* Bild aus dem Canva-Bestand. Alle Angaben kommen aus data/club.json
|
||||
* (legal_name, address, email, board — dieselbe Quelle wie das
|
||||
* Website-Impressum, nie duplizieren): ändert sich der Vorstand, stimmt die
|
||||
* nächste Ausgabe automatisch. Zentriertes Layout mit Logo und dünner
|
||||
* Rahmenlinie, wie das Mitglied-Poster. Juristische Seite, deshalb der
|
||||
* ausgeschriebene Vereinsname statt Wir-Form (Sprachregel-Ausnahme).
|
||||
*/
|
||||
$club = json_load('club');
|
||||
$legalName = (string) ($club['legal_name'] ?? $club['name'] ?? '');
|
||||
$address = $club['address'] ?? [];
|
||||
$email = (string) ($club['email'] ?? '');
|
||||
$domain = preg_replace('~^https?://(www\.)?~', '', rtrim((string) config('base_url'), '/'));
|
||||
$board = $club['board']['members'] ?? [];
|
||||
$boardNote = (string) ($club['board']['note'] ?? '');
|
||||
?>
|
||||
<div class="stadionzeitung-impressum">
|
||||
<img class="stadionzeitung-impressum__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<p class="stadionzeitung-page__kicker">Impressum</p>
|
||||
<h2 class="stadionzeitung-impressum__name"><?= e($legalName) ?></h2>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Herausgeber</h3>
|
||||
<p><?= e($legalName) ?></p>
|
||||
<p><?= e((string) ($address['street'] ?? '')) ?></p>
|
||||
<p><?= e(trim(($address['zip'] ?? '') . ' ' . ($address['city'] ?? ''))) ?></p>
|
||||
</div>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Kontakt</h3>
|
||||
<p>E-Mail: <?= e($email) ?></p>
|
||||
<p>Internet: <?= e($domain) ?></p>
|
||||
</div>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Inhaltlich verantwortlich</h3>
|
||||
<p><?= e($legalName) ?></p>
|
||||
</div>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Design und Layout</h3>
|
||||
<p>Felix Schneider</p>
|
||||
</div>
|
||||
<?php if ($board !== []): ?>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Vorstand</h3>
|
||||
<?php if ($boardNote !== ''): ?>
|
||||
<p class="stadionzeitung-impressum__note"><?= e($boardNote) ?></p>
|
||||
<?php endif; ?>
|
||||
<p><?= e(implode(' · ', array_map(
|
||||
static fn (array $m): string => trim((string) ($m['name'] ?? '') . (!empty($m['role']) ? ' (' . $m['role'] . ')' : '')),
|
||||
$board
|
||||
))) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
69
app/components/stadionzeitung-interview.php
Normal file
69
app/components/stadionzeitung-interview.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Interview-Seite der Stadionzeitung (Live-Seite) — das Gespräch, auf das die
|
||||
* Teaser-Zeile des Covers verweist (z. B. mit dem Trainer über die Neuzugänge).
|
||||
* Der Inhalt ist KEIN Skript-Input: er wird wie das Vorwort im Chat gepflegt —
|
||||
* Felix schickt Fragen/Antworten, Claude trägt sie ins `interview`-Feld der
|
||||
* Ausgabe in data/stadionzeitung.json ein:
|
||||
* teaser Coverzeile („Unser Trainer … über die Neuzugänge")
|
||||
* heading Überschrift der Seite
|
||||
* person_name / person_role wer spricht
|
||||
* intro optionaler Vorspann (kursiv, Serif)
|
||||
* fragen[] { frage, antwort }
|
||||
* photo optional, Porträt — entweder ein direkter Asset-Pfad
|
||||
* (z. B. "img/persons/ali-kilic.jpg", die geteilten
|
||||
* Personenfotos haben keine Breiten-Varianten) oder
|
||||
* base/widths wie verfasser_photo
|
||||
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe hinweg weiter
|
||||
* (wie `vorwort`) und plant die Seite nur ein, wenn das Feld existiert.
|
||||
*
|
||||
* Fragen in Abel fett, Antworten in der Lese-Serif (--ff-serif, PT Serif) —
|
||||
* dieselbe Begründung wie beim Vorwort: redaktioneller Text, keine UI-Fläche.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$interview = $issue['interview'] ?? null;
|
||||
if (!is_array($interview)) {
|
||||
return;
|
||||
}
|
||||
$heading = trim((string) ($interview['heading'] ?? $interview['teaser'] ?? 'Interview'));
|
||||
$person = trim(implode(' · ', array_filter([
|
||||
(string) ($interview['person_name'] ?? ''),
|
||||
(string) ($interview['person_role'] ?? ''),
|
||||
])));
|
||||
$photo = $interview['photo'] ?? null;
|
||||
$photoSrc = null;
|
||||
if (is_string($photo) && $photo !== '') {
|
||||
$photoSrc = $photo;
|
||||
} elseif (is_array($photo) && !empty($photo['base']) && !empty($photo['widths'])) {
|
||||
$photoSrc = $photo['base'] . '-' . min($photo['widths']) . '.jpg';
|
||||
}
|
||||
?>
|
||||
<div class="stadionzeitung-interview">
|
||||
<div class="stadionzeitung-interview__head-row">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Interview</p>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
|
||||
<?php if ($person !== ''): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e($person) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<?php if ($photoSrc !== null): ?>
|
||||
<img class="stadionzeitung-interview__portrait" src="<?= e(asset($photoSrc)) ?>" alt="" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if (!empty($interview['intro'])): ?>
|
||||
<p class="stadionzeitung-interview__intro"><?= e((string) $interview['intro']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($interview['fragen'] ?? [] as $qa): ?>
|
||||
<div class="stadionzeitung-interview__qa">
|
||||
<p class="stadionzeitung-interview__frage"><?= e((string) ($qa['frage'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-interview__antwort"><?= e((string) ($qa['antwort'] ?? '')) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
69
app/components/stadionzeitung-kontakt.php
Normal file
69
app/components/stadionzeitung-kontakt.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ansprechpartner-Seite der Stadionzeitung (Live-Seite) — die Fußball-
|
||||
* Ansprechpartner aus data/ansprechpartner.json (geteilte Quelle mit
|
||||
* /ansprechpartner, nie Namen/Nummern duplizieren), wie die „KONTAKT"-Seite
|
||||
* der Canva-Vorlage: Abteilung + Name + Telefonnummer, rechts das runde
|
||||
* Porträt. Keine Props — die Seite zeigt immer den aktuellen Stand der
|
||||
* geteilten Quelle (Personen ändern sich zwischen Ausgaben praktisch nie;
|
||||
* ein Snapshot würde hier nur veraltete Nummern konservieren).
|
||||
*/
|
||||
$persons = array_values(array_filter(
|
||||
json_load('ansprechpartner')['persons'] ?? [],
|
||||
static fn (array $p): bool => ($p['group'] ?? '') === 'Fußball'
|
||||
));
|
||||
|
||||
// CTA unter der Liste: QR + Link auf /mitmachen (Wunsch Felix, 29.07.2026).
|
||||
// Der QR wird von bin/stadionzeitung-add.php miterzeugt (qr-mitmachen.png,
|
||||
// geteilt über alle Ausgaben — die Ziel-URL ist immer dieselbe); fehlt er
|
||||
// (noch kein Lauf seit Einführung), erscheint nur der Text-Link.
|
||||
$mitmachenQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitmachen.png')
|
||||
? 'img/stadionzeitung/qr-mitmachen.png'
|
||||
: null;
|
||||
$mitmachenLink = preg_replace('~^https?://(www\.)?~', '', abs_url('mitmachen'));
|
||||
?>
|
||||
<div class="stadionzeitung-kontakt">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Kontakt</p>
|
||||
<h2 class="stadionzeitung-page__heading">Ansprechpartner im Verein</h2>
|
||||
</header>
|
||||
<?php /* Bewusst ohne die Trennlinie der Geschwister-Seiten: die Liste + CTA
|
||||
brauchen die volle Seitenhöhe (Felix, 29.07.2026). Die erste
|
||||
Listenzeile bringt ihre eigene Unterkante mit, das trennt genug. */ ?>
|
||||
<ul class="stadionzeitung-kontakt__list">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li class="stadionzeitung-kontakt__item">
|
||||
<div class="stadionzeitung-kontakt__body">
|
||||
<p class="stadionzeitung-kontakt__department"><?= e((string) ($person['department'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-kontakt__name"><?= e((string) ($person['name'] ?? '')) ?></p>
|
||||
<?php if (!empty($person['phone'])): ?>
|
||||
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['phone']) ?></p>
|
||||
<?php elseif (!empty($person['email'])): ?>
|
||||
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['email']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($person['image'])): ?>
|
||||
<img class="stadionzeitung-kontakt__portrait" src="<?= e(asset((string) $person['image'])) ?>" alt="" width="56" height="56" loading="lazy" decoding="async">
|
||||
<?php else: ?>
|
||||
<span class="stadionzeitung-kontakt__portrait stadionzeitung-kontakt__portrait--icon" aria-hidden="true"><?= icon('person') ?></span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Du möchtest selbst aktiv werden?</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($mitmachenLink) ?></p>
|
||||
</div>
|
||||
<?php if ($mitmachenQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($mitmachenQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
43
app/components/stadionzeitung-mitglied.php
Normal file
43
app/components/stadionzeitung-mitglied.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste:
|
||||
* riesige zentrierte Coolvetica-Headline („Werde Teil des Nullachters", das
|
||||
* letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile,
|
||||
* unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein
|
||||
* jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den
|
||||
* Ansprechpartnern ein) — Look bewusst weiß mit Rot-Akzent, damit die Seite
|
||||
* heraussticht, ohne die helle Heft-Optik zu brechen (Felix, 29.07.2026).
|
||||
*
|
||||
* Keine Props — die Seite ist statisch bis auf den geteilten QR
|
||||
* (qr-mitglied-werden.png, von bin/stadionzeitung-add.php mit der aktuellen
|
||||
* base_url erzeugt; fehlt er, erscheint nur der Text-Link).
|
||||
*/
|
||||
$mitgliedQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitglied-werden.png')
|
||||
? 'img/stadionzeitung/qr-mitglied-werden.png'
|
||||
: null;
|
||||
$mitgliedLink = preg_replace('~^https?://(www\.)?~', '', abs_url('mitglied-werden'));
|
||||
?>
|
||||
<div class="stadionzeitung-mitglied">
|
||||
<div class="stadionzeitung-mitglied__poster">
|
||||
<img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<h2 class="stadionzeitung-mitglied__headline">
|
||||
<span class="stadionzeitung-mitglied__line">Werde Teil</span>
|
||||
<span class="stadionzeitung-mitglied__line">des</span>
|
||||
<span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Nullachters</span>
|
||||
</h2>
|
||||
<p class="stadionzeitung-mitglied__claim">Seit 1908 in Kulmbach zuhause. Egal ob auf dem Platz, in der Halle oder am Spielfeldrand: bei uns ist Platz für dich.</p>
|
||||
<p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p>
|
||||
<?php /* Bewusst KEIN generischer CTA-Fuß wie auf den Listen-Seiten: das
|
||||
Poster endet mit dem großen zentrierten QR (Felix, 29.07.2026) —
|
||||
die Link-Zeile darunter bleibt für alle, die nicht scannen. */ ?>
|
||||
<div class="stadionzeitung-mitglied__qr-block">
|
||||
<?php if ($mitgliedQr !== null): ?>
|
||||
<img class="stadionzeitung-mitglied__qr" src="<?= e(asset($mitgliedQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
<span class="stadionzeitung-mitglied__link"><?= e($mitgliedLink) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
52
app/components/stadionzeitung-momente.php
Normal file
52
app/components/stadionzeitung-momente.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Momente-Seite der Stadionzeitung (Live-Seite, Heft-Abschluss) — die vier
|
||||
* neuesten Fotos aus unserem Instagram-Cache als 2×2-Galerie plus
|
||||
* „Folge uns"-Ticket mit QR aufs Profil. Pflegt sich selbst: im Entwurf
|
||||
* immer die aktuellsten Bilder, beim Veröffentlichen eingefroren
|
||||
* (stadionzeitung_build_snapshot(), app/helpers.php).
|
||||
*
|
||||
* Props: $items stadionzeitung_live_data()['momente'] — image/alt je Foto.
|
||||
* Profil-URL aus club.json → social.instagram (geteilte Quelle), QR:
|
||||
* geteilter qr-instagram.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
|
||||
$instagramHandle = '@' . trim((string) parse_url($instagramUrl, PHP_URL_PATH), '/');
|
||||
$momenteQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')
|
||||
? 'img/stadionzeitung/qr-instagram.png'
|
||||
: null;
|
||||
?>
|
||||
<div class="stadionzeitung-momente">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Instagram</p>
|
||||
<h2 class="stadionzeitung-page__heading">Aktuelles von Instagram</h2>
|
||||
</header>
|
||||
<?php /* Bewusst ohne Trennlinie und mit verdichteten Abständen: jeder
|
||||
gewonnene Millimeter geht in die Fotogröße (Felix, 29.07.2026). */ ?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted">Folge uns auf Instagram für alle Momente rund um den Verein.</p>
|
||||
<?php else: ?>
|
||||
<div class="stadionzeitung-momente__galerie">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<img class="stadionzeitung-momente__foto" src="<?= e(asset((string) $item['image'])) ?>" alt="<?= e((string) $item['alt']) ?>" loading="lazy" decoding="async">
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Folge uns auf Instagram</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($instagramHandle) ?></p>
|
||||
</div>
|
||||
<?php if ($momenteQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($momenteQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
90
app/components/stadionzeitung-news.php
Normal file
90
app/components/stadionzeitung-news.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* News-Seite der Stadionzeitung (Live-Seite) — eigene, einheitliche Liste im
|
||||
* Kopf-Muster der Geschwister-Seiten statt des geteilten news-feed der
|
||||
* /news-Seite: dort variieren die Medienspalten (Bild, Wappen, nichts) und
|
||||
* die Titel springen in der Einrückung — auf der schmalen A5-Seite braucht
|
||||
* jede Zeile dieselbe feste Medienspalte (Wappen, Foto-Thumbnail oder
|
||||
* Icon-Badge als Fallback), damit alle Titel bündig stehen.
|
||||
*
|
||||
* Props: $items stadionzeitung_live_data()['news'] — normalisierte Einträge
|
||||
* aus news_build_feed() (date/title/text/image/crest/source).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
|
||||
$sourceLabel = static fn (string $source): string => match ($source) {
|
||||
'ergebnis' => 'Ergebnis',
|
||||
'instagram' => 'Instagram',
|
||||
default => 'Meldung',
|
||||
};
|
||||
|
||||
// CTA unter der Liste: QR + Link auf /news (geteilter QR wie bei der
|
||||
// Kontakt-Seite, von bin/stadionzeitung-add.php mit der aktuellen base_url
|
||||
// erzeugt — fehlt er, erscheint nur der Text-Link).
|
||||
$newsQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-news.png')
|
||||
? 'img/stadionzeitung/qr-news.png'
|
||||
: null;
|
||||
$newsLink = preg_replace('~^https?://(www\.)?~', '', abs_url('news'));
|
||||
?>
|
||||
<div class="stadionzeitung-news">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">News</p>
|
||||
<h2 class="stadionzeitung-page__heading">Aktuelles aus dem Katzbachtal</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted">Aktuell keine News.</p>
|
||||
<?php endif; ?>
|
||||
<ul class="stadionzeitung-news__list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$source = (string) ($item['source'] ?? 'news');
|
||||
// Instagram-Titel ist die erste Zeile der Caption — der Text darunter
|
||||
// wäre dieselbe Caption noch einmal, deshalb dort nur der Titel.
|
||||
$text = $source === 'instagram' ? '' : trim((string) ($item['text'] ?? ''));
|
||||
$image = $item['image'] ?? null;
|
||||
$imageSrc = null;
|
||||
if (is_array($image)) {
|
||||
// Beide Bildformen aus news_build_feed(): Instagram liefert src,
|
||||
// redaktionelle Meldungen base/widths (kleinste Variante reicht
|
||||
// für das winzige Thumbnail).
|
||||
$imageSrc = $image['src'] ?? (isset($image['base'], $image['widths']) ? $image['base'] . '-' . min($image['widths']) . '.jpg' : null);
|
||||
}
|
||||
?>
|
||||
<li class="stadionzeitung-news__item">
|
||||
<?php if (!empty($item['crest']['src']) || (!empty($item['crest']['name']) && $source === 'ergebnis')): ?>
|
||||
<span class="stadionzeitung-news__media stadionzeitung-news__media--crest">
|
||||
<?php component('crest', ['src' => $item['crest']['src'] ?? null, 'name' => (string) ($item['crest']['name'] ?? '')]); ?>
|
||||
</span>
|
||||
<?php elseif ($imageSrc !== null): ?>
|
||||
<img class="stadionzeitung-news__media stadionzeitung-news__media--img" src="<?= e(asset($imageSrc)) ?>" alt="" loading="lazy" decoding="async">
|
||||
<?php else: ?>
|
||||
<span class="stadionzeitung-news__media stadionzeitung-news__media--icon" aria-hidden="true"><?= icon('newspaper') ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-news__body">
|
||||
<p class="stadionzeitung-news__meta"><?= e($sourceLabel($source)) ?><?php if (!empty($item['date'])): ?> · <time datetime="<?= e((string) $item['date']) ?>"><?= e(german_date((string) $item['date'])) ?></time><?php endif; ?></p>
|
||||
<p class="stadionzeitung-news__title"><?= e((string) ($item['title'] ?? '')) ?></p>
|
||||
<?php if ($text !== ''): ?>
|
||||
<p class="stadionzeitung-news__text"><?= e($text) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Mehr News findest du auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($newsLink) ?></p>
|
||||
</div>
|
||||
<?php if ($newsQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($newsQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
63
app/components/stadionzeitung-partner.php
Normal file
63
app/components/stadionzeitung-partner.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Partner-werden-Seite der Stadionzeitung (Live-Seite) — der Selbstbeweis:
|
||||
* die Seite wirbt für Anzeigen in genau dem Heft, das der Leser gerade in
|
||||
* der Hand hält. Fester Baustein jeder Ausgabe (Felix, 29.07.2026).
|
||||
*
|
||||
* Sie-Form mit Absicht: /partner-werden spricht Unternehmen an — die eine
|
||||
* benannte Ausnahme der Wir/Du-Sprachregel (CLAUDE.md). Die drei Pakete
|
||||
* kommen aus data/partner-werden.json (dieselbe Quelle wie die Website),
|
||||
* der Sponsoring-Ansprechpartner (2. Vorsitzender) aus club.json → board.
|
||||
* QR: geteilter qr-partner-werden.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$channels = json_load('partner-werden')['channels']['items'] ?? [];
|
||||
$sponsoringKontakt = null;
|
||||
foreach (json_load('club')['board']['members'] ?? [] as $member) {
|
||||
if (($member['role'] ?? '') === '2. Vorsitzender') {
|
||||
$sponsoringKontakt = (string) $member['name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$partnerQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
|
||||
? 'img/stadionzeitung/qr-partner-werden.png'
|
||||
: null;
|
||||
$partnerLink = preg_replace('~^https?://(www\.)?~', '', abs_url('partner-werden'));
|
||||
?>
|
||||
<div class="stadionzeitung-partner">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Partner werden</p>
|
||||
<h2 class="stadionzeitung-page__heading">Hier könnte Ihre Anzeige stehen</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<p class="stadionzeitung-partner__intro">Die Stadionzeitung liegt bei jedem Heimspiel im Katzbachtal aus, unsere Website erreicht Kulmbach jeden Tag. Bringen Sie Ihr Unternehmen dorthin, wo die Region zuhause ist.</p>
|
||||
<ul class="stadionzeitung-partner__pakete">
|
||||
<?php foreach ($channels as $channel): ?>
|
||||
<li class="stadionzeitung-partner__paket">
|
||||
<span class="stadionzeitung-partner__icon" aria-hidden="true"><?= icon((string) ($channel['icon'] ?? 'geo-alt-fill')) ?></span>
|
||||
<div class="stadionzeitung-partner__paket-body">
|
||||
<p class="stadionzeitung-partner__paket-titel"><?= e((string) ($channel['title'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-partner__paket-text"><?= e((string) ($channel['text'] ?? '')) ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($sponsoringKontakt !== null): ?>
|
||||
<p class="stadionzeitung-partner__kontakt">Ihr Ansprechpartner: <strong><?= e($sponsoringKontakt) ?></strong> · 2. Vorsitzender</p>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Alle Pakete auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($partnerLink) ?></p>
|
||||
</div>
|
||||
<?php if ($partnerQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($partnerQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
91
app/components/stadionzeitung-seite.php
Normal file
91
app/components/stadionzeitung-seite.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* EINE Seite einer Stadionzeitung-Ausgabe — die gemeinsame Render-Stelle für
|
||||
* den Swipe-Viewer (stadionzeitung-viewer.php) und die Druckfassung
|
||||
* (stadionzeitung-druck.php): beide zeigen exakt dieselben Seiten, nur in
|
||||
* anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
|
||||
* $entry Eintrag aus issue.images[]:
|
||||
* type 'image' (base/widths/alt),
|
||||
* type 'live' (page: cover|vorwort|interview|tabelle|termine|
|
||||
* news|ergebnisse|vorschau|kontakt; tabelle optional
|
||||
* mit team: Team-Key aus matchcenter.json),
|
||||
* type 'leer' (weiße Füllseite, nur Druckfassung — hält die
|
||||
* Seitenzahl auf einem Vielfachen von 4)
|
||||
* $issue Eintrag aus data/stadionzeitung.json
|
||||
* $data stadionzeitung_live_data($issue) — vom Aufrufer EINMAL berechnet
|
||||
* und an jede Seite durchgereicht (nicht pro Seite neu laden)
|
||||
* $eager bool, Bild ohne lazy-loading laden (erste Seite im Viewer)
|
||||
*/
|
||||
$entry = $entry ?? [];
|
||||
$issue = $issue ?? [];
|
||||
$data = $data ?? [];
|
||||
$eager = $eager ?? false;
|
||||
$type = $entry['type'] ?? 'image';
|
||||
?>
|
||||
<?php if ($type === 'leer'): ?>
|
||||
<div class="stadionzeitung-page stadionzeitung-page--leer" aria-hidden="true"></div>
|
||||
<?php elseif ($type === 'live' && ($entry['page'] ?? '') === 'cover'): ?>
|
||||
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
|
||||
<?php elseif ($type === 'live'): ?>
|
||||
<div class="stadionzeitung-page">
|
||||
<?php switch ($entry['page'] ?? ''):
|
||||
case 'vorwort': ?>
|
||||
<?php component('stadionzeitung-vorwort', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'tabelle': ?>
|
||||
<?php
|
||||
// Ohne team-Feld (Ausgaben vor der Mehr-Tabellen-Vorlage) zeigt die
|
||||
// Seite wie bisher die Tabelle der Mannschaft der Ausgabe.
|
||||
$tabelleTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
|
||||
component('stadionzeitung-tabelle', ['info' => $data['tabellen'][$tabelleTeam] ?? null]);
|
||||
?>
|
||||
<?php break; ?>
|
||||
<?php case 'interview': ?>
|
||||
<?php component('stadionzeitung-interview', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'termine': ?>
|
||||
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'ergebnisse': ?>
|
||||
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'vorschau': ?>
|
||||
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'kontakt': ?>
|
||||
<?php component('stadionzeitung-kontakt', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'mitglied': ?>
|
||||
<?php component('stadionzeitung-mitglied', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'historie': ?>
|
||||
<?php component('stadionzeitung-historie', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'sportheim': ?>
|
||||
<?php component('stadionzeitung-sportheim', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'partner': ?>
|
||||
<?php component('stadionzeitung-partner', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'momente': ?>
|
||||
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'impressum': ?>
|
||||
<?php component('stadionzeitung-impressum', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'news': ?>
|
||||
<?php component('stadionzeitung-news', ['items' => $data['news'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php endswitch; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php component('img', [
|
||||
'image' => $entry,
|
||||
'sizes' => '100vw',
|
||||
'eager' => $eager,
|
||||
'class' => 'stadionzeitung-page__img',
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
41
app/components/stadionzeitung-sportheim.php
Normal file
41
app/components/stadionzeitung-sportheim.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sportheim-Seite der Stadionzeitung (Live-Seite) — Nachbau der gelungenen
|
||||
* Canva-Seite „Feiern Sie Veranstaltungen im Katzbachtal": Kicker + große
|
||||
* Headline, Lese-Serif-Text, unten das einblendende Gastraum-Foto mit
|
||||
* QR-Karte darauf, rote Pinselstrich-Ecken als Gestaltungselement (hier als
|
||||
* CSS-Näherung, keine Bild-Assets). Fester Baustein jeder Ausgabe
|
||||
* (Felix, 29.07.2026).
|
||||
*
|
||||
* Text kommt aus data/sportheimbuchung.json (hero.text — dieselbe Quelle wie
|
||||
* /sportheimbuchung, nie duplizieren), Foto aus dem geteilten Bestand,
|
||||
* QR: geteilter qr-sportheimbuchung.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$heroText = (string) (json_load('sportheimbuchung')['hero']['text'] ?? '');
|
||||
$sportheimQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-sportheimbuchung.png')
|
||||
? 'img/stadionzeitung/qr-sportheimbuchung.png'
|
||||
: null;
|
||||
$sportheimLink = preg_replace('~^https?://(www\.)?~', '', abs_url('sportheimbuchung'));
|
||||
?>
|
||||
<?php /* Pinselstrich-Ecken bewusst entfernt (Felix, 29.07.2026) — kommen
|
||||
ggf. später als saubere Bild-Assets zurück. */ ?>
|
||||
<div class="stadionzeitung-sportheim">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Veranstaltung</p>
|
||||
<h2 class="stadionzeitung-page__heading">Feiern im Katzbachtal</h2>
|
||||
</header>
|
||||
<p class="stadionzeitung-sportheim__text"><?= e($heroText) ?></p>
|
||||
<div class="stadionzeitung-sportheim__foto-bereich">
|
||||
<img class="stadionzeitung-sportheim__foto" src="<?= e(asset('img/pages/sportheim-1000.jpg')) ?>" alt="Der Gastraum unseres Sportheims" loading="lazy" decoding="async">
|
||||
<div class="stadionzeitung-sportheim__foto-fade" aria-hidden="true"></div>
|
||||
<div class="stadionzeitung-sportheim__qr-karte">
|
||||
<?php if ($sportheimQr !== null): ?>
|
||||
<img class="stadionzeitung-sportheim__qr" src="<?= e(asset($sportheimQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
<span class="stadionzeitung-sportheim__link"><?= e($sportheimLink) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
31
app/components/stadionzeitung-tabelle.php
Normal file
31
app/components/stadionzeitung-tabelle.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tabellen-Seite der Stadionzeitung (Live-Seite) — Kopf im Muster der
|
||||
* Geschwister-Seiten (Kicker + Überschrift, siehe stadionzeitung-termine.php),
|
||||
* darunter die geteilte league-table-Komponente (dieselbe wie /fussball,
|
||||
* nie eine zweite Tabellen-Implementierung).
|
||||
*
|
||||
* Props: $info Eintrag aus stadionzeitung_live_data()['tabellen'][team]:
|
||||
* name/league/season/table — null, wenn das Team (noch) nicht
|
||||
* in data/matchcenter.json gepflegt ist.
|
||||
*/
|
||||
$info = $info ?? null;
|
||||
?>
|
||||
<div class="stadionzeitung-tabelle">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Tabelle</p>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e(trim((string) ($info['name'] ?? 'Tabelle'))) ?></h2>
|
||||
<?php if (!empty($info['league']) || !empty($info['season'])): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if (!empty($info['table'])): ?>
|
||||
<?php component('league-table', ['table' => $info['table'], 'caption' => 'Tabelle ' . (string) ($info['league'] ?? '')]); ?>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Tabelle aktuell nicht verfügbar.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
90
app/components/stadionzeitung-termine.php
Normal file
90
app/components/stadionzeitung-termine.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Termine-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Vorwort/News) —
|
||||
* schlichte weiße Content-Seite mit Foto-Kopf, angelehnt ans Vorwort-Layout
|
||||
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
|
||||
* Icon-Liste statt Fließtext.
|
||||
*
|
||||
* Die drei Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert
|
||||
* sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort
|
||||
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
|
||||
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json: termine_snapshot?)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$items = $issue['termine_snapshot'] ?? [];
|
||||
if ($items === []) {
|
||||
return;
|
||||
}
|
||||
$heroImage = json_load('home')['hero']['image'] ?? null;
|
||||
|
||||
// CTA-Fuß wie News/Kontakt: geteilter QR auf /termine (bin/stadionzeitung-add.php
|
||||
// erzeugt ihn mit der aktuellen base_url — fehlt er, nur der Text-Link).
|
||||
$termineQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-termine.png')
|
||||
? 'img/stadionzeitung/qr-termine.png'
|
||||
: null;
|
||||
$termineLink = preg_replace('~^https?://(www\.)?~', '', abs_url('termine'));
|
||||
?>
|
||||
<div class="stadionzeitung-termine">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-termine__bg']); ?>
|
||||
<div class="stadionzeitung-termine__bg-fade" aria-hidden="true"></div>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-termine__inner">
|
||||
<header class="stadionzeitung-termine__head">
|
||||
<p class="stadionzeitung-termine__kicker">Aktuelles</p>
|
||||
<h2 class="stadionzeitung-termine__heading">Kommende Termine</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-termine__rule" aria-hidden="true"></span>
|
||||
<ul class="stadionzeitung-termine__list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$isMatch = ($item['type'] ?? '') === 'spiel';
|
||||
$parts = [german_date_range((string) ($item['date'] ?? ''), $item['date_end'] ?? null)];
|
||||
$time = '';
|
||||
if ($isMatch && !empty($item['kickoff'])) {
|
||||
$t = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $item['kickoff']);
|
||||
$time = $t !== false ? $t->format('H:i') : '';
|
||||
} elseif (!$isMatch) {
|
||||
$time = (string) ($item['time'] ?? '');
|
||||
}
|
||||
if ($time !== '') {
|
||||
$parts[] = $time . ' Uhr';
|
||||
}
|
||||
if ($isMatch && !empty($item['meta'])) {
|
||||
$parts[] = (string) $item['meta'];
|
||||
} elseif (!$isMatch && !empty($item['location'])) {
|
||||
$parts[] = (string) $item['location'];
|
||||
}
|
||||
?>
|
||||
<li class="stadionzeitung-termine__item">
|
||||
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
|
||||
Trophäe, Vereins-Termine den Kalender — macht die Liste auf einen
|
||||
Blick unterscheidbar. */ ?>
|
||||
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
|
||||
<div class="stadionzeitung-termine__body">
|
||||
<p class="stadionzeitung-termine__title"><?= e((string) ($item['title'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-termine__meta"><?= e(implode(' · ', $parts)) ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Alle Termine findest du auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($termineLink) ?></p>
|
||||
</div>
|
||||
<?php if ($termineQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($termineQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
77
app/components/stadionzeitung-viewer.php
Normal file
77
app/components/stadionzeitung-viewer.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props:
|
||||
* $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status,
|
||||
* images[]{type: 'image'{base,widths,alt} | 'live'{page[,team]}})
|
||||
*
|
||||
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
|
||||
* voll lesbar, sequentiell auch per Screenreader. Mit JS
|
||||
* (stadionzeitung-viewer.js) wird der Track zum horizontalen Scroll-Snap-
|
||||
* Swiper, Zähler + Pfeil-Buttons kommen dazu (vorher [hidden], da ohne JS
|
||||
* funktionslos).
|
||||
*
|
||||
* Das Rendern der einzelnen Seiten (Bild wie Live) liegt in
|
||||
* stadionzeitung-seite.php — geteilt mit der Druckfassung
|
||||
* (stadionzeitung-druck.php), damit Bildschirm und Druck nie
|
||||
* auseinanderlaufen. Die Daten der Live-Seiten kommen EINMAL aus
|
||||
* stadionzeitung_live_data() (app/helpers.php): Entwürfe rechnen live,
|
||||
* veröffentlichte Ausgaben lesen ihren eingefrorenen snapshot.
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$images = $issue['images'] ?? [];
|
||||
$total = count($images);
|
||||
if ($images === []) {
|
||||
return;
|
||||
}
|
||||
$opponent = (string) ($issue['opponent'] ?? '');
|
||||
|
||||
// Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält.
|
||||
$hasLive = false;
|
||||
foreach ($images as $entry) {
|
||||
if (($entry['type'] ?? 'image') === 'live') {
|
||||
$hasLive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
|
||||
?>
|
||||
<section class="stadionzeitung-viewer" data-stadionzeitung-viewer aria-roledescription="Bildergalerie" aria-label="<?= e('Stadionzeitung gegen ' . $opponent . ', Seite für Seite') ?>">
|
||||
<ul class="stadionzeitung-viewer__track" data-viewer-track>
|
||||
<?php foreach ($images as $i => $entry): ?>
|
||||
<li class="stadionzeitung-viewer__page" data-viewer-page>
|
||||
<?php component('stadionzeitung-seite', [
|
||||
'entry' => $entry,
|
||||
'issue' => $issue,
|
||||
'data' => $liveData,
|
||||
'eager' => $i === 0,
|
||||
]); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-viewer__controls" data-viewer-controls hidden>
|
||||
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-prev aria-label="Vorherige Seite">
|
||||
<?= icon('chevron-left') ?>
|
||||
</button>
|
||||
<p class="stadionzeitung-viewer__counter" data-viewer-counter aria-live="polite">
|
||||
Seite <span data-viewer-current>1</span> von <?= $total ?>
|
||||
</p>
|
||||
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-next aria-label="Nächste Seite">
|
||||
<?= icon('chevron-right') ?>
|
||||
</button>
|
||||
<?php /* Vollbild-Lesemodus: am Handy fressen Kopfleiste, Navigation und Fußzeile
|
||||
mehr Platz als die Seite selbst. Beide Icons stehen im Markup, welches
|
||||
sichtbar ist entscheidet CSS am Zustand — so muss das JS kein Markup
|
||||
erzeugen. Zustand über aria-pressed, deshalb bleibt das aria-label
|
||||
unverändert. */ ?>
|
||||
<button class="btn btn--icon stadionzeitung-viewer__full-btn" type="button" data-viewer-full aria-pressed="false" aria-label="Vollbild">
|
||||
<span class="stadionzeitung-viewer__icon--open"><?= icon('arrows-fullscreen') ?></span>
|
||||
<span class="stadionzeitung-viewer__icon--close"><?= icon('x-lg') ?></span>
|
||||
</button>
|
||||
</div>
|
||||
<a class="btn btn--outline stadionzeitung-viewer__print" href="<?= e(url('stadionzeitung/' . ($issue['slug'] ?? '')) . '?druck=1') ?>">
|
||||
<?= icon('printer') ?> Druckfassung
|
||||
</a>
|
||||
</section>
|
||||
50
app/components/stadionzeitung-vorschau.php
Normal file
50
app/components/stadionzeitung-vorschau.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vorschau-Seite der Stadionzeitung (Live-Seite) — das nächste Spiel jeder
|
||||
* Mannschaft NACH dem Spieltag der Ausgabe (berechnet bzw. eingefroren in
|
||||
* stadionzeitung_build_snapshot(), app/helpers.php). Aufbau je Spiel wie die
|
||||
* „VORSCHAU"-Seite der Canva-Vorlage: Meta-Zeile (Mannschaft · Liga), darunter
|
||||
* die Begegnung mit Wappen, rechts Datum/Anstoß.
|
||||
*
|
||||
* Props: $items stadionzeitung_live_data()['vorschau'] — je Team:
|
||||
* team_name/league/match (Matchcenter-Rohform).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
|
||||
// Bewusst OHNE CTA-Fuß (Felix, 29.07.2026): mit drei Mannschaften füllt die
|
||||
// Liste die Seite — das Matchcenter-Ticket wohnt auf der Ergebnis-Seite.
|
||||
?>
|
||||
<div class="stadionzeitung-vorschau">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Vorschau</p>
|
||||
<h2 class="stadionzeitung-page__heading">Kommende Spiele</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted">Aktuell stehen keine weiteren Spiele fest.</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$match = $item['match'] ?? [];
|
||||
$when = array_filter([
|
||||
trim((string) ($match['date_display'] ?? '')),
|
||||
trim((string) ($match['time_display'] ?? '')),
|
||||
]);
|
||||
$place = !empty($match['is_home']) ? 'im Katzbachtal' : 'auswärts';
|
||||
?>
|
||||
<section class="stadionzeitung-vorschau__match">
|
||||
<p class="stadionzeitung-vorschau__meta"><?= e(trim(implode(' · ', array_filter([(string) ($item['team_name'] ?? ''), (string) ($item['league'] ?? '')])))) ?></p>
|
||||
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null],
|
||||
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null]] as $side): ?>
|
||||
<div class="stadionzeitung-vorschau__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-vorschau__row--own' : '' ?>">
|
||||
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
|
||||
<span class="stadionzeitung-vorschau__club"><?= e((string) $side['club']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<p class="stadionzeitung-vorschau__when"><?= e(implode(' · ', $when)) ?><?= $when !== [] ? ' · ' : '' ?><?= e($place) ?></p>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
70
app/components/stadionzeitung-vorwort.php
Normal file
70
app/components/stadionzeitung-vorwort.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vorwort-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Termine/News) —
|
||||
* schlichte weiße Content-Seite, keine fotografische Ausnahme wie das Cover.
|
||||
* Der Text selbst ist KEIN Skript-Input: er wird im Chat gepflegt (wie
|
||||
* data/events.json/news.json) und lebt im `vorwort`-Feld der Ausgabe in
|
||||
* data/stadionzeitung.json (siehe bin/stadionzeitung-add.php).
|
||||
*
|
||||
* Fließtext bewusst in einer Serifenschrift (--ff-serif, PT Serif) statt
|
||||
* --ff-body — einzige Stelle im Projekt mit einer dritten Schriftart: ein
|
||||
* Vorwort ist ein redaktioneller Brief, keine UI, eine Lese-Serif passt hier
|
||||
* besser als die Website-Grotesk. Überschrift bewusst NICHT in Versalien
|
||||
* (anders als der Cover-Masthead) — ein Begrüßungssatz in Großbuchstaben
|
||||
* wirkt geschrien, hier soll es wie eine echte Zeitschriften-Headline lesen.
|
||||
* Vereins-Hero-Bild (dieselbe Quelle wie Startseite/Cover) blendet unten ein,
|
||||
* wie im ursprünglichen Canva-Vorwort — füllt die sonst leere Fläche unter
|
||||
* dem Fließtext, ohne die Lesbarkeit oben zu stören. Läuft über einen weißen
|
||||
* Gradient-Layer (__bg-fade) statt mask-image auf dem Foto selbst — druckt
|
||||
* zuverlässig mit (siehe CSS-Kommentar), erscheint also auch im PDF.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json: vorwort?, verfasser_photo?)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$vorwort = $issue['vorwort'] ?? null;
|
||||
if (!$vorwort) {
|
||||
return;
|
||||
}
|
||||
$heading = (string) ($vorwort['heading'] ?? '');
|
||||
$paragraphs = $vorwort['paragraphs'] ?? [];
|
||||
$closing = (string) ($vorwort['closing'] ?? '');
|
||||
$signeeName = (string) ($vorwort['signee_name'] ?? '');
|
||||
$signeeRole = (string) ($vorwort['signee_role'] ?? '');
|
||||
$verfasserPhoto = $issue['verfasser_photo'] ?? null;
|
||||
$heroImage = json_load('home')['hero']['image'] ?? null;
|
||||
?>
|
||||
<div class="stadionzeitung-vorwort">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-vorwort__bg']); ?>
|
||||
<div class="stadionzeitung-vorwort__bg-fade" aria-hidden="true"></div>
|
||||
<?php endif; ?>
|
||||
<header class="stadionzeitung-vorwort__head">
|
||||
<p class="stadionzeitung-vorwort__kicker">Vorwort</p>
|
||||
<img class="stadionzeitung-vorwort__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
</header>
|
||||
<?php if ($heading !== ''): ?>
|
||||
<h2 class="stadionzeitung-vorwort__heading"><?= e($heading) ?></h2>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($paragraphs as $paragraph): ?>
|
||||
<p class="stadionzeitung-vorwort__text"><?= e($paragraph) ?></p>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($signeeName !== ''): ?>
|
||||
<footer class="stadionzeitung-vorwort__signature">
|
||||
<?php if ($verfasserPhoto): ?>
|
||||
<?php component('img', ['image' => $verfasserPhoto, 'sizes' => '80px', 'class' => 'stadionzeitung-vorwort__portrait']); ?>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-vorwort__signature-text">
|
||||
<?php if ($closing !== ''): ?>
|
||||
<p class="stadionzeitung-vorwort__closing"><?= e($closing) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="stadionzeitung-vorwort__signee"><?= e($signeeName) ?></p>
|
||||
<?php if ($signeeRole !== ''): ?>
|
||||
<p class="stadionzeitung-vorwort__role"><?= e($signeeRole) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Kennzahlen-Band über abgedunkeltem Foto (Vereinsgelände), Kanten verlaufen
|
||||
* weich in den Seitenhintergrund. Props:
|
||||
* $stats array{items: array{value, label}[], image?: array (img-Komponente)}
|
||||
*/
|
||||
$image = $stats['image'] ?? [
|
||||
'base' => 'img/pages/vereinsgelaende',
|
||||
'widths' => [1000, 1600],
|
||||
'alt' => '',
|
||||
];
|
||||
?>
|
||||
<section class="section stats" aria-label="Der Verein in Zahlen">
|
||||
<div class="stats__media" aria-hidden="true">
|
||||
<?php component('img', ['image' => $image, 'sizes' => '100vw', 'class' => 'stats__img']); ?>
|
||||
</div>
|
||||
<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>
|
||||
@@ -39,7 +39,7 @@ if (!empty($team['roster']['members']) || !empty($team['roster']['groups'])) {
|
||||
|
||||
component('faq-contact', [
|
||||
'faq' => $team['faq'] ?? [],
|
||||
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ' – kurz beantwortet.',
|
||||
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ', kurz beantwortet.',
|
||||
'persons' => $contacts,
|
||||
'contactTitle' => '',
|
||||
'anchor' => 'kontakt',
|
||||
|
||||
44
app/components/termine-list.php
Normal file
44
app/components/termine-list.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liste normalisierter Termine (Spiele + Vereins-Events gemischt). Props:
|
||||
* $items array{date, date_end?, title, department, type, meta?, href?}
|
||||
* $empty_text string (optional)
|
||||
* Kennt selbst nichts von Matchcenter-/Events-Datenstruktur — bekommt nur die
|
||||
* bereits gemergte, normalisierte Form (app/pages/termine.php baut sie).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$emptyText = $empty_text ?? 'Aktuell keine Termine.';
|
||||
?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted"><?= e($emptyText) ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="termine-list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$tag = !empty($item['href']) ? 'a' : 'div';
|
||||
$chip = german_date_chip($item['date']);
|
||||
$dateText = german_date_range($item['date'], $item['date_end'] ?? null);
|
||||
?>
|
||||
<li class="termine-list__item">
|
||||
<<?= $tag ?> class="termine-list__link"<?= !empty($item['href']) ? ' href="' . e($item['href']) . '"' : '' ?>>
|
||||
<span class="termine-list__date-chip" aria-hidden="true">
|
||||
<span class="termine-list__date-day"><?= e($chip['day']) ?></span>
|
||||
<span class="termine-list__date-month"><?= e($chip['month']) ?></span>
|
||||
</span>
|
||||
<span class="termine-list__body">
|
||||
<span class="termine-list__title"><?= e($item['title']) ?></span>
|
||||
<span class="termine-list__meta">
|
||||
<time datetime="<?= e($item['date']) ?>"><?= e($dateText) ?></time><?php if (!empty($item['meta'])): ?> · <?= e($item['meta']) ?><?php endif; ?>
|
||||
</span>
|
||||
</span>
|
||||
<span class="termine-list__badge termine-list__badge--<?= e($item['department']) ?>">
|
||||
<?= e(department_label($item['department'])) ?>
|
||||
</span>
|
||||
</<?= $tag ?>>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Interaktive Vereins-Timeline (Museums-Look). Props:
|
||||
* $events array — Stationen aus data/historie.json (year, text, image{src,width,height,alt})
|
||||
* $events array — Stationen aus data/historie.json (year, text, image?{src,width,height,alt})
|
||||
* Bilder werden bewusst in ORIGINALGRÖSSE eingebunden (keine Varianten) —
|
||||
* historisches Material bleibt unangetastet.
|
||||
* `image` ist optional: zu vielen belegten Ereignissen (Ligaereignisse, Chronik-Funde)
|
||||
* gibt es kein Bild, das wir zeigen dürfen — Pressefotos gehören dem Verlag, die Fotos
|
||||
* der alten Vereinsseiten sind verloren. Solche Stationen rendern als Notiz auf
|
||||
* derselben Sepia-Fläche, ohne <figure>/<figcaption> (ohne Bild trägt das nichts).
|
||||
* timeline.js ergänzt Scroll-Reveal + aktive Jahres-Navigation; ohne JS ist
|
||||
* alles sichtbar (Initial-Versteckt-Zustand nur unter .js-reveal).
|
||||
*/
|
||||
@@ -42,6 +46,7 @@ foreach ($events as $event) {
|
||||
<span class="timeline__marker" aria-hidden="true"></span>
|
||||
<article class="timeline__card">
|
||||
<h2 class="timeline__year"><?= e($event['year']) ?></h2>
|
||||
<?php if (!empty($event['image']['src'])): ?>
|
||||
<figure class="timeline__figure">
|
||||
<img src="<?= e(asset($event['image']['src'])) ?>"
|
||||
alt="<?= e($event['image']['alt']) ?>"
|
||||
@@ -49,6 +54,11 @@ foreach ($events as $event) {
|
||||
loading="<?= $i === 0 ? 'eager' : 'lazy' ?>" decoding="async">
|
||||
<figcaption class="timeline__text"><?= e($event['text']) ?></figcaption>
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<div class="timeline__figure timeline__figure--note">
|
||||
<p class="timeline__text"><?= e($event['text']) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
|
||||
52
app/components/veranstaltung-galerie.php
Normal file
52
app/components/veranstaltung-galerie.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Bildergalerie einer Veranstaltung: Raster aus Vorschaubildern, Klick öffnet
|
||||
* das Bild groß. Props:
|
||||
* $bilder array [{base, widths, alt, source?}, …] (script-verwaltet,
|
||||
* bin/veranstaltung-bilder.php)
|
||||
* $title string (optional, Default 'Bilder')
|
||||
*
|
||||
* Progressive enhancement: jedes Vorschaubild ist ein normaler Link auf die
|
||||
* größte Variante der Datei — ohne JS führt der Klick einfach zum Bild. Mit JS
|
||||
* fängt assets/js/lightbox.js den Klick ab und zeigt es im Overlay
|
||||
* (Vor/Zurück, Pfeiltasten, Esc). Das Overlay-Markup steht am Ende und ist
|
||||
* `hidden`, bis JS es übernimmt.
|
||||
*
|
||||
* Die Overlay-Grafik erbt ihr srcset vom angeklickten Vorschaubild (siehe
|
||||
* lightbox.js) — deshalb braucht der Link hier keine zweite Bildliste.
|
||||
*/
|
||||
$bilder = $bilder ?? [];
|
||||
$title = $title ?? 'Bilder';
|
||||
|
||||
if ($bilder === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$anzahl = count($bilder);
|
||||
?>
|
||||
<section class="section veranstaltung-galerie" id="bilder" data-lightbox-gallery>
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<ul class="veranstaltung-galerie__grid">
|
||||
<?php foreach ($bilder as $i => $bild): ?>
|
||||
<?php
|
||||
$gross = asset($bild['base'] . '-' . max($bild['widths']) . '.jpg');
|
||||
?>
|
||||
<li class="veranstaltung-galerie__item">
|
||||
<a class="veranstaltung-galerie__link" href="<?= e($gross) ?>" data-lightbox="<?= $i ?>">
|
||||
<?php component('img', [
|
||||
'image' => $bild,
|
||||
'sizes' => '(max-width: 600px) 45vw, (max-width: 992px) 30vw, 22vw',
|
||||
'class' => 'veranstaltung-galerie__img',
|
||||
]); ?>
|
||||
<span class="visually-hidden">(größer ansehen)</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php component('lightbox-overlay', ['label' => $title, 'count' => $anzahl]); ?>
|
||||
</section>
|
||||
93
app/components/veranstaltung-info.php
Normal file
93
app/components/veranstaltung-info.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Infoblock einer Veranstaltung: links die harten Angaben (Zeitraum, Beginn,
|
||||
* Adresse, Hinweis zum Parken), rechts die Karte. Props:
|
||||
* $date_text string fertig formatierter Zeitraum (german_date_range())
|
||||
* $date_iso string ISO-Startdatum für <time datetime>
|
||||
* $time string 'HH:MM' oder leer
|
||||
* $location string Ortsname der Veranstaltung
|
||||
* $karte string 'club' = auf unserem eigenen Gelände: Adresse kommt aus
|
||||
* data/club.json und die Karte wird gezeigt. Alles andere (oder
|
||||
* leer) → keine Karte, nur der Ortsname (ein Fest woanders hätte
|
||||
* sonst eine falsche Karte daneben).
|
||||
* $anfahrt array optionale Absätze zum Parken/Zugang
|
||||
*
|
||||
* Nur für Feste, die noch ausstehen (app/pages/veranstaltung.php ruft die
|
||||
* Komponente danach nicht mehr auf) — deshalb steht hier durchgehend Zukunft.
|
||||
*
|
||||
* KEINE Leaflet-Karte, sondern ein statisches Kartenbild: Leaflet würde seine
|
||||
* Kacheln zur Laufzeit von einem fremden Tile-Server holen, und genau das
|
||||
* verbietet Regel 1 (die CSP mit img-src 'self' würde sie ohnehin blocken).
|
||||
* Das Bild liegt selbst gehostet bei uns, der Klick führt erst dann zu Google
|
||||
* Maps, wenn jemand ihn macht. Namensnennung für die OSM-Daten steht als
|
||||
* Bildunterschrift, das verlangt die ODbL.
|
||||
*/
|
||||
$dateText = $date_text ?? '';
|
||||
$dateIso = $date_iso ?? '';
|
||||
$time = $time ?? '';
|
||||
$location = $location ?? '';
|
||||
$karte = $karte ?? '';
|
||||
$anfahrt = $anfahrt ?? [];
|
||||
|
||||
$istBeiUns = $karte === 'club';
|
||||
$club = $istBeiUns ? json_load('club') : [];
|
||||
$adresse = $club['address'] ?? [];
|
||||
$geo = $club['geo'] ?? [];
|
||||
|
||||
// Kartenlink ohne API-Schlüssel und ohne Einbettung: Koordinaten aus club.json
|
||||
// (Single Source), damit die Adresse hier nicht als Text zweitverwertet wird.
|
||||
$mapsUrl = '';
|
||||
if ($istBeiUns && !empty($geo['lat']) && !empty($geo['lng'])) {
|
||||
$mapsUrl = 'https://www.google.com/maps/search/?api=1&query=' . $geo['lat'] . ',' . $geo['lng'];
|
||||
}
|
||||
?>
|
||||
<section class="section veranstaltung-info" id="infos">
|
||||
<div class="container">
|
||||
<div class="veranstaltung-info__panel">
|
||||
<div class="veranstaltung-info__facts">
|
||||
<h2>Gut zu wissen</h2>
|
||||
<?php if ($dateText !== ''): ?>
|
||||
<p class="veranstaltung-info__wann">
|
||||
<time datetime="<?= e($dateIso) ?>"><?= e($dateText) ?></time>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($time !== ''): ?>
|
||||
<?php /* Wochentag dazu, weil bei einem mehrtägigen Fest sonst offen bleibt, für
|
||||
welchen Tag die Uhrzeit gilt. */ ?>
|
||||
<?php $wochentag = german_weekday($dateIso); ?>
|
||||
<p class="veranstaltung-info__beginn">Los geht es<?= $wochentag !== '' ? ' am ' . e($wochentag) : '' ?> um <?= e($time) ?> Uhr.</p>
|
||||
<?php endif; ?>
|
||||
<address class="veranstaltung-info__adresse">
|
||||
<?php if ($istBeiUns && !empty($adresse['street'])): ?>
|
||||
<?= e($adresse['venue'] ?? $location) ?><br>
|
||||
<?= e($adresse['street']) ?><br>
|
||||
<?= e($adresse['zip'] ?? '') ?> <?= e($adresse['city'] ?? '') ?>
|
||||
<?php else: ?>
|
||||
<?= e($location) ?>
|
||||
<?php endif; ?>
|
||||
</address>
|
||||
<?php foreach ($anfahrt as $absatz): ?>
|
||||
<p class="veranstaltung-info__hinweis"><?= e($absatz) ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($mapsUrl !== ''): ?>
|
||||
<figure class="veranstaltung-info__map">
|
||||
<a class="veranstaltung-info__map-link" href="<?= e($mapsUrl) ?>" target="_blank" rel="noopener">
|
||||
<?php component('img', [
|
||||
'image' => ['src' => 'img/pages/sportheim-map.jpg', 'width' => 512, 'height' => 512, 'alt' => 'Karte mit unserem Sportgelände im Katzbachtal'],
|
||||
'class' => 'veranstaltung-info__map-img',
|
||||
]); ?>
|
||||
<span class="veranstaltung-info__map-cta">
|
||||
<?= icon('geo-alt-fill') ?> In Google Maps öffnen
|
||||
<span class="visually-hidden">(öffnet in neuem Tab)</span>
|
||||
</span>
|
||||
</a>
|
||||
<figcaption class="veranstaltung-info__map-credit">Kartendaten © OpenStreetMap-Mitwirkende</figcaption>
|
||||
</figure>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
55
app/components/veranstaltung-plakate.php
Normal file
55
app/components/veranstaltung-plakate.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Die Plakate einer Veranstaltung (Ankündigung, Programm, …) als große,
|
||||
* anklickbare Bilder. Props:
|
||||
* $plakate array [{base, widths, alt, source?}, …] (script-verwaltet)
|
||||
* $title string (optional, Default 'Plakate')
|
||||
* $compact bool (optional) — kleinere Variante für den Rückblick
|
||||
*
|
||||
* Die Überschrift ist sichtbar, WENN die Plakate klein und nachgestellt sind
|
||||
* (Rückblick): nach einer langen Fotogalerie brauchen zwei kleine Bilder am
|
||||
* Seitenende eine Beschriftung. Als Hauptblock einer Ankündigung erklären sie
|
||||
* sich dagegen selbst, dort bleibt die Überschrift visually-hidden — im Markup
|
||||
* steht sie trotzdem, damit die Headings-Hierarchie keine Lücke hat (gleiche
|
||||
* Lösung wie in stadionzeitung-cards).
|
||||
*
|
||||
* Warum eine eigene Komponente und nicht die Fotogalerie: ein Plakat ist kein
|
||||
* Foto vom Fest, sondern trägt Informationen (Termine, Uhrzeiten, Ort). Es
|
||||
* gehört nach vorne statt in den Rückblick und braucht Hochformat statt
|
||||
* beschnittener Raster-Kacheln. Vergrößern läuft aber über dieselbe Lightbox —
|
||||
* gerade bei einem Programmplakat will man das lesen können.
|
||||
*
|
||||
* Ohne JS ist jedes Plakat ein normaler Link auf die größte Variante.
|
||||
*/
|
||||
$plakate = $plakate ?? [];
|
||||
$title = $title ?? 'Plakate';
|
||||
$compact = $compact ?? false;
|
||||
|
||||
if ($plakate === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section veranstaltung-plakate<?= $compact ? ' veranstaltung-plakate--compact' : '' ?>" id="plakate" data-lightbox-gallery>
|
||||
<div class="container">
|
||||
<h2<?= $compact ? '' : ' class="visually-hidden"' ?>><?= e($title) ?></h2>
|
||||
<ul class="veranstaltung-plakate__grid">
|
||||
<?php foreach ($plakate as $i => $plakat): ?>
|
||||
<li class="veranstaltung-plakate__item">
|
||||
<a class="veranstaltung-plakate__link" href="<?= e(asset($plakat['base'] . '-' . max($plakat['widths']) . '.jpg')) ?>" data-lightbox="<?= $i ?>">
|
||||
<?php component('img', [
|
||||
'image' => $plakat,
|
||||
'sizes' => count($plakate) > 1 ? '(max-width: 768px) 92vw, 44vw' : '(max-width: 768px) 92vw, 30vw',
|
||||
'eager' => $i === 0 && !$compact,
|
||||
'class' => 'veranstaltung-plakate__img',
|
||||
]); ?>
|
||||
<span class="visually-hidden">(größer ansehen)</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php component('lightbox-overlay', ['label' => $title, 'count' => count($plakate)]); ?>
|
||||
</section>
|
||||
66
app/components/veranstaltung-programm.php
Normal file
66
app/components/veranstaltung-programm.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tagesprogramm einer Veranstaltung. Props:
|
||||
* $programm array [{tag: 'YYYY-MM-DD', punkte: [{zeit: 'HH:MM', titel, text?}]}, …]
|
||||
* $title string (optional, Default 'Programm')
|
||||
* $compact bool (optional) — kleinere Variante für den Rückblick, wo das
|
||||
* Programm nicht mehr informiert, sondern nur noch dokumentiert.
|
||||
*
|
||||
* Ein Tag = eine Gruppe mit Wochentag-Überschrift, damit ein dreitägiges Fest
|
||||
* lesbar bleibt. Ohne Programm rendert die Komponente nichts (die Seite startet
|
||||
* ja mit nichts als dem Plakat).
|
||||
*/
|
||||
$programm = $programm ?? [];
|
||||
$title = $title ?? 'Programm';
|
||||
$compact = $compact ?? false;
|
||||
|
||||
if ($programm === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section veranstaltung-programm<?= $compact ? ' veranstaltung-programm--compact' : '' ?>" id="programm">
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<div class="veranstaltung-programm__tage">
|
||||
<?php foreach ($programm as $tag): ?>
|
||||
<?php
|
||||
$tagIso = (string) ($tag['tag'] ?? '');
|
||||
$weekday = german_weekday($tagIso);
|
||||
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . german_date($tagIso));
|
||||
?>
|
||||
<div class="veranstaltung-programm__tag">
|
||||
<?php if ($tagIso !== ''): ?>
|
||||
<h3 class="veranstaltung-programm__tag-titel">
|
||||
<time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time>
|
||||
</h3>
|
||||
<?php endif; ?>
|
||||
<ul class="veranstaltung-programm__liste">
|
||||
<?php foreach ($tag['punkte'] ?? [] as $punkt): ?>
|
||||
<?php $zeit = (string) ($punkt['zeit'] ?? ''); ?>
|
||||
<li class="veranstaltung-programm__punkt">
|
||||
<?php if ($zeit !== ''): ?>
|
||||
<span class="veranstaltung-programm__zeit">
|
||||
<time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<?php /* Platzhalter hält die Spalte, aber ohne die Zeit-Klasse — sonst stünde
|
||||
dort der rote Balken ohne Inhalt daneben. */ ?>
|
||||
<span aria-hidden="true"></span>
|
||||
<?php endif; ?>
|
||||
<span class="veranstaltung-programm__body">
|
||||
<span class="veranstaltung-programm__titel"><?= e($punkt['titel'] ?? '') ?></span>
|
||||
<?php if (!empty($punkt['text'])): ?>
|
||||
<span class="veranstaltung-programm__text"><?= e($punkt['text']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
68
app/components/veranstaltungen-cards.php
Normal file
68
app/components/veranstaltungen-cards.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liste der Veranstaltungen, ganze Karte verlinkt auf die Detailseite. Props:
|
||||
* $items array aus data/veranstaltungen.json (bereits vorsortiert)
|
||||
* $cta string (optional, Default 'Ansehen') — Linktext auf der Karte
|
||||
* $empty_text string (optional)
|
||||
*
|
||||
* EINE Kartenform für „Was ansteht" und „Rückblick" (Beschluss 30.07.2026):
|
||||
* Plakat links, Text rechts, volle Breite, untereinander gestapelt. Vorher war
|
||||
* der erste Eintrag breit und alles Weitere ein schmales Raster — zwei Formen
|
||||
* für dieselbe Sache, und der Rückblick sah dadurch nach Restposten aus.
|
||||
*
|
||||
* Vorschaubild ist das Hauptplakat (Hochformat). Fehlt es (die Seite entsteht ja
|
||||
* schon, bevor das Plakat fertig ist), greift das erste Galeriebild; ohne
|
||||
* beides bleibt die Karte reiner Text und bricht nicht.
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$cta = $cta ?? 'Ansehen';
|
||||
$emptyText = $empty_text ?? 'Aktuell keine Veranstaltungen.';
|
||||
|
||||
/** Erstes verfügbares Bild einer Veranstaltung (Hauptplakat vor Galerie). */
|
||||
$vorschau = static function (array $v): ?array {
|
||||
return $v['plakate'][0] ?? $v['galerie'][0] ?? null;
|
||||
};
|
||||
?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted"><?= e($emptyText) ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="veranstaltungen-cards__list">
|
||||
<?php foreach ($items as $v): ?>
|
||||
<?php
|
||||
$bild = $vorschau($v);
|
||||
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
|
||||
?>
|
||||
<li>
|
||||
<a class="veranstaltung-card" href="<?= e(url('veranstaltungen/' . $v['slug'])) ?>">
|
||||
<?php if ($bild !== null): ?>
|
||||
<div class="veranstaltung-card__media">
|
||||
<?php component('img', [
|
||||
'image' => $bild,
|
||||
'sizes' => '(max-width: 768px) 100vw, 300px',
|
||||
'class' => 'veranstaltung-card__img',
|
||||
]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="veranstaltung-card__body">
|
||||
<h3 class="veranstaltung-card__name"><?= e($v['title']) ?></h3>
|
||||
<p class="veranstaltung-card__meta">
|
||||
<time datetime="<?= e($v['date_start']) ?>"><?= e($dateText) ?></time>
|
||||
<?php if (!empty($v['location'])): ?>
|
||||
· <?= e($v['location']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (!empty($v['text'])): ?>
|
||||
<p class="veranstaltung-card__text"><?= e($v['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<span class="veranstaltung-card__cta">
|
||||
<?= e($cta) ?> <?= icon('arrow-right', 'veranstaltung-card__arrow') ?>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
1060
app/helpers.php
1060
app/helpers.php
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,8 @@ $anchor_slug = static function (string $s): string {
|
||||
|
||||
$meta = [
|
||||
'title' => 'Ansprechpartner',
|
||||
'description' => 'Ansprechpartner und Kontaktwege beim TSV 1908 Kulmbach – Geschäftsstelle, Turnabteilung und Vorstand mit E-Mail und Telefon auf einen Blick.',
|
||||
'description' => 'Alle Ansprechpartner des TSV 08 Kulmbach auf einen Blick: Herren-, Frauen- und Jugendfußball sowie Turnen, mit Telefonnummer oder E-Mail. Sprich uns direkt an!',
|
||||
'scripts' => ['persons-tabs.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
@@ -40,7 +41,7 @@ $meta = [
|
||||
];
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'bottom-left',
|
||||
'align' => 'center',
|
||||
'title' => 'Ansprechpartner',
|
||||
'text' => $page['intro'] ?? '',
|
||||
'image' => [
|
||||
@@ -50,14 +51,29 @@ component('hero', ['hero' => [
|
||||
],
|
||||
]]);
|
||||
|
||||
$i = 0;
|
||||
foreach ($groups as $label => $depts):
|
||||
?>
|
||||
<?php /* EINE Sektion für beide Bereiche, Tab-Leiste direkt über den
|
||||
Karten (assets/js/persons-tabs.js schaltet um und spiegelt den
|
||||
aktiven Bereich in die URL — #kontakt-turnen ist teilbar).
|
||||
Ohne JS bleibt die Leiste hidden und beide Panels stehen mit
|
||||
ihren Überschriften untereinander. */ ?>
|
||||
<section class="section section--tinted" id="kontakt" data-persons-tabs>
|
||||
<div class="container">
|
||||
<div class="filter-bar persons-tabs__bar" role="group" aria-label="Bereich wählen" hidden data-persons-tabs-bar>
|
||||
<?php $i = 0; foreach (array_keys($groups) as $label): ?>
|
||||
<button type="button" class="tab filter-bar__tab" data-tab-target="kontakt-<?= e($anchor_slug($label)) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
foreach ($groups as $label => $depts) {
|
||||
component('person-grid', [
|
||||
'departments' => $depts,
|
||||
'title' => $label,
|
||||
'intro' => '',
|
||||
'anchor' => 'kontakt-' . $anchor_slug($label),
|
||||
'tinted' => ($i % 2 === 0),
|
||||
'embedded' => true,
|
||||
]);
|
||||
$i++;
|
||||
endforeach;
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
|
||||
@@ -5,10 +5,16 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
|
||||
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
|
||||
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die drei
|
||||
* Formulare (Versand über den All-Inkl-Mailserver, also derselbe Auftrags-
|
||||
* verarbeiter wie beim Hosting), server-seitig gecachter Instagram-Feed
|
||||
* (Bilder von unserer Domain). Vereinsdaten aus club.json.
|
||||
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die vier
|
||||
* Formulare (Kontakt, Turnabteilung, Mitgliedsantrag, Sportheimbuchung; Versand
|
||||
* über den All-Inkl-Mailserver, also derselbe Auftragsverarbeiter wie beim
|
||||
* Hosting), server-seitig gecachter Instagram-Feed (Bilder von unserer Domain).
|
||||
* Vereinsdaten aus club.json.
|
||||
*
|
||||
* Alle Empfänger liegen auf unserer eigenen Domain bei All-Inkl (info@,
|
||||
* turnen@) — deshalb stimmt die Aussage weiter unten, dass kein zusätzlicher
|
||||
* E-Mail-Dienstleister im Spiel ist. Zeigt ein Postfach künftig auf einen
|
||||
* fremden Anbieter, muss dieser Satz mit.
|
||||
*/
|
||||
$club = json_load('club');
|
||||
$addr = $club['address'] ?? [];
|
||||
@@ -36,7 +42,7 @@ $meta = [
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal__body">
|
||||
<div class="legal__body prose">
|
||||
<section>
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</p>
|
||||
@@ -123,6 +129,12 @@ $meta = [
|
||||
Tracking-Zwecken</strong> und bindet keine Dienste ein, die solche Cookies
|
||||
setzen würden. Aus diesem Grund benötigen wir auch kein Cookie-Banner.
|
||||
</p>
|
||||
<p>
|
||||
Eine Ausnahme betrifft nur den passwortgeschützten internen Bereich
|
||||
(siehe „Interne Dateiablage“): Wer sich dort anmeldet, erhält ein
|
||||
technisch notwendiges Sitzungs-Cookie, das die Anmeldung für die Dauer
|
||||
der Sitzung merkt. Ohne dieses Cookie ist eine Anmeldung nicht möglich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -152,6 +164,15 @@ $meta = [
|
||||
E-Mail-Adresse, optional die Telefonnummer sowie Art, Datum und Personenzahl
|
||||
deiner Veranstaltung, um dir ein Angebot machen zu können.
|
||||
</p>
|
||||
<p>
|
||||
Das <strong>Anfrageformular der Turnabteilung</strong> auf
|
||||
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
|
||||
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
|
||||
interessierst; eine Nachricht ist dort freiwillig. Diese Anfragen gehen an das
|
||||
Postfach der Turnabteilung
|
||||
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
|
||||
weil die Abteilung sie selbst beantwortet.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
|
||||
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
|
||||
@@ -160,6 +181,18 @@ $meta = [
|
||||
deine Anfrage abschließend bearbeitet ist und keine gesetzlichen
|
||||
Aufbewahrungspflichten entgegenstehen.
|
||||
</p>
|
||||
<p>
|
||||
Schickst du uns Material für unsere <strong>Vereinshistorie</strong> – Fotos,
|
||||
Zeitungsausschnitte, Dokumente oder Erinnerungen – an
|
||||
<a href="mailto:<?= e($club['email_historie']) ?>"><?= e($club['email_historie']) ?></a>,
|
||||
bewahren wir es gemeinsam mit deinem Namen als Quellenangabe in unserem
|
||||
Vereinsarchiv auf. Grundlage dafür ist unser berechtigtes Interesse an der
|
||||
Dokumentation der Vereinsgeschichte (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
Veröffentlichen – auf dieser Website, in einer Chronik oder Festschrift –
|
||||
tun wir nur, was du uns ausdrücklich freigegeben hast (Art. 6 Abs. 1 lit. a
|
||||
DSGVO); diese Freigabe kannst du jederzeit widerrufen. Sind auf Bildern
|
||||
weitere Personen erkennbar, sag uns bitte, was du darüber weißt.
|
||||
</p>
|
||||
<p>
|
||||
Der technische Versand der Formular-Nachrichten als E-Mail läuft über den
|
||||
Mailserver unseres Webhosters ALL-INKL.COM (siehe oben) — die Daten bleiben
|
||||
@@ -177,6 +210,35 @@ $meta = [
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Interne Dateiablage</h2>
|
||||
<p>
|
||||
Unter <code>/dateien</code> betreiben wir einen
|
||||
<strong>passwortgeschützten Bereich</strong>, in dem Mitglieder Fotos und
|
||||
Videos von Vereinsveranstaltungen für die Website abliefern können. Er ist
|
||||
nicht öffentlich, nicht verlinkt und von Suchmaschinen ausgenommen; die
|
||||
Zugangsdaten geben wir nur intern weiter.
|
||||
</p>
|
||||
<p>
|
||||
Verarbeitet werden dort ausschließlich die hochgeladenen Dateien selbst
|
||||
(einschließlich der darin enthaltenen Aufnahmen von Personen und der
|
||||
technischen Bilddaten wie Aufnahmezeit oder Kameramodell) sowie der
|
||||
gewählte Benutzername mit dem Zeitpunkt der Anmeldung. Fehlgeschlagene
|
||||
Anmeldeversuche halten wir zum Schutz gegen automatisierte Angriffe kurz
|
||||
fest, dabei die IP-Adresse nur als nicht zurückrechenbare Prüfsumme.
|
||||
</p>
|
||||
<p>
|
||||
Die Dateien liegen ausschließlich auf unserem eigenen Webspace, außerhalb
|
||||
des öffentlich erreichbaren Bereichs, und werden nicht an Dritte
|
||||
weitergegeben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO
|
||||
(berechtigtes Interesse an der Vereinskommunikation); für die
|
||||
Veröffentlichung von Personenaufnahmen auf der Website gilt zusätzlich der
|
||||
Abschnitt „Foto- und Videoaufnahmen“. Wer nicht möchte, dass eine Aufnahme
|
||||
von ihm dort abgelegt oder veröffentlicht wird, kann sich jederzeit an die
|
||||
oben genannten Kontaktdaten wenden – wir löschen sie dann.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Keine Analyse-, Tracking- oder Werbedienste</h2>
|
||||
<p>
|
||||
|
||||
@@ -21,7 +21,7 @@ $cards = [
|
||||
|
||||
$meta = [
|
||||
'title' => 'Fußball in Kulmbach',
|
||||
'description' => 'Herren-, Frauen- und Jugendfußball beim TSV 08 Kulmbach: zwei Herrenmannschaften, Frauenmannschaft und Nachwuchs – aktiv werden im Katzbachtal.',
|
||||
'description' => 'Fußball in Kulmbach beim TSV 08: zwei Herrenmannschaften, Frauenfußball und Jugendteams von der G- bis zur C-Jugend. Komm zum Probetraining im Katzbachtal!',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
@@ -3,14 +3,28 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vereinshistorie — schmaler Hero + interaktive Timeline.
|
||||
* Inhalte: data/historie.json (Single Source of Truth).
|
||||
* Vereinshistorie — schmaler Hero + interaktive Timeline + Aufruf zum Mitsammeln.
|
||||
* Inhalte: data/historie.json (Single Source of Truth), Mailadresse aus data/club.json.
|
||||
*/
|
||||
$historie = json_load('historie');
|
||||
$club = json_load('club');
|
||||
|
||||
// Die Adresse steht ausschließlich in club.json — der Fließtext in historie.json
|
||||
// markiert ihre Stelle mit {mail}, CTA-Label und mailto-Ziel entstehen hier.
|
||||
// Betreff vorbelegt, damit Zuschriften direkt zuordenbar sind.
|
||||
$mail = $club['email_historie'];
|
||||
$contribute = $historie['contribute'] + ['cta' => [
|
||||
'label' => $mail,
|
||||
'href' => 'mailto:' . $mail . '?subject=' . rawurlencode('Material für die Vereinshistorie'),
|
||||
]];
|
||||
$contribute['text'] = array_map(
|
||||
static fn (string $p): string => strtr($p, ['{mail}' => $mail]),
|
||||
$contribute['text']
|
||||
);
|
||||
|
||||
$meta = [
|
||||
'title' => 'Vereinshistorie – Der Katzbachtaler Weg',
|
||||
'description' => 'Erlebe die bewegte Geschichte des TSV 08 Kulmbach – von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
|
||||
'title' => 'Vereinshistorie: Der Katzbachtaler Weg',
|
||||
'description' => 'Erlebe unsere bewegte Geschichte, von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
|
||||
'scripts' => ['timeline.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
@@ -21,3 +35,4 @@ $meta = [
|
||||
|
||||
component('hero', ['hero' => $historie['hero']]);
|
||||
component('timeline', ['events' => $historie['events']]);
|
||||
component('section', ['section' => $contribute]);
|
||||
|
||||
@@ -10,14 +10,17 @@ declare(strict_types=1);
|
||||
$home = json_load('home');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Fußball & Turnen in Kulmbach – TSV 08 Kulmbach',
|
||||
'title' => 'Fußball & Turnen in Kulmbach | TSV 08 Kulmbach',
|
||||
'title_absolute' => true,
|
||||
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin – seit 1908 im Katzbachtal. Jetzt Mitglied werden!',
|
||||
'scripts' => ['form.js', 'carousel.js'],
|
||||
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin: seit 1908 in Kulmbach. Jetzt Mitglied werden!',
|
||||
'scripts' => ['form.js', 'carousel.js', 'crest.js'],
|
||||
'schema' => [website_schema()],
|
||||
];
|
||||
|
||||
component('hero', ['hero' => $home['hero']]);
|
||||
// Die nächsten drei Termine als Live-Aufhänger im Hero — Spiele und
|
||||
// Vereins-Events gemischt, aus upcoming_highlights() (baut auf derselben
|
||||
// Merge-Logik auf wie /termine). Keine Termine → der Hero rendert ohne Karte.
|
||||
component('hero', ['hero' => $home['hero'], 'upcoming' => upcoming_highlights(3)]);
|
||||
component('banner-slider');
|
||||
component('instagram-feed');
|
||||
|
||||
@@ -25,12 +28,10 @@ foreach ($home['sections'] as $section) {
|
||||
component('section', ['section' => $section]);
|
||||
}
|
||||
|
||||
component('stats', ['stats' => $home['stats']]);
|
||||
component('partner-grid', ['intro' => $home['partners']]);
|
||||
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
|
||||
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
|
||||
component('person-grid', [
|
||||
'departments' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
|
||||
'columns' => 3,
|
||||
]);
|
||||
component('contact-form', ['intro' => $home['contact']]);
|
||||
|
||||
@@ -28,7 +28,7 @@ $meta = [
|
||||
<p class="legal__lead">Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).</p>
|
||||
</div>
|
||||
|
||||
<div class="legal__body">
|
||||
<div class="legal__body prose">
|
||||
<section>
|
||||
<h2>Anbieter</h2>
|
||||
<div class="legal__card">
|
||||
|
||||
@@ -21,7 +21,7 @@ $contacts = array_values(array_filter(
|
||||
|
||||
$meta = [
|
||||
'title' => 'Jugendfußball in Kulmbach',
|
||||
'description' => 'Jugendfußball in Kulmbach – entdecke unsere Mannschaften von der G- bis zur C-Jugend, Betreuer und Trainingszeiten. Probiere Fußball in familiärer Atmosphäre aus.',
|
||||
'description' => 'Jugendfußball in Kulmbach beim TSV 08: Teams von der G- bis zur C-Jugend, Trainingszeiten und kostenloses Schnuppertraining. Komm vorbei ins Katzbachtal!',
|
||||
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
|
||||
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
|
||||
'schema' => page_schema(
|
||||
@@ -70,7 +70,7 @@ component('age-groups', ['groups' => $page['groups'] ?? []]);
|
||||
|
||||
component('faq-contact', [
|
||||
'faq' => $page['faq'] ?? [],
|
||||
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp – wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
|
||||
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp. Wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
|
||||
'persons' => $contacts,
|
||||
'contactTitle' => '',
|
||||
'anchor' => 'kontakt',
|
||||
|
||||
@@ -10,7 +10,7 @@ $page = json_load('kontakt');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Kontakt',
|
||||
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular – zu Fußball, Turnen oder einem allgemeinen Anliegen. Wir melden uns so schnell wie möglich.',
|
||||
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder deinem Anliegen. Wir melden uns so schnell wie möglich.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'scripts' => ['form.js'],
|
||||
'schema' => page_schema([
|
||||
|
||||
@@ -14,9 +14,9 @@ $teams = $mc['teams'] ?? [];
|
||||
$upcoming = $mc['upcoming'] ?? [];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Matchcenter – Spiele, Ergebnisse & Tabellen',
|
||||
'title' => 'Matchcenter · Spiele, Ergebnisse & Tabellen',
|
||||
'description' => 'Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften des TSV 08 Kulmbach: 1. Mannschaft und Frauenmannschaft – automatisch aktuell.',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'og_image' => 'img/pages/matchcenter-hero-1600.jpg',
|
||||
'scripts' => ['crest.js', 'group-tabs.js'],
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
@@ -30,11 +30,16 @@ $meta = [
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'bottom-left',
|
||||
'align' => 'center',
|
||||
'title' => 'Matchcenter',
|
||||
'text' => 'Alle Spiele, Ergebnisse und Tabellen unserer Aktivenmannschaften – immer aktuell.',
|
||||
/* Eigenes Hero-Motiv (Spielszene) statt des leeren Platzes von /fussball — die Seite
|
||||
zeigt Spiele, also zeigt der Kopf welche. Quelle: 14_mrz.jpg aus der alten Seite,
|
||||
vor dem Skalieren oben um 230 px beschnitten (1620x1080 → 1620x850). Der Beschnitt
|
||||
rückt die Spielszene in die Bildmitte, sonst schneidet der flache Compact-Hero sie
|
||||
auf Mobil komplett weg. */
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'base' => 'img/pages/matchcenter-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ $feeOptions = array_map(
|
||||
|
||||
$meta = [
|
||||
'title' => 'Mitglied werden',
|
||||
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag – alle Beiträge und Infos für deinen Vereinsbeitritt.',
|
||||
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag. Alle Beiträge und Infos für deinen Vereinsbeitritt.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'scripts' => ['form.js'],
|
||||
'schema' => page_schema(
|
||||
@@ -33,7 +33,9 @@ $meta = [
|
||||
|
||||
component('hero', ['hero' => $page['hero']]);
|
||||
|
||||
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung']);
|
||||
// Titel nur für Screenreader: der Hero sagt bereits „Wähle deine Abteilung", und die
|
||||
// beiden Karten sprechen optisch für sich. Die h2 bleibt für die Heading-Kette stehen.
|
||||
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung', 'title_hidden' => true]);
|
||||
?>
|
||||
<section class="section membership-signup" id="anmeldung-fussball">
|
||||
<div class="container membership-signup__inner">
|
||||
|
||||
@@ -26,8 +26,8 @@ $contact_for = static function (array $pos) use ($directory): ?array {
|
||||
|
||||
$meta = [
|
||||
'title' => 'Mitmachen & Ehrenamt',
|
||||
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen – mit direktem Ansprechpartner, ohne Vorerfahrung.',
|
||||
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
|
||||
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen. Direkte Ansprechpartner, keine Vorerfahrung nötig!',
|
||||
'og_image' => 'img/pages/mitmachen-hero-1600.jpg',
|
||||
'scripts' => ['position-filter.js'],
|
||||
'schema' => array_merge(
|
||||
page_schema(
|
||||
@@ -61,7 +61,7 @@ component('hero', ['hero' => $page['hero']]);
|
||||
<?php component('position-card', ['position' => $pos, 'contact' => $contact_for($pos)]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei – oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
|
||||
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
|
||||
</div>
|
||||
</section>
|
||||
<?php if (!empty($page['arguments']['items'])): ?>
|
||||
|
||||
82
app/pages/news-artikel.php
Normal file
82
app/pages/news-artikel.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Einzelne Meldung als Artikel: dynamische Route news/<slug>, siehe
|
||||
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
|
||||
* Ermittelt den Slug selbst wieder aus der URL, Daten aus data/news.json
|
||||
* (chat-gepflegt, keine Automatisierung).
|
||||
*
|
||||
* Die Seite existiert nur für Meldungen MIT `artikel` (news_has_article()):
|
||||
* eine Kurzmeldung bleibt eine Feed-Zeile auf /news, ihre URL liefert 404. Ohne
|
||||
* diese Bedingung stünde unter jedem Slug eine Seite mit nichts als der
|
||||
* Überschrift.
|
||||
*
|
||||
* Geladen wird bewusst UNGEFILTERT (news_find(), nicht news_load()): ein
|
||||
* Entwurf muss unter seiner direkten URL lesbar bleiben, damit er gegengelesen
|
||||
* werden kann. Sichtbar wird er dadurch nirgends sonst, und er trägt noindex.
|
||||
*
|
||||
* Bewusst OHNE hero (wie die Veranstaltungs-Detailseite): das Titelbild ist der
|
||||
* Aufmacher im Artikelkörper, nicht ein cover-beschnittener Hintergrund.
|
||||
*/
|
||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$newsSlug = substr($urlSlug, strlen('news/'));
|
||||
|
||||
$n = $newsSlug !== '' ? news_find($newsSlug) : null;
|
||||
|
||||
if ($n === null || !news_has_article($n)) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
|
||||
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
|
||||
http_response_code(404);
|
||||
require APP_PATH . '/pages/404.php';
|
||||
return;
|
||||
}
|
||||
|
||||
$isDraft = !news_is_published($n);
|
||||
$pageSlug = 'news/' . $n['slug'];
|
||||
$hatBilder = array_filter(
|
||||
$n['artikel'],
|
||||
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild'
|
||||
) !== [];
|
||||
|
||||
$meta = [
|
||||
'title' => $n['title'],
|
||||
'description' => (string) ($n['text'] ?? $n['title']),
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'News', 'slug' => 'news'],
|
||||
['name' => $n['title'], 'slug' => $pageSlug],
|
||||
]),
|
||||
// Mit $pageSlug: derselbe Article-Knoten (@id) wie auf /news.
|
||||
array_values(array_filter([news_article_schema($n, $pageSlug)]))
|
||||
),
|
||||
];
|
||||
|
||||
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
|
||||
if ($hatBilder) {
|
||||
$meta['scripts'] = ['lightbox.js'];
|
||||
}
|
||||
|
||||
if (!empty($n['image']['base']) && !empty($n['image']['widths'])) {
|
||||
$meta['og_image'] = $n['image']['base'] . '-' . max($n['image']['widths']) . '.jpg';
|
||||
}
|
||||
|
||||
if ($isDraft) {
|
||||
$meta['noindex'] = true;
|
||||
}
|
||||
?>
|
||||
<?php component('detail-head', [
|
||||
'back' => ['href' => url('news'), 'label' => 'Alle News'],
|
||||
'title' => $n['title'],
|
||||
'badge' => $isDraft ? 'Entwurf' : '',
|
||||
'date_iso' => (string) ($n['date'] ?? ''),
|
||||
'date_text' => !empty($n['date']) ? german_date((string) $n['date']) : '',
|
||||
]); ?>
|
||||
<?php component('news-artikel', [
|
||||
'image' => $n['image'] ?? null,
|
||||
'blocks' => $n['artikel'],
|
||||
]); ?>
|
||||
56
app/pages/news.php
Normal file
56
app/pages/news.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zentrale News-Seite: mischt redaktionelle Meldungen (data/news.json,
|
||||
* chat-gepflegt), Instagram-Posts (data/instagram.json, automatisch gecacht)
|
||||
* und zuletzt gespielte Ergebnisse (data/matchcenter.json, teamübergreifend)
|
||||
* zu einem gemeinsamen, absteigend sortierten Feed. Merge-Logik in
|
||||
* news_build_feed() (app/helpers.php) — dieselbe Grundlage nutzt die
|
||||
* Stadionzeitungs-Vorlage (Phase 3), damit beide nie auseinanderlaufen.
|
||||
*
|
||||
* Meldungen mit Artikeltext (`artikel` in data/news.json) verlinken auf ihre
|
||||
* Detailseite /news/<slug>. Sie stehen immer alle im Feed: die Kappung aus
|
||||
* config('news.max_items') trifft nur Instagram und Ergebnisse, sonst wäre ein
|
||||
* älterer Artikel irgendwann von der Website aus nicht mehr verlinkt.
|
||||
*/
|
||||
$feed = news_build_feed();
|
||||
|
||||
$meta = [
|
||||
'title' => 'News',
|
||||
'description' => 'Alle Neuigkeiten des TSV 08 Kulmbach: aktuelle Meldungen, Fußball-Ergebnisse und Instagram-Beiträge aus dem Vereinsleben in Kulmbach an einem Ort.',
|
||||
'scripts' => ['news-filter.js'],
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'News', 'slug' => 'news'],
|
||||
]),
|
||||
// Meldungen mit Detailseite bekommen ihren Slug mit — dann trägt der
|
||||
// Article-Knoten hier dieselbe @id wie auf /news/<slug>, beide Seiten
|
||||
// beschreiben also nachweislich denselben Artikel.
|
||||
array_values(array_filter(array_map(
|
||||
static fn (array $n): ?array => news_article_schema($n, news_has_article($n) ? 'news/' . $n['slug'] : null),
|
||||
$feed['news']
|
||||
)))
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'News',
|
||||
'text' => 'Unsere Meldungen, Ergebnisse und Instagram-Beiträge an einem Ort.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<?php component('news-feed', ['items' => $feed['items'], 'featured' => true, 'filterable' => true, 'empty_text' => 'Aktuell gibt es noch keine News.']); ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -11,7 +11,7 @@ $page = json_load('partner-werden');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Werbepartner werden',
|
||||
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach – sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen.',
|
||||
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach: sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen!',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
@@ -11,7 +11,7 @@ $club = json_load('club');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Sportheim mieten in Kulmbach',
|
||||
'description' => 'Das Sportheim auf der Hans-Rausch-Sportanlage in Kulmbach für Feiern und Veranstaltungen mieten – Küche, Außenbereich, Beamer optional. Jetzt anfragen.',
|
||||
'description' => 'Sportheim in Kulmbach mieten: auf der Hans-Rausch-Sportanlage, mit Küche, Außenbereich und Beamer auf Wunsch. Jetzt unverbindlich anfragen!',
|
||||
'og_image' => 'img/pages/sportheim-2.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
140
app/pages/stadionzeitung-ausgabe.php
Normal file
140
app/pages/stadionzeitung-ausgabe.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Einzelne Stadionzeitung-Ausgabe: dynamische Route stadionzeitung/<slug>,
|
||||
* siehe public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs
|
||||
* kennt). Ermittelt den Ausgaben-Slug selbst wieder aus der URL, Daten aus
|
||||
* data/stadionzeitung.json (maschinenverwaltet von bin/stadionzeitung-add.php).
|
||||
*
|
||||
* Bewusst OHNE das übliche hero-Component: der Swipe-Viewer soll so viel
|
||||
* Bildschirmhöhe wie möglich bekommen (gerade am Handy) — stattdessen ein
|
||||
* schmaler Kopfbereich direkt über dem Viewer.
|
||||
*/
|
||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$ausgabeSlug = substr($urlSlug, strlen('stadionzeitung/'));
|
||||
|
||||
/*
|
||||
* Reservierter Slug „aktuell": leitet auf die neueste veröffentlichte Ausgabe
|
||||
* weiter. Damit zeigt ein GEDRUCKTER QR-Code (Plakat, Aushang) dauerhaft auf die
|
||||
* jeweils aktuelle Ausgabe, ohne dass zu jedem Heimspiel ein neuer Code
|
||||
* entstehen muss. Kollision ausgeschlossen: Ausgaben-Slugs beginnen immer mit
|
||||
* dem Datum (2026-08-01-…).
|
||||
*
|
||||
* Drei Dinge sind hier load-bearing:
|
||||
* 1. **302, nie 301.** Ein 301 wird von Browsern und Crawlern dauerhaft
|
||||
* gemerkt — das Plakat zeigte dann für immer auf die erste Ausgabe, und
|
||||
* zwar unreparierbar auf allen Geräten, die ihn einmal gesehen haben.
|
||||
* 2. `no-store`, damit ein Handy, das im letzten Monat gescannt hat, nicht
|
||||
* das alte Ziel wiederverwendet.
|
||||
* 3. Ohne veröffentlichte Ausgabe geht es aufs Archiv statt in einen 404 —
|
||||
* ein gedruckter Code darf nie ins Leere führen.
|
||||
*/
|
||||
if ($ausgabeSlug === 'aktuell') {
|
||||
$latest = stadionzeitung_latest();
|
||||
header('Cache-Control: no-store');
|
||||
header(
|
||||
'Location: ' . ($latest !== null ? url('stadionzeitung/' . $latest['slug']) : url('stadionzeitung')),
|
||||
true,
|
||||
302
|
||||
);
|
||||
// exit, nicht return: nach einem `return` liefe in public/index.php noch
|
||||
// layout.php und hängte einen kompletten HTML-Body an die Weiterleitung.
|
||||
// Der Ausgabepuffer aus render_page() ist hier noch leer, es geht also
|
||||
// nichts verloren.
|
||||
exit;
|
||||
}
|
||||
|
||||
$issue = null;
|
||||
foreach (json_load('stadionzeitung')['issues'] ?? [] as $entry) {
|
||||
if (($entry['slug'] ?? '') === $ausgabeSlug) {
|
||||
$issue = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($issue === null) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
|
||||
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
|
||||
http_response_code(404);
|
||||
require APP_PATH . '/pages/404.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Entwurf vs. veröffentlicht (Interview-Beschluss 29.07.2026): Entwürfe sind
|
||||
// „unsichtbar, aber offen" — sie fehlen im Archiv und in der Sitemap und
|
||||
// tragen noindex, die direkte URL funktioniert aber (zum Gegenlesen ohne
|
||||
// Login-System). Die Druckfassung (?druck=1) montiert dieselben Seiten in
|
||||
// Broschüren-Reihenfolge fürs gefaltete A5-Heft.
|
||||
$isDraft = !stadionzeitung_is_published($issue);
|
||||
$isDruck = isset($_GET['druck']);
|
||||
|
||||
// Erste Seite ist seit dem automatischen Cover (Phase 3) eine Live-Seite ohne
|
||||
// eigenes Bild — og_image gibt es nur, wenn die Ausgabe (noch/wieder) mit
|
||||
// einem Bild beginnt, sonst greift das globale Default-OG-Bild (meta.php).
|
||||
$firstImage = $issue['images'][0] ?? null;
|
||||
$cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null;
|
||||
|
||||
$meta = [
|
||||
'title' => 'Stadionzeitung gegen ' . $issue['opponent'] . ($isDruck ? ' (Druckfassung)' : ''),
|
||||
'description' => 'Stadionzeitung zum Heimspiel gegen ' . $issue['opponent'] . ' am ' . german_date($issue['date']) . ' – digital zum Durchblättern.',
|
||||
'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Fußball', 'slug' => 'fussball'],
|
||||
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
|
||||
['name' => 'Gegen ' . $issue['opponent'], 'slug' => 'stadionzeitung/' . $issue['slug']],
|
||||
]),
|
||||
];
|
||||
if ($cover !== null) {
|
||||
$meta['og_image'] = $cover['base'] . '-' . max($cover['widths']) . '.jpg';
|
||||
}
|
||||
// Entwürfe und die Druckfassung (parametrisierte Zweitansicht derselben
|
||||
// Inhalte) gehören nicht in den Index.
|
||||
if ($isDraft || $isDruck) {
|
||||
$meta['noindex'] = true;
|
||||
}
|
||||
?>
|
||||
<?php
|
||||
// Kopfleiste: im Viewer Titel + Datum, in der Druckfassung stattdessen die
|
||||
// Blatt-Bilanz und ein Drucken-Knopf griffbereit oben (der ausführliche
|
||||
// Hinweis + Button am Seitenende bleibt).
|
||||
$druckSeiten = count($issue['images'] ?? []);
|
||||
$druckBlaetter = (int) ceil($druckSeiten / 4);
|
||||
?>
|
||||
<?php /* Kopfleiste in drei Zonen und zwei Reihen: oben Rückweg links, Titel mittig,
|
||||
Aktion rechts — darunter eine ruhige Statuszeile. Die Badges standen
|
||||
früher neben dem Titel und machten die Mitte unruhig; Zustand („Entwurf",
|
||||
„Druckfassung") und Bilanz sind Information, keine Überschrift. */ ?>
|
||||
<header class="stadionzeitung-issue-head<?= $isDruck ? ' stadionzeitung-issue-head--druck' : '' ?>">
|
||||
<div class="container stadionzeitung-issue-head__inner">
|
||||
<a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>">
|
||||
<?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?>
|
||||
</a>
|
||||
<p class="stadionzeitung-issue-head__title">Gegen <?= e($issue['opponent']) ?></p>
|
||||
<?php if ($isDruck): ?>
|
||||
<p class="stadionzeitung-issue-head__actions">
|
||||
<button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden>
|
||||
<?= icon('printer') ?> Drucken
|
||||
</button>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="stadionzeitung-issue-head__date">
|
||||
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($isDraft || $isDruck): ?>
|
||||
<p class="stadionzeitung-issue-head__state">
|
||||
<?php if ($isDraft): ?>
|
||||
<span class="stadionzeitung-issue-head__badge stadionzeitung-issue-head__badge--entwurf">Entwurf</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($isDruck): ?>
|
||||
<span>Druckfassung · <?= $druckBlaetter ?> Blätter · <?= $druckSeiten ?> Seiten</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
<?php component($isDruck ? 'stadionzeitung-druck' : 'stadionzeitung-viewer', ['issue' => $issue]); ?>
|
||||
41
app/pages/stadionzeitung.php
Normal file
41
app/pages/stadionzeitung.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Stadionzeitung-Archiv: Vorschau-Karten aller Ausgaben, verlinkt auf den
|
||||
* Swipe-Viewer je Ausgabe (stadionzeitung/<slug>, dynamische Route in
|
||||
* public/index.php). Daten: data/stadionzeitung.json (maschinenverwaltet von
|
||||
* bin/stadionzeitung-add.php — nie von Hand editieren).
|
||||
*/
|
||||
// Entwürfe bleiben unsichtbar (kein Archiv-Eintrag, siehe status-Feld) —
|
||||
// erreichbar sind sie nur über ihre direkte URL, zum Gegenlesen.
|
||||
$issues = array_values(array_filter(
|
||||
json_load('stadionzeitung')['issues'] ?? [],
|
||||
'stadionzeitung_is_published'
|
||||
));
|
||||
|
||||
$meta = [
|
||||
'title' => 'Stadionzeitung',
|
||||
'description' => 'Die Stadionzeitung zu jedem Heimspiel des TSV 08 Kulmbach digital zum Durchblättern – alle Ausgaben im Archiv.',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Fußball', 'slug' => 'fussball'],
|
||||
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
|
||||
]),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'Stadionzeitung',
|
||||
'text' => 'Die Stadionzeitung zu jedem Heimspiel – digital zum Durchblättern, auch unterwegs am Handy.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
|
||||
component('stadionzeitung-cards', ['issues' => $issues]);
|
||||
@@ -10,8 +10,8 @@ $slug = 'fussball/damen';
|
||||
$team = json_load('teams')['damen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Frauenmannschaft – Frauenfußball Kulmbach',
|
||||
'description' => 'Frauen- und Mädchenfußball beim TSV 08 Kulmbach: Ob erfahren oder Wiedereinsteigerin – im Katzbachtal findest du dein Team. Komm zum Probetraining.',
|
||||
'title' => 'Frauenmannschaft · Frauenfußball Kulmbach',
|
||||
'description' => 'Frauenfußball in Kulmbach beim TSV 08: Ob erfahren oder Wiedereinsteigerin, im Katzbachtal findest du dein Team. Komm zum Probetraining!',
|
||||
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
|
||||
'schema' => team_schema($team, $slug),
|
||||
];
|
||||
|
||||
@@ -9,8 +9,8 @@ $slug = 'fussball/erste-mannschaft';
|
||||
$team = json_load('teams')['erste-mannschaft'];
|
||||
|
||||
$meta = [
|
||||
'title' => '1. Mannschaft – Kreisklasse Kulmbach',
|
||||
'description' => 'Die erste Herrenmannschaft des TSV 08 Kulmbach spielt auf Kreisklasse-Niveau – ambitioniert und mit echtem Vereinsgeist. Komm vorbei und spiel mit.',
|
||||
'title' => '1. Mannschaft · Kreisklasse Kulmbach',
|
||||
'description' => 'Die 1. Mannschaft des TSV 08 Kulmbach spielt Fußball in der Kreisklasse im Katzbachtal. Ehrgeizig, aber familiär: Komm zum Probetraining!',
|
||||
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
|
||||
'schema' => team_schema($team, $slug),
|
||||
];
|
||||
|
||||
@@ -9,8 +9,8 @@ $slug = 'fussball/zweite-mannschaft';
|
||||
$team = json_load('teams')['zweite-mannschaft'];
|
||||
|
||||
$meta = [
|
||||
'title' => '2. Mannschaft – Herrenfußball Kulmbach',
|
||||
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach spielt in Spielgemeinschaft mit dem TSV Melkendorf – Fußball mit Spaß für alle, die gern kicken.',
|
||||
'title' => '2. Mannschaft · Herrenfußball Kulmbach',
|
||||
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach: Spielgemeinschaft mit dem TSV Melkendorf. Fußball mit Spaß für alle, die gern kicken. Komm vorbei!',
|
||||
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
|
||||
'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [],
|
||||
'schema' => team_schema($team, $slug),
|
||||
|
||||
62
app/pages/termine.php
Normal file
62
app/pages/termine.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zentrale Termine/Events-Seite: kombiniert Fußball-Spieltermine (aus
|
||||
* data/matchcenter.json — next_matches/last_results, NICHT dupliziert) mit
|
||||
* manuell gepflegten Vereins-Events (data/events.json, chat-gepflegt wie
|
||||
* teams.json/partners.json — keine Automatisierung, kein Bin-Skript) und den
|
||||
* Veranstaltungen mit eigener Detailseite (data/veranstaltungen.json, dorthin
|
||||
* verlinkt). Getrennt
|
||||
* in kommend/vergangen, je eine gemeinsame chronologische Liste — kein Filter
|
||||
* nach Kategorie (v1, siehe Interview). Merge-Logik in termine_build_lists()
|
||||
* (app/helpers.php) — dieselbe Grundlage nutzt die Stadionzeitungs-Vorlage
|
||||
* (Phase 3), damit beide nie auseinanderlaufen.
|
||||
*/
|
||||
$termine = termine_build_lists();
|
||||
|
||||
$meta = [
|
||||
'title' => 'Termine',
|
||||
'description' => 'Alle kommenden Termine des TSV 08 Kulmbach: Fußball-Spiele unserer Mannschaften und Vereins-Events in Kulmbach auf einen Blick. Schau vorbei im Katzbachtal!',
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Termine', 'slug' => 'termine'],
|
||||
]),
|
||||
sportsevent_nodes($termine['upcoming_matches']),
|
||||
array_values(array_filter(array_map('event_schema', $termine['events']))),
|
||||
// Veranstaltungen tragen zusätzlich ihre eigene URL, damit dieser Knoten
|
||||
// und der auf der Detailseite dasselbe Event beschreiben.
|
||||
array_values(array_filter(array_map(
|
||||
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
|
||||
$termine['veranstaltungen']
|
||||
)))
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'Termine',
|
||||
'text' => 'Alle kommenden Spiele und Vereins-Events auf einen Blick.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h2>Kommende Termine</h2>
|
||||
<?php component('termine-list', ['items' => $termine['upcoming'], 'empty_text' => 'Aktuell sind keine Termine geplant.']); ?>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--sunken">
|
||||
<div class="container">
|
||||
<h2>Vergangene Termine</h2>
|
||||
<?php component('termine-list', ['items' => $termine['past'], 'empty_text' => 'Noch keine vergangenen Termine erfasst.']); ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
|
||||
$meta = [
|
||||
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
|
||||
'description' => $d['hero']['text'],
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'scripts' => ['group-tabs.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
|
||||
@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Erwachsenenturnen in Kulmbach',
|
||||
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren – Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
|
||||
@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['step-aerobic-fitness-pilates'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Step Aerobic, Fitness & Pilates in Kulmbach',
|
||||
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene – Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
|
||||
@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['trampolin'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Trampolinturnen in Kulmbach',
|
||||
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin – Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin: Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
|
||||
@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['wettkampfturnen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Wettkampfturnen in Kulmbach',
|
||||
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen – Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen: Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Turn-Übersichtsseite (reiner Hub). Inhalt: data/turnen.json.
|
||||
* Struktur: Hero → Disziplin-Karten. Keine weiteren Sektionen — die Detailinhalte
|
||||
* (Beiträge, FAQ, Trainingszeiten) leben auf den Disziplin-Unterseiten.
|
||||
* Turn-Übersichtsseite (Hub + Abteilungskontakt). Inhalt: data/turnen.json.
|
||||
* Struktur: Hero → Disziplin-Karten → Anfrageformular. Die Detailinhalte
|
||||
* (Beiträge, Trainingszeiten) leben auf den Disziplin-Unterseiten.
|
||||
*
|
||||
* Das Formular ist die allgemeine contact-form-Komponente mit
|
||||
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
|
||||
* Turnabteilung (club.email_turnen) statt info@. Die alte Seite hatte dafür ein
|
||||
* eigenes, zweites Formular — hier ist es dieselbe Komponente und dieselbe
|
||||
* Action, damit der Spam-Schutz nicht doppelt gepflegt werden muss.
|
||||
*/
|
||||
$page = json_load('turnen')['overview'];
|
||||
|
||||
// Heros nutzen das Vereins-Default-Bild; nur die Übersichts-Kacheln zeigen die
|
||||
// (vorläufigen) themenbezogenen Unsplash-Platzhalter aus public/assets/img/pages/.
|
||||
// Heros aller Turn-Seiten zeigen VORÜBERGEHEND das Drohnen-Luftbild des
|
||||
// Vereinsgeländes (img/pages/vereinsgelaende, Quelle: data/turnen.json) statt des
|
||||
// Fußballplatz-Bildes. Ehrlicher wäre ein Bild aus der Turnhalle — liegt eines
|
||||
// vor, tauschen die sechs Hero-Blöcke in data/turnen.json und das og_image der
|
||||
// sechs Seiten-Dateien. Die Übersichts-Kacheln zeigen weiter die (vorläufigen)
|
||||
// themenbezogenen Platzhalter aus public/assets/img/pages/.
|
||||
$cards = [
|
||||
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1600], 'alt' => 'Erwachsenenturnen beim TSV 08 Kulmbach']],
|
||||
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1600], 'alt' => 'Eltern-Kind- und Kinderturnen beim TSV 08 Kulmbach']],
|
||||
@@ -22,7 +32,7 @@ $cards = [
|
||||
$meta = [
|
||||
'title' => 'Turnen in Kulmbach',
|
||||
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
@@ -36,3 +46,8 @@ component('team-cards', [
|
||||
'title_hidden' => true,
|
||||
'anchor' => 'gruppen',
|
||||
]);
|
||||
component('contact-form', [
|
||||
'intro' => $page['contact'],
|
||||
'return' => 'turnen',
|
||||
'department' => 'turnen',
|
||||
]);
|
||||
|
||||
157
app/pages/veranstaltung.php
Normal file
157
app/pages/veranstaltung.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Einzelne Veranstaltung: dynamische Route veranstaltungen/<slug>, siehe
|
||||
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
|
||||
* Ermittelt den Slug selbst wieder aus der URL, Daten aus
|
||||
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
|
||||
* schreibt bin/veranstaltung-bilder.php.
|
||||
*
|
||||
* Geladen wird hier bewusst UNGEFILTERT (nicht über veranstaltungen_load()):
|
||||
* ein Entwurf muss unter seiner direkten URL lesbar bleiben, damit er
|
||||
* gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
|
||||
* trägt noindex.
|
||||
*
|
||||
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
|
||||
* ein Archiv-Eintrag hat kein eigenes Titelbild, und das einzige vorhandene
|
||||
* Bild ist ein Plakat im Hochformat, das als cover-beschnittener
|
||||
* Hero-Hintergrund zerstört würde. Stattdessen ein schmaler Kopf mit Rückweg.
|
||||
* Die Übersichtsseite /veranstaltungen behält den regulären Unterseiten-Hero.
|
||||
*/
|
||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
|
||||
|
||||
$v = null;
|
||||
foreach (json_load('veranstaltungen')['veranstaltungen'] ?? [] as $entry) {
|
||||
if (($entry['slug'] ?? '') === $veranstaltungSlug) {
|
||||
$v = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($v === null) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
|
||||
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
|
||||
http_response_code(404);
|
||||
require APP_PATH . '/pages/404.php';
|
||||
return;
|
||||
}
|
||||
|
||||
$isDraft = !veranstaltung_is_published($v);
|
||||
// Der Lebenszyklus läuft über das Datum, nicht über ein Feld von Hand:
|
||||
// vor dem Fest wirbt die Seite, danach erzählt sie. Maßgeblich ist der LETZTE
|
||||
// Tag (termin_is_upcoming()), damit ein dreitägiges Fest am Samstag nicht schon
|
||||
// zum Rückblick wird.
|
||||
$isVorbei = !termin_is_upcoming($v, date('Y-m-d'));
|
||||
|
||||
$plakate = $v['plakate'] ?? [];
|
||||
$galerie = $v['galerie'] ?? [];
|
||||
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
|
||||
|
||||
$meta = [
|
||||
'title' => $v['title'],
|
||||
'description' => (string) ($v['text'] ?? $v['title']),
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
|
||||
['name' => $v['title'], 'slug' => 'veranstaltungen/' . $v['slug']],
|
||||
]),
|
||||
array_values(array_filter([event_schema($v, 'veranstaltungen/' . $v['slug'])]))
|
||||
),
|
||||
];
|
||||
|
||||
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
|
||||
if ($plakate !== [] || $galerie !== []) {
|
||||
$meta['scripts'] = ['lightbox.js'];
|
||||
}
|
||||
|
||||
$ogBild = $plakate[0] ?? $galerie[0] ?? null;
|
||||
if ($ogBild !== null && !empty($ogBild['widths'])) {
|
||||
$meta['og_image'] = $ogBild['base'] . '-' . max($ogBild['widths']) . '.jpg';
|
||||
}
|
||||
|
||||
if ($isDraft) {
|
||||
$meta['noindex'] = true;
|
||||
}
|
||||
|
||||
// Einleitung: vor dem Fest die Ankündigung, danach der Rückblick. Fehlt der
|
||||
// jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die Seite darf
|
||||
// nie leer sein, sie startet ja mit nichts als dem Plakat.
|
||||
// Fällt der jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die
|
||||
// Seite darf nie leer sein, sie startet ja mit nichts als dem Plakat.
|
||||
$introText = $isVorbei ? ($v['rueckblick'] ?? null) : ($v['ankuendigung'] ?? null);
|
||||
$introSection = !empty($introText) ? [
|
||||
'id' => 'ueberblick',
|
||||
'title' => $isVorbei ? 'Unser Rückblick' : 'Das erwartet dich',
|
||||
// Ohne Bild läuft die Sektion einspaltig auf Lesebreite (split--plain).
|
||||
'text' => $introText,
|
||||
] : null;
|
||||
?>
|
||||
<?php component('detail-head', [
|
||||
'back' => ['href' => url('veranstaltungen'), 'label' => 'Alle Veranstaltungen'],
|
||||
'title' => $v['title'],
|
||||
'badge' => $isDraft ? 'Entwurf' : '',
|
||||
'date_iso' => (string) $v['date_start'],
|
||||
'date_text' => $dateText,
|
||||
'location' => (string) ($v['location'] ?? ''),
|
||||
]); ?>
|
||||
<?php /* Einleitungstext nur, wenn wirklich einer gepflegt ist — die Regel für die
|
||||
ganze Seite: gezeigt wird, was in der JSON steht, und sonst nichts. */ ?>
|
||||
<?php if ($introSection !== null): ?>
|
||||
<?php component('section', ['section' => $introSection]); ?>
|
||||
<?php endif; ?>
|
||||
<?php /* Plakate und Galerie tauschen den Platz: vor dem Fest wirbt das Plakat,
|
||||
danach tragen die Fotos die Seite und das Plakat bleibt als Andenken. */ ?>
|
||||
<?php if ($isVorbei): ?>
|
||||
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
|
||||
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => 'Plakate von damals', 'compact' => true]); ?>
|
||||
<?php else: ?>
|
||||
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => count($plakate) > 1 ? 'Plakat und Programm' : 'Plakat']); ?>
|
||||
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
|
||||
<?php endif; ?>
|
||||
<?php /* Zeitraum, Beginn, Adresse und Karte in einem Block — aber nur, solange das
|
||||
Fest noch aussteht. Hinterher wäre es reine Doppelung: Datum und Ort
|
||||
stehen im Kopf, und eine Anfahrtskarte zu einem Fest von letztem Jahr
|
||||
hilft niemandem mehr. Alles Weitere steht auf den Plakaten. */ ?>
|
||||
<?php if (!$isVorbei): ?>
|
||||
<?php component('veranstaltung-info', [
|
||||
'date_text' => $dateText,
|
||||
'date_iso' => (string) $v['date_start'],
|
||||
'time' => (string) ($v['time'] ?? ''),
|
||||
'location' => (string) ($v['location'] ?? ''),
|
||||
'karte' => (string) ($v['karte'] ?? ''),
|
||||
'anfahrt' => $v['anfahrt'] ?? [],
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
// phase steuert, welche Abschnitte wann stehen: „Anfahrt und Parken" hilft nur
|
||||
// vor dem Fest. Ohne Angabe steht ein Abschnitt immer — der harmlosere Default:
|
||||
// geschriebener Text verschwindet nie überraschend.
|
||||
foreach ($v['abschnitte'] ?? [] as $abschnitt):
|
||||
$phase = $abschnitt['phase'] ?? 'immer';
|
||||
if ($phase !== 'immer' && $phase !== ($isVorbei ? 'rueckblick' : 'ankuendigung')) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<?php component('section', ['section' => $abschnitt]); ?>
|
||||
<?php endforeach; ?>
|
||||
<?php /* Das Programm als Liste ist die Ausnahme, nicht die Regel: normalerweise
|
||||
trägt es das Programmplakat. Nur wenn `programm` gepflegt ist, kommt es
|
||||
zusätzlich als lesbare Liste dazu (Screenreader, Suchmaschinen). */ ?>
|
||||
<?php component('veranstaltung-programm', [
|
||||
'programm' => $v['programm'] ?? [],
|
||||
'title' => $isVorbei ? 'So lief unser Programm' : 'Programm',
|
||||
'compact' => $isVorbei,
|
||||
]); ?>
|
||||
<?php /* Helfer-Aufruf nur vor dem Fest: hinterher sind die Schichten gelaufen. */ ?>
|
||||
<?php if (!$isVorbei && !empty($v['helfer'])): ?>
|
||||
<?php component('cta-band', ['band' => $v['helfer'], 'anchor' => 'helfen']); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($v['partner'])): ?>
|
||||
<?php component('partner-grid', ['intro' => $v['partner'], 'anchor' => 'fest-partner']); ?>
|
||||
<?php endif; ?>
|
||||
77
app/pages/veranstaltungen.php
Normal file
77
app/pages/veranstaltungen.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Übersicht aller Veranstaltungen mit eigener Detailseite (Sportfest,
|
||||
* Jubiläum, …) — Quelle data/veranstaltungen.json, geladen über
|
||||
* veranstaltungen_load() (filtert Entwürfe, sortiert neueste zuerst).
|
||||
*
|
||||
* Zwei Abschnitte statt einer gemischten Liste: oben was ansteht (der nächste
|
||||
* Termin als große Karte mit Plakat), unten das Archiv. Auch in der Sommerpause
|
||||
* ohne anstehendes Fest ist die Seite damit gefüllt.
|
||||
*
|
||||
* Abgrenzung zu /termine: dort steht der vollständige Kalender (Spiele +
|
||||
* Vereins-Termine + Veranstaltungen), hier nur das, was eine eigene Seite hat.
|
||||
*/
|
||||
$today = date('Y-m-d');
|
||||
$alle = veranstaltungen_load();
|
||||
|
||||
$kommend = array_values(array_filter($alle, static fn (array $v): bool => termin_is_upcoming($v, $today)));
|
||||
// veranstaltungen_load() sortiert absteigend (fürs Archiv richtig) — was
|
||||
// ansteht, gehört aufsteigend sortiert: das Nächste zuerst.
|
||||
usort($kommend, static fn (array $a, array $b): int => (string) $a['date_start'] <=> (string) $b['date_start']);
|
||||
|
||||
$vergangen = array_values(array_filter($alle, static fn (array $v): bool => !termin_is_upcoming($v, $today)));
|
||||
|
||||
$meta = [
|
||||
'title' => 'Veranstaltungen',
|
||||
'description' => 'Unsere Feste und Veranstaltungen: Sportfest, Frühschoppen und alles, was wir im Katzbachtal auf die Beine stellen.',
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
|
||||
]),
|
||||
// Nur die anstehenden als Event-Knoten: ein vergangenes Fest ist für
|
||||
// Suchmaschinen kein Termin mehr, den man sich vormerken kann. Mit Slug,
|
||||
// damit es derselbe Knoten ist wie auf /termine und der Detailseite.
|
||||
array_values(array_filter(array_map(
|
||||
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
|
||||
$kommend
|
||||
)))
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'Veranstaltungen',
|
||||
'text' => 'Sportfest, Frühschoppen, Feiern: hier findest du alles zu unseren Festen, vorher das Programm und hinterher die Bilder.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/vereinsgelaende',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h2>Was ansteht</h2>
|
||||
<?php component('veranstaltungen-cards', [
|
||||
'items' => $kommend,
|
||||
'cta' => 'Zum Programm',
|
||||
'empty_text' => 'Gerade ist kein Fest geplant. Sobald der nächste Termin steht, findest du ihn hier.',
|
||||
]); ?>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--sunken">
|
||||
<div class="container">
|
||||
<h2>Rückblick</h2>
|
||||
<?php component('veranstaltungen-cards', [
|
||||
'items' => $vergangen,
|
||||
'cta' => 'Bilder ansehen',
|
||||
'empty_text' => 'Noch kein Fest im Rückblick. Nach unserem nächsten Sportfest findest du hier die Bilder.',
|
||||
]); ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -26,7 +26,7 @@ $cards = [
|
||||
|
||||
$meta = [
|
||||
'title' => 'Der Verein',
|
||||
'description' => 'Der TSV 1908 Kulmbach e. V. – seit 1908 dein Sportverein im Katzbachtal: über 650 Mitglieder, Fußball und Turnen, zuhause auf der Hans-Rausch-Sportanlage.',
|
||||
'description' => 'Der TSV 1908 Kulmbach e. V.: seit 1908 dein Sportverein in Kulmbach mit über 650 Mitgliedern. Fußball und Turnen auf der Hans-Rausch-Sportanlage im Katzbachtal.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
@@ -11,11 +11,13 @@ declare(strict_types=1);
|
||||
*/
|
||||
return [
|
||||
'' => ['file' => 'home.php', 'sitemap' => true],
|
||||
'news' => ['file' => 'news.php', 'sitemap' => true],
|
||||
'fussball' => ['file' => 'fussball.php', 'sitemap' => true],
|
||||
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
|
||||
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
|
||||
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
|
||||
'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true],
|
||||
'stadionzeitung' => ['file' => 'stadionzeitung.php', 'sitemap' => true],
|
||||
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
|
||||
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
|
||||
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
|
||||
@@ -28,6 +30,8 @@ return [
|
||||
'historie' => ['file' => 'historie.php', 'sitemap' => true],
|
||||
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
|
||||
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
|
||||
'termine' => ['file' => 'termine.php', 'sitemap' => true],
|
||||
'veranstaltungen' => ['file' => 'veranstaltungen.php', 'sitemap' => true],
|
||||
'kontakt' => ['file' => 'kontakt.php', 'sitemap' => true],
|
||||
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
|
||||
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],
|
||||
|
||||
181
bin/anzeige-add.php
Normal file
181
bin/anzeige-add.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sponsoren-Anzeige in den Bestand aufnehmen oder ihr Motiv erneuern (nur CLI).
|
||||
*
|
||||
* php bin/anzeige-add.php <bild-oder-ordner> [-v]
|
||||
*
|
||||
* Beispiele:
|
||||
* php bin/anzeige-add.php ~/Desktop/anzeigen/ alle Bilder im Ordner
|
||||
* php bin/anzeige-add.php ~/Desktop/eldorado.jpg nur diese eine
|
||||
*
|
||||
* DER DATEINAME IST DER SLUG: `eldorado.jpg` wird zu `img/anzeigen/eldorado-*.jpg`
|
||||
* und findet damit den bestehenden Eintrag wieder. Ein neues Motiv für einen
|
||||
* bekannten Sponsor heißt also einfach wieder `eldorado.jpg`.
|
||||
*
|
||||
* Anzeigen sind ein DAUERHAFTER Bestand (data/anzeigen.json), kein Zubehör einer
|
||||
* einzelnen Ausgabe: Sponsorenmotive ändern sich selten, die Stadionzeitung
|
||||
* erscheint oft. bin/stadionzeitung-add.php holt sie sich von hier, pro Heft ist
|
||||
* nichts abzuliefern. Vorher lag jede Anzeige als Kopie unter jeder Ausgabe und
|
||||
* ihr Alt-Text musste jedes Mal neu geschrieben werden.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich `base` und `widths`. `sponsor`, `alt`,
|
||||
* `aktiv` und die REIHENFOLGE der Liste (= Reihenfolge im Heft) sind
|
||||
* chat-gepflegt und überleben jeden Lauf unangetastet — dasselbe Prinzip wie bei
|
||||
* bin/veranstaltung-bilder.php. Bei einem neuen Sponsor wird `sponsor` aus dem
|
||||
* Dateinamen vorbelegt und muss im Chat richtiggestellt werden; das Skript sagt,
|
||||
* bei welchen.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => $a !== '-v'));
|
||||
$verbose = in_array('-v', $argv, true);
|
||||
|
||||
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
|
||||
log_write('stadionzeitung', $level, $msg);
|
||||
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
|
||||
echo '[' . date('c') . "] {$level} {$msg}\n";
|
||||
}
|
||||
};
|
||||
|
||||
$fail = static function (string $msg) use ($log): never {
|
||||
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
|
||||
exit(1);
|
||||
};
|
||||
|
||||
if ($args === []) {
|
||||
fwrite(STDERR, "Nutzung: php bin/anzeige-add.php <bild-oder-ordner> [-v]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// --- Quelldateien einsammeln ---
|
||||
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
|
||||
$quelle = $args[0];
|
||||
if (is_dir($quelle)) {
|
||||
$sources = glob(rtrim($quelle, '/') . "/*.{{$exts}}", GLOB_BRACE) ?: [];
|
||||
natsort($sources);
|
||||
$sources = array_values($sources);
|
||||
} elseif (is_file($quelle)) {
|
||||
$sources = [$quelle];
|
||||
} else {
|
||||
$fail("Nicht gefunden: {$quelle}");
|
||||
}
|
||||
|
||||
if ($sources === []) {
|
||||
$fail("Keine Bilder in {$quelle} gefunden.");
|
||||
}
|
||||
|
||||
// Selbst-Import verhindern: zeigt jemand auf den Zielordner, wären die Quellen
|
||||
// die erzeugten Varianten (`eldorado-1440.jpg`), und aus jeder Breite entstünde
|
||||
// ein eigener Sponsor („eldorado 1440"). Einmal reingelaufen, deshalb der Riegel.
|
||||
$zielReal = realpath(PUBLIC_PATH . '/assets/img/anzeigen');
|
||||
foreach ($sources as $src) {
|
||||
if ($zielReal !== false && realpath(dirname($src)) === $zielReal) {
|
||||
$fail('Quelle liegt im Zielordner public/assets/img/anzeigen/ — das sind die erzeugten Varianten, nicht die Originale.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lock gegen parallele Läufe ---
|
||||
if (!is_dir(STORAGE_PATH . '/cache')) {
|
||||
mkdir(STORAGE_PATH . '/cache', 0755, true);
|
||||
}
|
||||
$lock = fopen(STORAGE_PATH . '/cache/anzeigen.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$dataFile = DATA_PATH . '/anzeigen.json';
|
||||
$data = is_file($dataFile)
|
||||
? (json_decode((string) file_get_contents($dataFile), true) ?: ['anzeigen' => []])
|
||||
: ['anzeigen' => []];
|
||||
|
||||
$destDir = PUBLIC_PATH . '/assets/img/anzeigen';
|
||||
if (!is_dir($destDir)) {
|
||||
mkdir($destDir, 0755, true);
|
||||
}
|
||||
|
||||
$widths = [480, 960, 1440];
|
||||
|
||||
/** Dateiname zu Slug: Kleinschreibung, Umlaute weg, alles andere zu Bindestrichen. */
|
||||
$slugify = static function (string $text): string {
|
||||
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
|
||||
$text = strtolower($text);
|
||||
return trim(preg_replace('/[^a-z0-9]+/', '-', $text) ?? '', '-');
|
||||
};
|
||||
|
||||
$neu = [];
|
||||
$erneuert = [];
|
||||
|
||||
foreach ($sources as $src) {
|
||||
$slug = $slugify(pathinfo($src, PATHINFO_FILENAME));
|
||||
if ($slug === '') {
|
||||
$log('Dateiname ergibt keinen Slug, übersprungen: ' . basename($src), 'WARN');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
|
||||
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
|
||||
// nicht mehr passen.
|
||||
foreach (glob("{$destDir}/{$slug}-*.jpg") ?: [] as $alt) {
|
||||
unlink($alt);
|
||||
}
|
||||
|
||||
try {
|
||||
// Qualität 82 wie bei den Plakaten: Anzeigen tragen Text, und Ringing um
|
||||
// Buchstaben sieht man.
|
||||
$variants = resize_image_variants($src, "{$destDir}/{$slug}", $widths, 82);
|
||||
} catch (RuntimeException $e) {
|
||||
$fail('Anzeige nicht verarbeitbar (' . basename($src) . '): ' . $e->getMessage());
|
||||
}
|
||||
$generated = array_values(array_unique(array_column($variants, 'width')));
|
||||
sort($generated);
|
||||
|
||||
// Bestehenden Eintrag suchen und NUR base/widths ersetzen.
|
||||
$index = null;
|
||||
foreach ($data['anzeigen'] as $i => $eintrag) {
|
||||
if (($eintrag['slug'] ?? '') === $slug) {
|
||||
$index = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($index === null) {
|
||||
// Neuer Sponsor: Platzhalter-Name aus dem Slug, im Chat zu ersetzen.
|
||||
$data['anzeigen'][] = [
|
||||
'slug' => $slug,
|
||||
'sponsor' => str_replace('-', ' ', $slug),
|
||||
'aktiv' => true,
|
||||
'base' => "img/anzeigen/{$slug}",
|
||||
'widths' => $generated,
|
||||
];
|
||||
$neu[] = $slug;
|
||||
} else {
|
||||
$data['anzeigen'][$index]['base'] = "img/anzeigen/{$slug}";
|
||||
$data['anzeigen'][$index]['widths'] = $generated;
|
||||
$erneuert[] = $slug;
|
||||
}
|
||||
|
||||
$log("Anzeige {$slug}: " . implode('/', $generated) . 'px');
|
||||
}
|
||||
|
||||
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$tmp = $dataFile . '.tmp';
|
||||
file_put_contents($tmp, $payload . "\n");
|
||||
if (!rename($tmp, $dataFile)) {
|
||||
$fail('Konnte data/anzeigen.json nicht schreiben.');
|
||||
}
|
||||
|
||||
$log(sprintf('Anzeigen-Bestand aktualisiert: %d neu, %d erneuert.', count($neu), count($erneuert)));
|
||||
|
||||
echo 'Anzeigen-Bestand: ' . count($data['anzeigen']) . ' Motive, ' . count($neu) . ' neu, ' . count($erneuert) . " erneuert.\n";
|
||||
if ($neu !== []) {
|
||||
echo "Sponsorenname noch Platzhalter bei: " . implode(', ', $neu) . "\n";
|
||||
echo "In data/anzeigen.json richtigstellen (daraus entsteht der Alt-Text „Anzeige: …\").\n";
|
||||
}
|
||||
491
bin/chronik-add.php
Normal file
491
bin/chronik-add.php
Normal file
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fundstücke für die Vereinschronik 2028 einsammeln und ablegen.
|
||||
*
|
||||
* Holt zu einer Web-Adresse Überschrift, Datum, Teaser, Bildnachweis und das Aufmacherbild,
|
||||
* legt beides im richtigen Jahrzehnt-Ordner unter Vereinshistorie/ ab, schreibt einen
|
||||
* Quellen-Beleg daneben und eine Zeile ins Register (docs/chronik/register.csv).
|
||||
*
|
||||
* Warum überhaupt ein Skript und kein „Rechtsklick, Bild speichern": das Bild allein ist
|
||||
* in fünf Jahren wertlos. Woher es stammt, wann es entstand und wem es gehört, steht nirgends
|
||||
* im JPEG — und genau das braucht man, wenn die Chronik in den Druck geht. Das Skript hält
|
||||
* den Zusammenhang fest, in dem Moment, in dem er noch bekannt ist.
|
||||
*
|
||||
* Es wird NICHTS massenhaft abgegriffen: pro Aufruf eine Seite, mit Pause zwischen den
|
||||
* Abrufen. Die Presseportale haben keine Übersichtsseite mehr, über die man den Verein
|
||||
* vollständig abfragen könnte — die Fundstellen kommen aus gezielter Suche, nicht aus
|
||||
* einem Crawler.
|
||||
*
|
||||
* NUR CLI, nie im Request-Pfad (wie alle bin/-Skripte).
|
||||
*
|
||||
* Aufruf:
|
||||
* php bin/chronik-add.php <URL> [--datum=1972-07-15] [--titel="…"] [--art=Zeitungsartikel]
|
||||
* [--notiz="…"] [--urheber="…"] [--ohne-bild] [--nur-register]
|
||||
* php bin/chronik-add.php --liste=urls.txt Zeilen: URL | Datum | Titel (| optional)
|
||||
* php bin/chronik-add.php --datei=<pfad> --datum=1983-06-11 --titel="Festzug 75 Jahre"
|
||||
* [--quelle="Album Familie Rausch"] [--urheber="…"] [--art=Foto]
|
||||
* [--rechte-klaeren]
|
||||
* php bin/chronik-add.php --rechte nur 00_RECHTE-ZU-KLAEREN.txt neu schreiben
|
||||
* php bin/chronik-add.php --bericht Registerübersicht auf stdout
|
||||
*
|
||||
* Optionen --datum/--titel gewinnen immer gegen das, was die Seite selbst angibt.
|
||||
* --datei benennt die Datei um und verschiebt sie in den passenden Jahrzehnt-Ordner.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
require __DIR__ . '/chronik-lib.php';
|
||||
|
||||
$opts = [];
|
||||
$positional = [];
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if (preg_match('/^--([a-z-]+)(?:=(.*))?$/s', $arg, $m)) {
|
||||
$opts[$m[1]] = $m[2] ?? true;
|
||||
} else {
|
||||
$positional[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
$root = chronik_root();
|
||||
if (!is_dir($root)) {
|
||||
fwrite(STDERR, "Vereinshistorie/ fehlt — erst 'php bin/chronik-init.php' laufen lassen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// --- Nebenmodi -------------------------------------------------------------
|
||||
|
||||
if (isset($opts['rechte'])) {
|
||||
$n = chronik_write_rights_todo();
|
||||
echo "00_RECHTE-ZU-KLAEREN.txt geschrieben — {$n} offene Posten.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (isset($opts['bericht'])) {
|
||||
chronik_report();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// --- HTTP ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Browser-typische Header. Ohne sie antworten mehrere der Quellen mit 403 —
|
||||
* dieselbe Erfahrung wie beim BFV-Widget (dort HTTP 418), siehe matchcenter-sync.php.
|
||||
*/
|
||||
function chronik_fetch(string $url, ?string $referer = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
|
||||
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
CURLOPT_HTTPHEADER => array_filter([
|
||||
'Accept: text/html,application/xhtml+xml,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language: de-DE,de;q=0.9',
|
||||
$referer !== null ? 'Referer: ' . $referer : null,
|
||||
]),
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
$final = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
|
||||
|
||||
return [
|
||||
'ok' => $body !== false && $status === 200,
|
||||
'status' => $status,
|
||||
'body' => $body === false ? '' : $body,
|
||||
'type' => $type,
|
||||
'url' => $final !== '' ? $final : $url,
|
||||
];
|
||||
}
|
||||
|
||||
/** Ersten Treffer eines Musters liefern, HTML-Entities aufgelöst. */
|
||||
function chronik_match(string $html, string $pattern): ?string
|
||||
{
|
||||
if (!preg_match($pattern, $html, $m)) {
|
||||
return null;
|
||||
}
|
||||
$val = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return $val !== '' ? $val : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fließtext einer Seite herausschälen.
|
||||
*
|
||||
* Grob und absichtlich: Skripte und Styles raus, Tags raus, alles behalten, was länger als
|
||||
* ein Navigationspunkt ist. Für saubere Extraktion bräuchte es pro Portal eine eigene Regel —
|
||||
* das lohnt hier nicht, weil der Text als Beleg dient und nicht als Layout.
|
||||
*/
|
||||
function chronik_body_text(string $html): string
|
||||
{
|
||||
$html = (string) preg_replace('#<(script|style|nav|header|footer|form)\b.*?</\1>#is', ' ', $html);
|
||||
$html = (string) preg_replace('#<(br|/p|/div|/h[1-6]|/li|/td|/tr)[^>]*>#i', "\n", $html);
|
||||
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
$keep = [];
|
||||
$seen = [];
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim((string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $line));
|
||||
// 45 Zeichen trennt Fließtext zuverlässig von Menüpunkten und Buttons.
|
||||
if (mb_strlen($line) < 45 || isset($seen[$line])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$line] = true;
|
||||
$keep[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n\n", $keep);
|
||||
}
|
||||
|
||||
/** Seite auswerten: was an Metadaten drinsteht, ohne Layout-Annahmen. */
|
||||
function chronik_parse(string $html): array
|
||||
{
|
||||
$strip = static fn(string $s): string => trim((string) preg_replace('/\s+/', ' ', strip_tags($s)));
|
||||
|
||||
$titel = chronik_match($html, '/<meta property="og:title" content="([^"]+)"/i')
|
||||
?? chronik_match($html, '/<h1[^>]*>(.*?)<\/h1>/is')
|
||||
?? chronik_match($html, '/<title[^>]*>(.*?)<\/title>/is');
|
||||
if ($titel !== null) {
|
||||
$titel = $strip($titel);
|
||||
// Seitentitel tragen oft " | Portalname" mit — für den Dateinamen stört das nur.
|
||||
$titel = (string) preg_replace('/\s*[|–-]\s*(inFranken\.de|BFV|FUSSBALL\.DE|Deutsche Turnliga)\s*$/iu', '', $titel);
|
||||
}
|
||||
|
||||
$datum = chronik_match($html, '/<meta property="article:published_time" content="(\d{4}-\d{2}-\d{2})/i')
|
||||
?? chronik_match($html, '/"datePublished"\s*:\s*"(\d{4}-\d{2}-\d{2})/i')
|
||||
?? chronik_match($html, '/datetime="(\d{4}-\d{2}-\d{2})/i');
|
||||
|
||||
$teaser = chronik_match($html, '/<meta property="og:description" content="([^"]+)"/i')
|
||||
?? chronik_match($html, '/<meta name="description" content="([^"]+)"/i');
|
||||
|
||||
$bild = chronik_match($html, '/<meta property="og:image" content="([^"]+)"/i');
|
||||
|
||||
// Bildnachweis steht bei Presseseiten fast immer in der Bildunterschrift
|
||||
// („Foto: Max Mustermann") — genau die Angabe, die später fehlt.
|
||||
$urheber = null;
|
||||
if (preg_match_all('/<figcaption[^>]*>(.*?)<\/figcaption>/is', $html, $caps)) {
|
||||
foreach ($caps[1] as $cap) {
|
||||
$cap = $strip(html_entity_decode($cap, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if (preg_match('/\b(Foto|Bild|Quelle|©)\s*:?\s*(.{2,80})$/ui', $cap, $m)) {
|
||||
$urheber = trim($m[2], " .;");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$urheber ??= chronik_match($html, '/<meta name="author" content="([^"]+)"/i');
|
||||
|
||||
return compact('titel', 'datum', 'teaser', 'bild', 'urheber');
|
||||
}
|
||||
|
||||
// --- Ein Fundstück verarbeiten ---------------------------------------------
|
||||
|
||||
function chronik_add(string $url, array $opts, array $override = []): bool
|
||||
{
|
||||
$root = chronik_root();
|
||||
$heute = date('Y-m-d');
|
||||
|
||||
if (chronik_register_has_url($url) && !isset($opts['erneut'])) {
|
||||
echo "· schon im Register: {$url}\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bei Wayback-Adressen zählt für Rechte und Quellenkürzel die ARCHIVIERTE Seite,
|
||||
// nicht das Archiv — abgerufen wird trotzdem der Schnappschuss.
|
||||
$quellUrl = chronik_origin_url($url);
|
||||
$host = (string) (parse_url($quellUrl, PHP_URL_HOST) ?: '');
|
||||
if ($host === '') {
|
||||
fwrite(STDERR, "✗ Keine gültige Adresse: {$url}\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
$rights = chronik_rights($host);
|
||||
|
||||
$meta = ['titel' => null, 'datum' => null, 'teaser' => null, 'bild' => null, 'urheber' => null];
|
||||
$volltext = '';
|
||||
if (!isset($opts['nur-register'])) {
|
||||
$res = chronik_fetch($url);
|
||||
if (!$res['ok']) {
|
||||
fwrite(STDERR, "✗ HTTP {$res['status']}: {$url}\n");
|
||||
log_write('chronik', 'WARN', "Abruf fehlgeschlagen (HTTP {$res['status']}): {$url}");
|
||||
return false;
|
||||
}
|
||||
$meta = chronik_parse($res['body']);
|
||||
// Die Rechtelage entscheidet, wie viel Text bleibt: eigenes Vereinsmaterial sichern
|
||||
// wir vollständig (sonst ist es weg — die alten Vereinsseiten sind offline), fremde
|
||||
// Presseartikel nur als Nachweis mit Überschrift und Anriss.
|
||||
if (!$rights['klaeren']) {
|
||||
$volltext = chronik_body_text($res['body']);
|
||||
}
|
||||
}
|
||||
|
||||
$titel = (string) ($override['titel'] ?? $opts['titel'] ?? $meta['titel'] ?? 'Ohne Titel');
|
||||
$datum = $override['datum'] ?? $opts['datum'] ?? $meta['datum'] ?? null;
|
||||
$urheber = (string) ($opts['urheber'] ?? $meta['urheber'] ?? '');
|
||||
$art = (string) ($opts['art'] ?? 'Zeitungsartikel');
|
||||
$notiz = (string) ($opts['notiz'] ?? '');
|
||||
if ($quellUrl !== $url) {
|
||||
$notiz = trim($notiz . ' Archivfassung (Internet Archive), Original nicht mehr online: ' . $quellUrl);
|
||||
}
|
||||
|
||||
$tag = chronik_source_tag($host);
|
||||
$bucket = chronik_bucket(is_string($datum) ? $datum : null);
|
||||
$dnorm = chronik_date_norm(is_string($datum) ? $datum : null);
|
||||
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
|
||||
|
||||
$dir = $root . '/' . $bucket;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
// Namenskollision auflösen. Passiert wirklich: zwei Seiten „Bundesliga" und
|
||||
// „Bundesliga-1" mit gleicher Überschrift und gleichem Jahr ergeben denselben Namen —
|
||||
// ohne Suffix überschreibt der zweite Fund den ersten stillschweigend.
|
||||
// Anker ist die .quelle.txt, die zu jedem Fundstück entsteht.
|
||||
if (is_file($dir . '/' . $base . '.quelle.txt')) {
|
||||
$n = 2;
|
||||
while (is_file($dir . '/' . $base . '-' . $n . '.quelle.txt')) {
|
||||
$n++;
|
||||
}
|
||||
$base .= '-' . $n;
|
||||
}
|
||||
|
||||
// --- Aufmacherbild ---
|
||||
$bildDatei = '';
|
||||
if (!isset($opts['ohne-bild']) && !isset($opts['nur-register']) && is_string($meta['bild'])) {
|
||||
$img = chronik_fetch($meta['bild'], $url);
|
||||
$ext = match (true) {
|
||||
str_contains($img['type'], 'png') => 'png',
|
||||
str_contains($img['type'], 'webp') => 'webp',
|
||||
str_contains($img['type'], 'gif') => 'gif',
|
||||
default => 'jpg',
|
||||
};
|
||||
// 6 KB Untergrenze: Portale liefern bei fehlendem Aufmacher ein Platzhalter-Pixel aus.
|
||||
if ($img['ok'] && strlen($img['body']) > 6144) {
|
||||
$bildDatei = $base . '.' . $ext;
|
||||
file_put_contents($dir . '/' . $bildDatei, $img['body']);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Textbeleg: das Fundstück bleibt auffindbar, auch wenn die Seite verschwindet ---
|
||||
$belegDatei = $base . '.artikel.txt';
|
||||
$beleg = implode("\n", array_filter([
|
||||
$titel,
|
||||
str_repeat('=', min(72, max(8, strlen($titel)))),
|
||||
'',
|
||||
$dnorm !== '0000-00-00' ? 'Erschienen: ' . $dnorm : null,
|
||||
'Quelle: ' . $host,
|
||||
'URL: ' . $url,
|
||||
'Abgerufen: ' . $heute,
|
||||
'',
|
||||
// Bei Volltext ist der Teaser nur dessen erster Absatz — nicht doppelt hinschreiben.
|
||||
($meta['teaser'] !== null && $volltext === '') ? wordwrap((string) $meta['teaser'], 88, "\n") : null,
|
||||
$volltext === '' ? '' : null,
|
||||
'--',
|
||||
$volltext !== ''
|
||||
? "Eigenes Vereinsmaterial — vollständig gesichert, weil die Quellseite offline ist."
|
||||
: 'Nur Überschrift, Datum und Anrisstext festgehalten — der vollständige Artikeltext',
|
||||
$volltext !== ''
|
||||
? ''
|
||||
: 'gehört dem Verlag und wird hier nicht gespeichert. Für die Chronik über die URL',
|
||||
$volltext !== '' ? '' : 'oder das Zeitungsarchiv im Original nachlesen.',
|
||||
$volltext !== '' ? "\n" . wordwrap($volltext, 88, "\n") : null,
|
||||
], static fn($l): bool => $l !== null));
|
||||
file_put_contents($dir . '/' . $belegDatei, $beleg . "\n");
|
||||
|
||||
// --- Register + Beleg-Sidecar ---
|
||||
$row = [
|
||||
'datei' => $bucket . '/' . ($bildDatei !== '' ? $bildDatei : $belegDatei),
|
||||
'datum' => $dnorm,
|
||||
'titel' => $titel,
|
||||
'art' => $art,
|
||||
'quelle' => $host,
|
||||
'url' => $url,
|
||||
'urheber' => $urheber,
|
||||
'rechte' => $rights['status'],
|
||||
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
|
||||
'abgerufen' => $heute,
|
||||
'notiz' => $notiz,
|
||||
];
|
||||
chronik_register_append($row);
|
||||
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
|
||||
|
||||
$flag = $rights['klaeren'] ? ' [Rechte klären]' : '';
|
||||
echo "✓ {$bucket}/{$base}" . ($bildDatei !== '' ? ' (+Bild)' : ' (nur Text)') . $flag . "\n";
|
||||
log_write('chronik', 'INFO', "Fundstück aufgenommen: {$bucket}/{$base} ({$host})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Datei aufnehmen, die schon da ist — Scan aus einer Festschrift, Foto aus einem
|
||||
* privaten Album, per Hand gespeichertes Pressebild.
|
||||
*
|
||||
* Genau derselbe Weg wie bei einer Web-Adresse: umbenennen nach Konvention, ins richtige
|
||||
* Jahrzehnt einsortieren, Beleg daneben, Zeile ins Register. Ohne das landen die Scans aus
|
||||
* dem Sammelaufruf als IMG_4711.jpg im Ordner und sind in einem Jahr nicht mehr zuzuordnen.
|
||||
*/
|
||||
function chronik_add_file(string $path, array $opts): bool
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
fwrite(STDERR, "✗ Datei nicht gefunden: {$path}\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
$root = chronik_root();
|
||||
$heute = date('Y-m-d');
|
||||
|
||||
$datum = is_string($opts['datum'] ?? null) ? $opts['datum'] : null;
|
||||
$titel = (string) ($opts['titel'] ?? pathinfo($path, PATHINFO_FILENAME));
|
||||
$quelle = (string) ($opts['quelle'] ?? 'Vereinsbestand');
|
||||
$urheber = (string) ($opts['urheber'] ?? '');
|
||||
$art = (string) ($opts['art'] ?? 'Foto');
|
||||
$notiz = (string) ($opts['notiz'] ?? '');
|
||||
|
||||
// Ohne --quelle gilt: es kommt aus dem Verein und ist nutzbar. Mit --quelle=<Host>
|
||||
// greift dieselbe Einschätzung wie bei einem Abruf aus dem Netz.
|
||||
$rights = str_contains($quelle, '.')
|
||||
? chronik_rights($quelle)
|
||||
: ['status' => 'Vereinsbestand — Herkunft ' . $quelle, 'klaeren' => false,
|
||||
'hinweis' => 'Aus dem Bestand des Vereins bzw. von Mitgliedern. Bei erkennbaren Personen '
|
||||
. 'die Einwilligung zur Veröffentlichung einholen, bei Kindern die Eltern fragen.'];
|
||||
if (isset($opts['rechte-klaeren'])) {
|
||||
$rights['klaeren'] = true;
|
||||
}
|
||||
|
||||
$tag = str_contains($quelle, '.') ? chronik_source_tag($quelle) : strtoupper(chronik_slug($quelle, 12));
|
||||
$bucket = chronik_bucket($datum);
|
||||
$dnorm = chronik_date_norm($datum);
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)) ?: 'bin';
|
||||
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
|
||||
|
||||
$dir = $root . '/' . $bucket;
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
fwrite(STDERR, "✗ Zielordner nicht anlegbar: {$bucket}\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
$ziel = $dir . '/' . $base . '.' . $ext;
|
||||
if (realpath($path) !== realpath($ziel) && !rename($path, $ziel)) {
|
||||
fwrite(STDERR, "✗ Verschieben fehlgeschlagen: {$path}\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = [
|
||||
'datei' => $bucket . '/' . $base . '.' . $ext,
|
||||
'datum' => $dnorm,
|
||||
'titel' => $titel,
|
||||
'art' => $art,
|
||||
'quelle' => $quelle,
|
||||
'url' => (string) ($opts['url'] ?? ''),
|
||||
'urheber' => $urheber,
|
||||
'rechte' => $rights['status'],
|
||||
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
|
||||
'abgerufen' => $heute,
|
||||
'notiz' => $notiz,
|
||||
];
|
||||
chronik_register_append($row);
|
||||
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
|
||||
|
||||
echo "✓ {$bucket}/{$base}.{$ext}" . ($rights['klaeren'] ? ' [Rechte klären]' : '') . "\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Registerübersicht — was haben wir, wo klafft die Lücke. */
|
||||
function chronik_report(): void
|
||||
{
|
||||
$path = chronik_register_path();
|
||||
if (!is_file($path)) {
|
||||
echo "Register ist noch leer.\n";
|
||||
return;
|
||||
}
|
||||
$fh = fopen($path, 'r');
|
||||
fgetcsv($fh, 0, ';', '"', '\\');
|
||||
|
||||
$perBucket = array_fill_keys(array_merge(array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]), 0);
|
||||
$perQuelle = [];
|
||||
$offen = 0;
|
||||
$gesamt = 0;
|
||||
while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
|
||||
$gesamt++;
|
||||
$bucket = explode('/', (string) ($r[0] ?? ''))[0];
|
||||
if (isset($perBucket[$bucket])) {
|
||||
$perBucket[$bucket]++;
|
||||
}
|
||||
$perQuelle[$r[4] ?? '?'] = ($perQuelle[$r[4] ?? '?'] ?? 0) + 1;
|
||||
if (($r[8] ?? '') === 'ja') {
|
||||
$offen++;
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
echo "Chronik-Register — {$gesamt} Fundstücke, {$offen} mit offenen Rechten\n\n";
|
||||
echo "Nach Zeitraum:\n";
|
||||
foreach ($perBucket as $b => $n) {
|
||||
printf(" %-28s %3d %s\n", $b, $n, $n === 0 ? '← Lücke' : str_repeat('▌', min(40, $n)));
|
||||
}
|
||||
echo "\nNach Quelle:\n";
|
||||
arsort($perQuelle);
|
||||
foreach ($perQuelle as $q => $n) {
|
||||
printf(" %-28s %3d\n", $q, $n);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hauptlauf -------------------------------------------------------------
|
||||
|
||||
if (isset($opts['datei'])) {
|
||||
$ok = chronik_add_file((string) $opts['datei'], $opts);
|
||||
$offen = chronik_write_rights_todo();
|
||||
echo "\n{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
|
||||
exit($ok ? 0 : 1);
|
||||
}
|
||||
|
||||
$jobs = [];
|
||||
if (isset($opts['liste'])) {
|
||||
$listFile = (string) $opts['liste'];
|
||||
if (!is_file($listFile)) {
|
||||
fwrite(STDERR, "Liste nicht gefunden: {$listFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
foreach (file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
$jobs[] = [
|
||||
'url' => $parts[0],
|
||||
'datum' => ($parts[1] ?? '') !== '' ? $parts[1] : null,
|
||||
'titel' => ($parts[2] ?? '') !== '' ? $parts[2] : null,
|
||||
];
|
||||
}
|
||||
} elseif ($positional !== []) {
|
||||
$jobs[] = ['url' => $positional[0], 'datum' => null, 'titel' => null];
|
||||
} else {
|
||||
fwrite(STDERR, "Aufruf: php bin/chronik-add.php <URL> | --liste=urls.txt | --rechte | --bericht\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$ok = 0;
|
||||
$fehler = 0;
|
||||
foreach ($jobs as $i => $job) {
|
||||
if ($i > 0) {
|
||||
// Pause zwischen den Abrufen: das hier ist Recherche, kein Crawl.
|
||||
usleep(1_500_000);
|
||||
}
|
||||
$override = array_filter(['datum' => $job['datum'], 'titel' => $job['titel']], static fn($v): bool => $v !== null);
|
||||
chronik_add($job['url'], $opts, $override) ? $ok++ : $fehler++;
|
||||
}
|
||||
|
||||
$offen = chronik_write_rights_todo();
|
||||
|
||||
echo "\n{$ok} aufgenommen" . ($fehler > 0 ? ", {$fehler} fehlgeschlagen" : '') . ".\n";
|
||||
echo "{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
|
||||
exit($fehler > 0 ? 1 : 0);
|
||||
94
bin/chronik-init.php
Normal file
94
bin/chronik-init.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ordnerstruktur für die Jubiläums-Chronik 2028 anlegen (unter Vereinshistorie/).
|
||||
*
|
||||
* Vereinshistorie/
|
||||
* 00_LIESMICH.txt ← Benennung + Ablageregeln, direkt im Ordner lesbar
|
||||
* 00_RECHTE-ZU-KLAEREN.txt ← von chronik-add.php gepflegt, siehe unten
|
||||
* 1908-1929/ … 2010-2028/ ← Jahrzehnt-Bündel, chronologisch
|
||||
* Festschriften-und-Dokumente/ ← Jubiläumsschriften, Satzungen, Urkunden, Protokolle
|
||||
* Undatiert/ ← Fundstücke ohne Jahr; wandern nach der Zuordnung um
|
||||
*
|
||||
* Warum Zeit als oberste Ebene und nicht Abteilung: eine Chronik wird chronologisch
|
||||
* gelesen und geschrieben. Fußball/Turnen steht im Dateinamen, danach kann man filtern —
|
||||
* ein Jahrzehnt dagegen ist die Einheit, in der Kapitel entstehen.
|
||||
*
|
||||
* Warum Jahrzehnte und nicht Einzeljahre: für 1908-1970 gibt es pro Jahr oft null bis drei
|
||||
* Fundstücke. 60 fast leere Ordner verstecken den Bestand, statt ihn zu zeigen. Die frühen
|
||||
* Bündel sind bewusst 20 Jahre breit, die jüngeren enger — dort wird das Material dichter.
|
||||
*
|
||||
* Die Ablage ist bewusst dieselbe wie in dateien-init.php: flach, ohne Vorgaben in der
|
||||
* Tiefe. Wer ein Ereignis bündeln will, legt darin „1972-07-15 Einweihung Sportheim" an.
|
||||
*
|
||||
* Idempotent — vorhandene Ordner und Dateien bleiben unberührt.
|
||||
*
|
||||
* NUR CLI, und die Ordner müssen auf dem SERVER entstehen, nicht per FTP hochgeladen
|
||||
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC).
|
||||
*
|
||||
* Aufruf: php bin/chronik-init.php
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
require __DIR__ . '/chronik-lib.php';
|
||||
|
||||
$root = chronik_root();
|
||||
$parent = dirname($root);
|
||||
|
||||
if (!is_dir($parent)) {
|
||||
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_writable($parent)) {
|
||||
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$failed = 0;
|
||||
|
||||
$dirs = array_merge([''], array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]);
|
||||
foreach (array_unique($dirs) as $rel) {
|
||||
$dir = $rel === '' ? $root : $root . '/' . $rel;
|
||||
$label = 'Vereinshistorie' . ($rel === '' ? '' : '/' . $rel);
|
||||
if (is_dir($dir)) {
|
||||
echo "· {$label} (gibt es schon)\n";
|
||||
continue;
|
||||
}
|
||||
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
fwrite(STDERR, "✗ {$label} konnte nicht angelegt werden\n");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
echo "✓ {$label}\n";
|
||||
$created++;
|
||||
}
|
||||
|
||||
// LIESMICH: die Ablageregeln gehören in den Ordner, nicht nur ins Repo — gelesen wird
|
||||
// dort, wo hochgeladen wird. Wird nicht überschrieben, falls jemand sie ergänzt hat.
|
||||
$readme = $root . '/00_LIESMICH.txt';
|
||||
if (!is_file($readme)) {
|
||||
file_put_contents($readme, chronik_readme_text());
|
||||
echo "✓ Vereinshistorie/00_LIESMICH.txt\n";
|
||||
$created++;
|
||||
} else {
|
||||
echo "· Vereinshistorie/00_LIESMICH.txt (gibt es schon)\n";
|
||||
}
|
||||
|
||||
// Entwurf der Verlagsanfrage: gehört neben die Fundstücke, auf die er sich bezieht.
|
||||
$anfrage = $root . '/00_RECHTE-Anfrage-Verlag-Entwurf.txt';
|
||||
if (!is_file($anfrage)) {
|
||||
file_put_contents($anfrage, chronik_verlagsanfrage_text());
|
||||
echo "✓ Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt\n";
|
||||
$created++;
|
||||
} else {
|
||||
echo "· Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt (gibt es schon)\n";
|
||||
}
|
||||
|
||||
echo "\n{$created} Einträge angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
564
bin/chronik-lib.php
Normal file
564
bin/chronik-lib.php
Normal file
@@ -0,0 +1,564 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Gemeinsame Logik der Chronik-Skripte (chronik-init.php, chronik-add.php).
|
||||
*
|
||||
* Bewusst hier unter bin/ und NICHT in app/helpers.php: das ist Recherche-Werkzeug für
|
||||
* die Jubiläums-Chronik 2028, kein Website-Code. Nichts davon darf je im Request-Pfad
|
||||
* landen — die Seite kennt die Chronik nicht.
|
||||
*
|
||||
* Zwei Dinge trägt diese Datei, und beide sind der eigentliche Punkt der Übung:
|
||||
*
|
||||
* 1. Der DATEINAME ist der Metadatenträger. Die Files Gallery unter /dateien kennt keine
|
||||
* Felder für Datum, Quelle oder Rechte — sie listet Dateien. Also muss alles, was ein
|
||||
* Fundstück später brauchbar macht, in den Namen: `1972-07-15_Einweihung-Sportheim_BR.jpg`.
|
||||
* Datum vorn, damit die alphabetische Sortierung der Gallery zufällig die chronologische ist.
|
||||
*
|
||||
* 2. RECHTE werden beim Einsammeln festgehalten, nicht 2028 rekonstruiert. Zu jedem Fundstück
|
||||
* entsteht eine `.quelle.txt` daneben und eine Zeile im Register. Was geklärt werden muss,
|
||||
* landet zusätzlich in 00_RECHTE-ZU-KLAEREN.txt — im Ordner, wo es jemand sieht.
|
||||
*/
|
||||
|
||||
/** Jahrzehnt-Bündel: [von, bis] => Ordnername. Früh breit, später enger — dort wird das Material dichter. */
|
||||
const CHRONIK_BUCKETS = [
|
||||
'1908-1929' => '1908-1929',
|
||||
'1930-1949' => '1930-1949',
|
||||
'1950-1969' => '1950-1969',
|
||||
'1970-1989' => '1970-1989',
|
||||
'1990-2009' => '1990-2009',
|
||||
'2010-2028' => '2010-2028',
|
||||
];
|
||||
|
||||
const CHRONIK_DOKUMENTE = 'Festschriften-und-Dokumente';
|
||||
const CHRONIK_UNDATIERT = 'Undatiert';
|
||||
|
||||
const CHRONIK_README = '00_LIESMICH.txt';
|
||||
const CHRONIK_RECHTE = '00_RECHTE-ZU-KLAEREN.txt';
|
||||
|
||||
/** Register liegt im REPO, nicht im Uploads-Ordner: uploads/ ist gitignored, das Register braucht Versionsgeschichte. */
|
||||
function chronik_register_path(): string
|
||||
{
|
||||
return ROOT_PATH . '/docs/chronik/register.csv';
|
||||
}
|
||||
|
||||
function chronik_root(): string
|
||||
{
|
||||
return STORAGE_PATH . '/dateien/uploads/Vereinshistorie';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wayback-Adressen auf die ursprüngliche Adresse zurückführen.
|
||||
*
|
||||
* Das Internet Archive ist nur der Aufbewahrungsort — Urheber und Rechte richten sich nach
|
||||
* der Seite, die dort eingefroren wurde. Ein Schnappschuss der eigenen alten Vereinsseite
|
||||
* gehört dem Verein, nicht dem Archiv.
|
||||
*/
|
||||
function chronik_origin_url(string $url): string
|
||||
{
|
||||
if (preg_match('#^https?://web\.archive\.org/web/[^/]+/(https?://.+)$#i', $url, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechtelage aus dem Host ableiten.
|
||||
*
|
||||
* Die Einschätzung ist eine ARBEITSHILFE, keine Rechtsauskunft — sie sagt nur, wo geklärt
|
||||
* werden muss, bevor etwas gedruckt oder auf die Website gestellt wird. Sammeln und Sichten
|
||||
* ist das eine, Veröffentlichen das andere; genau diese Grenze markiert das Feld.
|
||||
*
|
||||
* @return array{status:string, klaeren:bool, hinweis:string}
|
||||
*/
|
||||
function chronik_rights(string $host): array
|
||||
{
|
||||
$host = strtolower(preg_replace('/^www\./', '', $host));
|
||||
|
||||
$map = [
|
||||
'infranken.de' => [
|
||||
'Presse © Mediengruppe Oberfranken (inFranken/Bayerische Rundschau)',
|
||||
true,
|
||||
'Text UND Foto sind geschützt. Für Abdruck in der Chronik bzw. Nutzung auf der Website '
|
||||
. 'Nachdruckrechte beim Verlag anfragen (Mediengruppe Oberfranken, Bamberg). Für '
|
||||
. 'Vereinsjubiläen wird das üblicherweise gegen Quellenangabe eingeräumt — aber schriftlich, und früh.',
|
||||
],
|
||||
'bayerische-rundschau.de' => [
|
||||
'Presse © Mediengruppe Oberfranken (Bayerische Rundschau)',
|
||||
true,
|
||||
'Wie inFranken — gleicher Verlag. Nachdruckrechte schriftlich einholen.',
|
||||
],
|
||||
'frankenpost.de' => [
|
||||
'Presse © Mediengruppe Oberfranken (Frankenpost)',
|
||||
true,
|
||||
'Gleiches Haus wie Bayerische Rundschau/inFranken — eine gemeinsame Anfrage an die '
|
||||
. 'Mediengruppe Oberfranken deckt alle drei Titel ab.',
|
||||
],
|
||||
'fraenkischertag.de' => [
|
||||
'Presse © Mediengruppe Oberfranken (Fränkischer Tag)',
|
||||
true,
|
||||
'Vierter Titel des Hauses, dieselbe Anfrage deckt ihn mit ab. Achtung beim Lokalsport '
|
||||
. 'Kulmbach: dort steht oft „Foto: privat" — das Bild kommt dann aus dem Verein oder von '
|
||||
. 'einer Privatperson, die Bildrechte liegen bei ihr und nicht beim Verlag. Wer das ist, '
|
||||
. 'weiß die Redaktion oder die Mannschaft; danach ist es ein Anruf statt einer Anfrage.',
|
||||
],
|
||||
'kicker.de' => [
|
||||
'Presse © kicker / Olympia-Verlag',
|
||||
true,
|
||||
'Reine Stammdaten (Liga, Spielort) sind Fakten und frei; Texte und Fotos nicht.',
|
||||
],
|
||||
'europlan-online.de' => [
|
||||
'© Europlan-Online (Groundhopping-Portal)',
|
||||
true,
|
||||
'Stadiondaten als Fakten frei verwendbar; die Stadionfotos gehören den einzelnen '
|
||||
. 'Fotografen und sind einzeln anzufragen.',
|
||||
],
|
||||
'turneninkulmbach.info' => [
|
||||
'Turnabteilung TSV 08 Kulmbach — eigenes Material',
|
||||
false,
|
||||
'Seite der eigenen Turnabteilung. Intern klären, wer sie betreut, dann sind die '
|
||||
. 'Inhalte für die Chronik verfügbar.',
|
||||
],
|
||||
'anpfiff.info' => [
|
||||
'© anpfiff.info',
|
||||
true,
|
||||
'Amateurfußball-Portal. Ergebnisse frei, Berichte und Fotos nicht.',
|
||||
],
|
||||
'bfv.de' => [
|
||||
'© Bayerischer Fußball-Verband',
|
||||
true,
|
||||
'Reine Ergebnis- und Tabellendaten (Fakten) sind frei verwendbar; Texte, Fotos und Wappen nicht. '
|
||||
. 'Wappen fremder Vereine nur mit deren Zustimmung abbilden.',
|
||||
],
|
||||
'fussball.de' => [
|
||||
'© DFB / Bayerischer Fußball-Verband',
|
||||
true,
|
||||
'Ergebnisse und Tabellenstände als Fakten frei; Seitenaufbau, Texte und Fotos nicht.',
|
||||
],
|
||||
'deutsche-turnliga.de' => [
|
||||
'© Deutsche Turnliga',
|
||||
true,
|
||||
'Ergebnisdaten frei, Texte/Fotos nicht.',
|
||||
],
|
||||
'tsv08kulmbach.de' => [
|
||||
'Eigenes Material (TSV 08 Kulmbach)',
|
||||
false,
|
||||
'Aus dem Bestand des Vereins — nutzbar. Bei Personenfotos trotzdem das Recht am eigenen Bild beachten.',
|
||||
],
|
||||
// Frühere Vereinsdomain (Jimdo, ~2016–2023). DNS läuft nicht mehr, erreichbar nur
|
||||
// noch über das Internet Archive — inhaltlich aber unser eigenes Material.
|
||||
'tsv1908kulmbach.de' => [
|
||||
'Eigenes Material (TSV 08 Kulmbach, frühere Vereinsseite)',
|
||||
false,
|
||||
'Alte Vereinsseite, nur noch im Internet Archive. Inhalte stammen vom Verein selbst. '
|
||||
. 'Bei Personenfotos das Recht am eigenen Bild beachten.',
|
||||
],
|
||||
'commons.wikimedia.org' => [
|
||||
'Wikimedia Commons — freie Lizenz, Bedingung prüfen',
|
||||
true,
|
||||
'Frei nutzbar, aber fast immer MIT Namensnennung und Lizenzangabe. Die konkrete Lizenz '
|
||||
. '(CC BY-SA, CC0, PD) auf der Dateiseite ablesen und im Register eintragen.',
|
||||
],
|
||||
'de.wikipedia.org' => [
|
||||
'Wikipedia — CC BY-SA',
|
||||
true,
|
||||
'Text nur mit Namensnennung und unter gleicher Lizenz weiterverwendbar. Für eine Chronik '
|
||||
. 'besser als Faktenquelle nutzen und selbst formulieren.',
|
||||
],
|
||||
'facebook.com' => [
|
||||
'Social Media — Urheber unklar',
|
||||
true,
|
||||
'Wer das Foto gemacht hat, steht dort meist nicht. Vor einer Veröffentlichung Urheber '
|
||||
. 'ermitteln (oft der Verein selbst oder ein Mitglied) und Einwilligung abgeborgener Personen prüfen.',
|
||||
],
|
||||
'instagram.com' => [
|
||||
'Social Media — Urheber unklar',
|
||||
true,
|
||||
'Wie Facebook. Eigene Vereinsposts sind unkritisch, fremde nicht.',
|
||||
],
|
||||
'digipress.digitale-sammlungen.de' => [
|
||||
'Bayerische Staatsbibliothek — meist gemeinfrei',
|
||||
false,
|
||||
'Zeitungen bis ~1920er sind in der Regel gemeinfrei. Quellenangabe (Blatt, Datum, Seite, BSB) trotzdem mitführen.',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($map as $needle => [$status, $klaeren, $hinweis]) {
|
||||
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
|
||||
return ['status' => $status, 'klaeren' => $klaeren, 'hinweis' => $hinweis];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'Ungeklärt — Urheber unbekannt',
|
||||
'klaeren' => true,
|
||||
'hinweis' => 'Herkunft im Register nachtragen und vor jeder Veröffentlichung klären.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Datum (YYYY, YYYY-MM oder YYYY-MM-DD) → Zielordner.
|
||||
* Unbekanntes oder unplausibles Datum landet in Undatiert/ statt still im falschen Jahrzehnt.
|
||||
*/
|
||||
function chronik_bucket(?string $date): string
|
||||
{
|
||||
if ($date === null || !preg_match('/^(\d{4})/', $date, $m)) {
|
||||
return CHRONIK_UNDATIERT;
|
||||
}
|
||||
$year = (int) $m[1];
|
||||
foreach (CHRONIK_BUCKETS as $range => $dir) {
|
||||
[$from, $to] = array_map('intval', explode('-', $range));
|
||||
if ($year >= $from && $year <= $to) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
return CHRONIK_UNDATIERT;
|
||||
}
|
||||
|
||||
/** Datum auf YYYY-MM-DD normalisieren; unbekannte Teile werden 00 (sortiert vor dem 1. des Monats). */
|
||||
function chronik_date_norm(?string $date): string
|
||||
{
|
||||
if ($date === null || !preg_match('/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/', $date, $m)) {
|
||||
return '0000-00-00';
|
||||
}
|
||||
return sprintf('%s-%s-%s', $m[1], $m[2] ?? '00', $m[3] ?? '00');
|
||||
}
|
||||
|
||||
/** Titel → dateinamensicherer Slug. Umlaute ausgeschrieben, damit NFD/NFC nie zum Thema wird. */
|
||||
function chronik_slug(string $text, int $maxLen = 60): string
|
||||
{
|
||||
$text = strtr($text, [
|
||||
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue',
|
||||
'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'á' => 'a', 'à' => 'a', 'ç' => 'c',
|
||||
]);
|
||||
$text = (string) preg_replace('/[^A-Za-z0-9]+/', '-', $text);
|
||||
$text = trim((string) preg_replace('/-+/', '-', $text), '-');
|
||||
if (strlen($text) > $maxLen) {
|
||||
$text = substr($text, 0, $maxLen);
|
||||
$text = (string) preg_replace('/-[^-]*$/', '', $text) ?: $text;
|
||||
}
|
||||
return $text !== '' ? $text : 'ohne-titel';
|
||||
}
|
||||
|
||||
/** Kurzkürzel der Quelle für den Dateinamen (BR, BFV, …). */
|
||||
function chronik_source_tag(string $host): string
|
||||
{
|
||||
$host = strtolower(preg_replace('/^www\./', '', $host));
|
||||
$tags = [
|
||||
'infranken.de' => 'BR',
|
||||
'bayerische-rundschau.de' => 'BR',
|
||||
'frankenpost.de' => 'FP',
|
||||
'fraenkischertag.de' => 'FT',
|
||||
'kicker.de' => 'KICKER',
|
||||
'europlan-online.de' => 'EUROPLAN',
|
||||
'anpfiff.info' => 'ANPFIFF',
|
||||
'turneninkulmbach.info' => 'TURNEN08',
|
||||
'tsv1908kulmbach.de' => 'TSV08',
|
||||
'bfv.de' => 'BFV',
|
||||
'fussball.de' => 'FUSSBALLDE',
|
||||
'deutsche-turnliga.de' => 'DTL',
|
||||
'tsv08kulmbach.de' => 'TSV08',
|
||||
'facebook.com' => 'FB',
|
||||
'instagram.com' => 'IG',
|
||||
'de.wikipedia.org' => 'WIKIPEDIA',
|
||||
'commons.wikimedia.org' => 'COMMONS',
|
||||
'digipress.digitale-sammlungen.de' => 'BSB',
|
||||
'web.archive.org' => 'WAYBACK',
|
||||
];
|
||||
foreach ($tags as $needle => $tag) {
|
||||
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
|
||||
return $tag;
|
||||
}
|
||||
}
|
||||
return strtoupper(chronik_slug(explode('.', $host)[0] ?? 'quelle', 12));
|
||||
}
|
||||
|
||||
/** Registerzeile anhängen (CSV, Semikolon — öffnet sich in Excel/LibreOffice ohne Import-Dialog). */
|
||||
function chronik_register_append(array $row): void
|
||||
{
|
||||
$path = chronik_register_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$cols = ['datei', 'datum', 'titel', 'art', 'quelle', 'url', 'urheber', 'rechte', 'klaeren', 'abgerufen', 'notiz'];
|
||||
$new = !is_file($path);
|
||||
|
||||
$fh = fopen($path, 'a');
|
||||
if (!$fh) {
|
||||
return;
|
||||
}
|
||||
flock($fh, LOCK_EX);
|
||||
if ($new) {
|
||||
fputcsv($fh, $cols, ';', '"', '\\');
|
||||
}
|
||||
fputcsv($fh, array_map(static fn(string $c): string => (string) ($row[$c] ?? ''), $cols), ';', '"', '\\');
|
||||
flock($fh, LOCK_UN);
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
/** Schon im Register? Verhindert Dubletten bei wiederholten Läufen. */
|
||||
function chronik_register_has_url(string $url): bool
|
||||
{
|
||||
$path = chronik_register_path();
|
||||
if (!is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$fh = fopen($path, 'r');
|
||||
if (!$fh) {
|
||||
return false;
|
||||
}
|
||||
$found = false;
|
||||
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
|
||||
if (($row[5] ?? '') === $url) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Beleg-Sidecar neben dem Fundstück: alles, was der Dateiname nicht fasst. */
|
||||
function chronik_sidecar_text(array $row, string $hinweis): string
|
||||
{
|
||||
$lines = [
|
||||
'FUNDSTÜCK FÜR DIE VEREINSCHRONIK — TSV 08 Kulmbach',
|
||||
str_repeat('=', 52),
|
||||
'',
|
||||
'Titel: ' . ($row['titel'] ?? ''),
|
||||
'Datum: ' . ($row['datum'] ?? '') . ' (00 = unbekannt)',
|
||||
'Art: ' . ($row['art'] ?? ''),
|
||||
'Quelle: ' . ($row['quelle'] ?? ''),
|
||||
'URL: ' . ($row['url'] ?? ''),
|
||||
'Urheber: ' . (($row['urheber'] ?? '') !== '' ? $row['urheber'] : 'nicht angegeben'),
|
||||
'Abgerufen am: ' . ($row['abgerufen'] ?? ''),
|
||||
'',
|
||||
'RECHTE: ' . ($row['rechte'] ?? ''),
|
||||
' ' . (($row['klaeren'] ?? '') === 'ja' ? '>>> VOR VERÖFFENTLICHUNG KLÄREN <<<' : 'nutzbar'),
|
||||
'',
|
||||
wordwrap($hinweis, 88, "\n"),
|
||||
];
|
||||
if (($row['notiz'] ?? '') !== '') {
|
||||
$lines[] = '';
|
||||
$lines[] = 'Notiz:';
|
||||
$lines[] = wordwrap((string) $row['notiz'], 88, "\n");
|
||||
}
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 00_RECHTE-ZU-KLAEREN.txt aus dem Register neu schreiben.
|
||||
* Bewusst generiert statt fortgeschrieben: die Datei soll immer den aktuellen Stand zeigen,
|
||||
* auch wenn im Register etwas auf „geklärt" gesetzt wurde.
|
||||
*/
|
||||
function chronik_write_rights_todo(): int
|
||||
{
|
||||
$path = chronik_register_path();
|
||||
if (!is_file($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$byRights = [];
|
||||
$count = 0;
|
||||
$fh = fopen($path, 'r');
|
||||
if (!$fh) {
|
||||
return 0;
|
||||
}
|
||||
fgetcsv($fh, 0, ';', '"', '\\'); // Kopfzeile
|
||||
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
|
||||
if (($row[8] ?? '') !== 'ja') {
|
||||
continue;
|
||||
}
|
||||
$byRights[$row[7] ?? 'Ungeklärt'][] = [
|
||||
'datei' => $row[0] ?? '',
|
||||
'titel' => $row[2] ?? '',
|
||||
'datum' => $row[1] ?? '',
|
||||
'url' => $row[5] ?? '',
|
||||
// Der Verlag verlangt den Bildnachweis namentlich — wo er bekannt ist,
|
||||
// gehört er in die Anfrage-Anlage, nicht nur ins Register.
|
||||
'urheber' => $row[6] ?? '',
|
||||
];
|
||||
$count++;
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
$out = [
|
||||
'RECHTE, DIE VOR DER VERÖFFENTLICHUNG ZU KLÄREN SIND',
|
||||
str_repeat('=', 52),
|
||||
'',
|
||||
'Diese Datei wird von bin/chronik-add.php erzeugt — nicht von Hand pflegen,',
|
||||
'sondern die Spalte „klaeren" in docs/chronik/register.csv auf „nein" setzen,',
|
||||
'sobald eine Freigabe vorliegt, und das Skript erneut laufen lassen.',
|
||||
'',
|
||||
'Sammeln und intern sichten ist unproblematisch. Erst der Abdruck in der Chronik',
|
||||
'oder die Veröffentlichung auf der Website braucht eine Erlaubnis.',
|
||||
'',
|
||||
"Offen: {$count} Fundstücke",
|
||||
'',
|
||||
];
|
||||
|
||||
foreach ($byRights as $rechte => $items) {
|
||||
$out[] = str_repeat('-', 52);
|
||||
$out[] = $rechte . ' (' . count($items) . ')';
|
||||
$out[] = str_repeat('-', 52);
|
||||
foreach ($items as $i) {
|
||||
$out[] = ' · ' . $i['datum'] . ' ' . $i['titel'];
|
||||
$out[] = ' ' . $i['datei'];
|
||||
if ($i['urheber'] !== '') {
|
||||
$out[] = ' ' . $i['urheber'];
|
||||
}
|
||||
if ($i['url'] !== '') {
|
||||
$out[] = ' ' . $i['url'];
|
||||
}
|
||||
}
|
||||
$out[] = '';
|
||||
}
|
||||
|
||||
$out[] = 'Stand: ' . date('d.m.Y H:i');
|
||||
file_put_contents(chronik_root() . '/' . CHRONIK_RECHTE, implode("\n", $out) . "\n");
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entwurf der Nachdruck-Anfrage an den Verlag, als Datei im Ordner.
|
||||
*
|
||||
* Steht hier und nicht im Repo, weil der Vorstand sie braucht, nicht die Entwicklung — und
|
||||
* weil sie neben den Fundstücken liegen soll, auf die sie sich bezieht. Der wichtigste Satz
|
||||
* darin ist die Bitte um EINE Freigabe für alle drei Titel: Bayerische Rundschau, inFranken
|
||||
* und Frankenpost gehören zur selben Mediengruppe, drei Einzelanfragen wären unnötig.
|
||||
*/
|
||||
function chronik_verlagsanfrage_text(): string
|
||||
{
|
||||
return <<<TXT
|
||||
ENTWURF — ANFRAGE NACHDRUCKRECHTE
|
||||
=================================
|
||||
|
||||
Für die Vereinschronik zum 120-jährigen Bestehen (2028) wollen wir Zeitungsberichte und
|
||||
Pressefotos verwenden. Die gehören dem Verlag, nicht uns — auch die, in denen wir vorkommen.
|
||||
|
||||
Bitte früh abschicken. Eine Absage im Jahr 2027 wirft das Chronik-Konzept um, eine Absage
|
||||
2026 kostet nichts.
|
||||
|
||||
Empfänger: Mediengruppe Oberfranken – Zeitungsverlage GmbH & Co. KG
|
||||
(deckt Bayerische Rundschau, inFranken.de UND Frankenpost gemeinsam ab)
|
||||
|
||||
Vor dem Versand prüfen: aktuelle Anschrift und zuständige Ansprechperson auf der
|
||||
Verlagsseite nachsehen, die Liste im Anhang aus docs/chronik/register.csv erzeugen.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
Betreff: Nachdruckrechte für die Vereinschronik zum 120-jährigen Jubiläum des
|
||||
TSV 08 Kulmbach (2028)
|
||||
|
||||
Sehr geehrte Damen und Herren,
|
||||
|
||||
der TSV 1908 Kulmbach e. V. begeht im Jahr 2028 sein 120-jähriges Bestehen. Aus diesem
|
||||
Anlass erarbeiten wir eine Vereinschronik.
|
||||
|
||||
Über die Jahrzehnte hat die Bayerische Rundschau das Vereinsleben in Kulmbach begleitet.
|
||||
Ihre Berichte sind für unsere Vereinsgeschichte häufig die einzige erhaltene Quelle.
|
||||
Wir möchten Sie deshalb um die Erlaubnis bitten, einzelne Artikel und Pressefotos in der
|
||||
Chronik abzudrucken — jeweils mit vollständiger Quellenangabe (Titel, Datum, Autorin
|
||||
oder Autor, Bildnachweis).
|
||||
|
||||
Konkret geht es um:
|
||||
|
||||
· Umfang: voraussichtlich [ANZAHL] Artikel und [ANZAHL] Fotos
|
||||
(Aufstellung im Anhang)
|
||||
· Verwendung: gedruckte Festschrift, Auflage ca. [ANZAHL] Exemplare,
|
||||
Abgabe an Mitglieder und Gäste, nicht gewerblich
|
||||
· zusätzlich: Darstellung einzelner Ausschnitte im Historie-Bereich unserer
|
||||
Vereinswebsite tsv08kulmbach.de
|
||||
· Zeitraum: Erscheinen der Chronik im Jubiläumsjahr 2028
|
||||
|
||||
Sollten für die Nutzung Gebühren anfallen, bitten wir um eine Auskunft über die Konditionen.
|
||||
Als gemeinnütziger Verein sind wir für ein Entgegenkommen dankbar.
|
||||
|
||||
Für Rückfragen stehen wir gerne zur Verfügung.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
[NAME], [FUNKTION]
|
||||
TSV 1908 Kulmbach e. V.
|
||||
[ANSCHRIFT / TELEFON / E-MAIL]
|
||||
|
||||
Anlage: Aufstellung der gewünschten Artikel und Fotos
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
NOCH ZU KLÄREN, UNABHÄNGIG VOM VERLAG
|
||||
-------------------------------------
|
||||
· Private Fotos von Mitgliedern: Einverständnis der abgebildeten Personen einholen.
|
||||
Bei Kindern die Eltern fragen. Am einfachsten gleich beim Einsammeln miterledigen.
|
||||
· Wappen anderer Vereine: nur mit deren Zustimmung abbilden.
|
||||
· Fotos ohne bekannten Urheber: Herkunft ermitteln, solange es noch jemand weiß.
|
||||
In zwei Jahren weiß es niemand mehr.
|
||||
TXT;
|
||||
}
|
||||
|
||||
function chronik_readme_text(): string
|
||||
{
|
||||
return <<<TXT
|
||||
VEREINSHISTORIE — MATERIALSAMMLUNG FÜR DAS JUBILÄUM 2028
|
||||
========================================================
|
||||
|
||||
Hier sammeln wir alles, was in die Vereinschronik zum 120-jährigen Bestehen
|
||||
einfließen kann: Fotos, Zeitungsausschnitte, Urkunden, Festschriften, Programmhefte,
|
||||
Mannschaftsbilder, Wimpel, Protokolle.
|
||||
|
||||
Lieber zu viel als zu wenig. Aussortiert wird später.
|
||||
|
||||
|
||||
WO KOMMT ES HIN?
|
||||
----------------
|
||||
In den Ordner des Jahrzehnts, aus dem es stammt:
|
||||
|
||||
1908-1929 1930-1949 1950-1969 1970-1989 1990-2009 2010-2028
|
||||
|
||||
Weißt du das Jahr nicht? -> Undatiert/ (wir ordnen es später zu)
|
||||
Festschrift, Satzung, Urkunde, Protokoll? -> Festschriften-und-Dokumente/
|
||||
|
||||
|
||||
WIE SOLL ES HEISSEN?
|
||||
--------------------
|
||||
Datum zuerst, dann worum es geht:
|
||||
|
||||
1972-07-15_Einweihung-Sportheim.jpg
|
||||
1985-00-00_Mannschaftsfoto-Erste.jpg (Monat/Tag unbekannt -> 00)
|
||||
1962-00-00_Aufstieg-Bericht_BR.jpg (Quelle hinten, hier: Bayerische Rundschau)
|
||||
|
||||
Das Datum vorn sorgt dafür, dass die Liste sich von selbst richtig sortiert.
|
||||
Wenn du den Namen nicht hinbekommst: trotzdem hochladen. Ein falsch benanntes
|
||||
Foto ist unendlich viel besser als ein Foto, das im Schuhkarton bleibt.
|
||||
|
||||
|
||||
WAS UNS BEI JEDEM STÜCK HILFT
|
||||
-----------------------------
|
||||
Wenn du es weißt, schreib es dazu (in den Dateinamen oder als kurze Textdatei daneben):
|
||||
· Wann ungefähr?
|
||||
· Wer ist zu sehen?
|
||||
· Wo aufgenommen?
|
||||
· Wer hat fotografiert / wo stand der Artikel?
|
||||
|
||||
Der letzte Punkt ist der wichtigste und der, der am häufigsten fehlt. Ohne ihn
|
||||
dürfen wir das Bild später womöglich nicht drucken.
|
||||
|
||||
|
||||
RECHTE
|
||||
------
|
||||
Zeitungsartikel und Pressefotos gehören dem Verlag, nicht uns — auch wenn wir
|
||||
darin vorkommen. Was noch freigegeben werden muss, steht in
|
||||
|
||||
00_RECHTE-ZU-KLAEREN.txt
|
||||
|
||||
Private Aufnahmen von Vereinsmitgliedern sind unkritisch, solange die abgebildeten
|
||||
Personen mit einer Veröffentlichung einverstanden sind.
|
||||
|
||||
|
||||
Fragen? Melde dich bei der Vorstandschaft.
|
||||
TXT;
|
||||
}
|
||||
83
bin/dateien-init.php
Normal file
83
bin/dateien-init.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ordnerstruktur der Dateiablage /dateien anlegen: Jahr → Abteilung.
|
||||
*
|
||||
* 2026/
|
||||
* Fußball/
|
||||
* Jugend/
|
||||
* Turnen/
|
||||
* Feste und Aktionen/
|
||||
*
|
||||
* Warum diese Reihenfolge: das Jahr außen hält die oberste Ebene für immer kurz
|
||||
* (ein Eintrag pro Jahr) und macht die Grenze zum Archiv sichtbar. Die Abteilungen
|
||||
* innen sind bewusst dieselben wie die Bereiche der Website — dann ist beim Hochladen
|
||||
* schon klar, auf welche Seite das Material später gehört. Wer ein Ereignis bündeln
|
||||
* will, legt darin einen Ordner mit Datum voran an („2026-08-15 Sommerfest"); der
|
||||
* sortiert sich dann von selbst richtig ein.
|
||||
*
|
||||
* Tiefer als diese zwei Ebenen wird nichts vorgegeben: die hochladenden Personen
|
||||
* stehen meist mit dem Handy am Spielfeldrand, und jede zusätzliche Ebene wird
|
||||
* ignoriert oder falsch getroffen.
|
||||
*
|
||||
* Idempotent — vorhandene Ordner bleiben unberührt. Im Januar für das neue Jahr
|
||||
* einmal neu aufrufen (oder das Vereins-Konto legt den Jahresordner selbst an).
|
||||
*
|
||||
* NUR CLI. Die Ordner müssen auf dem Server entstehen, nicht per FTP hochgeladen
|
||||
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC), sonst
|
||||
* steht „Fußball" am Ende zweimal da.
|
||||
*
|
||||
* Aufruf: php bin/dateien-init.php [Jahr] (ohne Argument: laufendes Jahr)
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
const DATEIEN_ABTEILUNGEN = ['Fußball', 'Jugend', 'Turnen', 'Feste und Aktionen'];
|
||||
|
||||
// Europe/Berlin, weil PHP global auf UTC läuft — sonst kippt das Jahr am 31.12. zu früh.
|
||||
$year = (string) ($argv[1] ?? (new DateTimeImmutable('now', new DateTimeZone('Europe/Berlin')))->format('Y'));
|
||||
|
||||
if (!preg_match('/^20\d{2}$/', $year)) {
|
||||
fwrite(STDERR, "Ungültiges Jahr: {$year}\nAufruf: php bin/dateien-init.php [Jahr]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = STORAGE_PATH . '/dateien/uploads';
|
||||
if (!is_dir($root)) {
|
||||
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_writable($root)) {
|
||||
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$paths = [$year];
|
||||
foreach (DATEIEN_ABTEILUNGEN as $abteilung) {
|
||||
$paths[] = $year . '/' . $abteilung;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$failed = 0;
|
||||
foreach ($paths as $rel) {
|
||||
$dir = $root . '/' . $rel;
|
||||
if (is_dir($dir)) {
|
||||
echo "· {$rel} (gibt es schon)\n";
|
||||
continue;
|
||||
}
|
||||
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
fwrite(STDERR, "✗ {$rel} konnte nicht angelegt werden\n");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
echo "✓ {$rel}\n";
|
||||
$created++;
|
||||
}
|
||||
|
||||
echo "\n{$created} Ordner angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
273
bin/filesgallery-update.php
Normal file
273
bin/filesgallery-update.php
Normal file
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Files Gallery (Dateiablage /dateien) aktualisieren — App-Datei und self-hosted Assets.
|
||||
*
|
||||
* Warum ein Skript: die App lädt ihre Assets sonst vom jsDelivr-CDN (index.php:965), was
|
||||
* gegen Regel 1 verstößt. Self-Hosting ist offiziell vorgesehen, muss aber bei JEDEM
|
||||
* Versionswechsel wiederholt werden, weil die Asset-Pfade die Version enthalten
|
||||
* (files.photo.gallery@<version>/...). Dazu kommt der TSV-PATCH in index.php, den ein
|
||||
* Update sonst wegwirft. Beides von Hand ist genau der Schritt, der einmal vergessen wird.
|
||||
*
|
||||
* NUR CLI/Build — nie im Request-Pfad. Die einzige ausgehende Verbindung ist jsDelivr,
|
||||
* und die passiert hier, nicht im Browser des Besuchers.
|
||||
*
|
||||
* Aufruf:
|
||||
* php bin/filesgallery-update.php --check nur prüfen (Patch da? Assets zur Version da?)
|
||||
* php bin/filesgallery-update.php --assets Assets zur vendorten Version neu holen
|
||||
* php bin/filesgallery-update.php neue index.php holen, patchen, Assets holen
|
||||
*
|
||||
* Nach einem echten Versionssprung: Login, Upload und Vorschau einmal durchklicken und
|
||||
* die alten Asset-Verzeichnisse löschen (das Skript sagt, welche das sind).
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
const FG_APP = PUBLIC_PATH . '/dateien/index.php';
|
||||
const FG_ASSETS_DIR = PUBLIC_PATH . '/assets/filesgallery';
|
||||
const FG_APP_URL = 'https://cdn.jsdelivr.net/npm/files.photo.gallery/index.php';
|
||||
const FG_LOG = STORAGE_PATH . '/logs/filesgallery.log';
|
||||
|
||||
// Der Patch, ohne den die App bei abweichendem Arbeitsverzeichnis ohne Login startet.
|
||||
const FG_PATCH_NEEDLE = "public static \$localconfigpath = '_filesconfig.php';";
|
||||
const FG_PATCH_MARKER = "public static \$localconfigpath = __DIR__ . '/_filesconfig.php';";
|
||||
const FG_PATCH_REPLACEMENT = <<<'PHP'
|
||||
// TSV-PATCH (einzige Änderung an dieser Datei): absolut statt relativ.
|
||||
// Der Original-Pfad wird gegen das aktuelle Arbeitsverzeichnis aufgelöst. Weicht das ab
|
||||
// (z. B. unter `php -S` mit Router-Script), findet die App _filesconfig.php nicht und
|
||||
// startet mit ihrer Default-Config — also OHNE Login und mit falschen Pfaden.
|
||||
// Nebeneffekt: der Selbst-Ausschluss in Path::is_exclude() vergleicht diesen Wert mit
|
||||
// einem Basename und greift dadurch nicht mehr. Belanglos, weil unser `root`
|
||||
// (storage/dateien/uploads) außerhalb dieses Verzeichnisses liegt.
|
||||
// Nach jedem Update neu setzen: `php bin/filesgallery-update.php` erledigt und prüft das.
|
||||
public static $localconfigpath = __DIR__ . '/_filesconfig.php';
|
||||
PHP;
|
||||
|
||||
$mode = $argv[1] ?? '';
|
||||
if (!in_array($mode, ['', '--check', '--assets'], true)) {
|
||||
fwrite(STDERR, "Unbekannte Option: {$mode}\nAufruf: php bin/filesgallery-update.php [--check|--assets]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$errors = 0;
|
||||
|
||||
function fg_log(string $line): void
|
||||
{
|
||||
@file_put_contents(FG_LOG, date('c') . ' ' . $line . "\n", FILE_APPEND);
|
||||
}
|
||||
|
||||
function fg_fail(string $message): void
|
||||
{
|
||||
global $errors;
|
||||
$errors++;
|
||||
fwrite(STDERR, "✗ {$message}\n");
|
||||
fg_log('FEHLER ' . $message);
|
||||
}
|
||||
|
||||
/** Datei von jsDelivr holen; gibt den Rohinhalt zurück oder null. */
|
||||
function fg_fetch(string $url): ?string
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 120, // die Assets sind ein paar MB
|
||||
CURLOPT_USERAGENT => 'tsv08kulmbach-filesgallery-update/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($ch);
|
||||
|
||||
if (!is_string($body) || $body === '' || $status !== 200) {
|
||||
fg_fail("Download fehlgeschlagen ({$status}" . ($error !== '' ? ", {$error}" : '') . "): {$url}");
|
||||
return null;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/** Version aus dem Quelltext der App lesen. */
|
||||
function fg_version(string $source): ?string
|
||||
{
|
||||
return preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Den TSV-PATCH anwenden. Idempotent: ist er schon drin, bleibt die Datei unverändert.
|
||||
* Fehlt beides (Patch und Original-Zeile), bricht das Skript ab — dann hat die App ihre
|
||||
* Struktur geändert und der Patch muss von Hand nachgezogen werden.
|
||||
*/
|
||||
function fg_patch(string $source): ?string
|
||||
{
|
||||
if (str_contains($source, FG_PATCH_MARKER)) {
|
||||
return $source;
|
||||
}
|
||||
if (!str_contains($source, FG_PATCH_NEEDLE)) {
|
||||
fg_fail('Patch-Stelle nicht gefunden ($localconfigpath) — index.php von Hand prüfen, NICHT ungepatcht ausliefern.');
|
||||
return null;
|
||||
}
|
||||
// Die Original-Zeile trägt einen Kommentar hinter dem Semikolon; den behalten wir.
|
||||
return (string) preg_replace(
|
||||
'/^\h*' . preg_quote(FG_PATCH_NEEDLE, '/') . '/m',
|
||||
FG_PATCH_REPLACEMENT,
|
||||
$source,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/** assets.zip zur Version holen und nach public/assets/filesgallery/ entpacken. */
|
||||
function fg_assets(string $version): void
|
||||
{
|
||||
$url = "https://cdn.jsdelivr.net/npm/files.photo.gallery@{$version}/_files/assets/assets.zip";
|
||||
$zipData = fg_fetch($url);
|
||||
if ($zipData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'fg-assets-');
|
||||
if ($tmp === false || file_put_contents($tmp, $zipData) === false) {
|
||||
fg_fail('Konnte assets.zip nicht zwischenspeichern.');
|
||||
return;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tmp) !== true) {
|
||||
fg_fail('assets.zip ist kein lesbares ZIP-Archiv.');
|
||||
@unlink($tmp);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir(FG_ASSETS_DIR) && !mkdir(FG_ASSETS_DIR, 0775, true) && !is_dir(FG_ASSETS_DIR)) {
|
||||
fg_fail('Kann ' . FG_ASSETS_DIR . ' nicht anlegen.');
|
||||
$zip->close();
|
||||
@unlink($tmp);
|
||||
return;
|
||||
}
|
||||
|
||||
$written = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = (string) $zip->getNameIndex($i);
|
||||
|
||||
// Manche Archive tragen den _files/assets-Pfad noch mit sich; wir wollen den Inhalt flach.
|
||||
$rel = (string) preg_replace('#^(\./)?(_files/)?assets/#', '', $name);
|
||||
$rel = ltrim(str_replace('\\', '/', $rel), '/');
|
||||
|
||||
// Zip-Slip: alles mit Parent-Traversal oder absolutem Pfad wird verworfen.
|
||||
if ($rel === '' || str_contains($rel, '../')) {
|
||||
continue;
|
||||
}
|
||||
$target = FG_ASSETS_DIR . '/' . $rel;
|
||||
|
||||
if (str_ends_with($name, '/')) {
|
||||
if (!is_dir($target)) {
|
||||
@mkdir($target, 0775, true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
fg_fail("Kann Verzeichnis nicht anlegen: {$dir}");
|
||||
continue;
|
||||
}
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
fg_fail("Konnte Eintrag nicht lesen: {$name}");
|
||||
continue;
|
||||
}
|
||||
if (@file_put_contents($target, $stream) === false) {
|
||||
fg_fail("Konnte Datei nicht schreiben: {$target}");
|
||||
} else {
|
||||
$written++;
|
||||
}
|
||||
fclose($stream);
|
||||
}
|
||||
$zip->close();
|
||||
@unlink($tmp);
|
||||
|
||||
echo "✓ Assets entpackt ({$written} Dateien) → " . str_replace(ROOT_PATH . '/', '', FG_ASSETS_DIR) . "\n";
|
||||
fg_log("Assets {$version} entpackt ({$written} Dateien)");
|
||||
}
|
||||
|
||||
/** Prüfen, ob Patch und Assets zur vendorten Version passen. */
|
||||
function fg_check(string $version, string $source): void
|
||||
{
|
||||
if (str_contains($source, FG_PATCH_MARKER)) {
|
||||
echo "✓ TSV-PATCH in public/dateien/index.php vorhanden\n";
|
||||
} else {
|
||||
fg_fail('TSV-PATCH fehlt in public/dateien/index.php — die App würde ohne Login starten.');
|
||||
}
|
||||
|
||||
$css = FG_ASSETS_DIR . "/files.photo.gallery@{$version}/css/files.css";
|
||||
if (is_file($css)) {
|
||||
echo "✓ Assets für Version {$version} vorhanden\n";
|
||||
} else {
|
||||
fg_fail("Assets für Version {$version} fehlen (erwartet: " . str_replace(ROOT_PATH . '/', '', $css) . ") — php bin/filesgallery-update.php --assets");
|
||||
}
|
||||
|
||||
// Verirrte Assets anderer Versionen melden, damit sie nicht mitgeschleppt werden.
|
||||
foreach (glob(FG_ASSETS_DIR . '/files.photo.gallery@*') ?: [] as $dir) {
|
||||
if (basename($dir) !== "files.photo.gallery@{$version}") {
|
||||
echo "· Alte Assets, können weg: " . str_replace(ROOT_PATH . '/', '', $dir) . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ablauf ---------------------------------------------------------------------------
|
||||
|
||||
if (!is_file(FG_APP)) {
|
||||
fwrite(STDERR, '✗ ' . FG_APP . " fehlt.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$source = (string) file_get_contents(FG_APP);
|
||||
|
||||
if ($mode === '' ) {
|
||||
// Vollupdate: neue App-Datei holen, patchen, dann Assets zur neuen Version.
|
||||
$fresh = fg_fetch(FG_APP_URL);
|
||||
if ($fresh !== null) {
|
||||
$newVersion = fg_version($fresh);
|
||||
if ($newVersion === null) {
|
||||
fg_fail('Version im Download nicht gefunden — Datei nicht übernommen.');
|
||||
} else {
|
||||
$patched = fg_patch($fresh);
|
||||
if ($patched !== null) {
|
||||
$old = fg_version($source) ?? '?';
|
||||
if ($patched === $source) {
|
||||
echo "· Version {$newVersion} ist schon aktuell und gepatcht\n";
|
||||
} elseif (file_put_contents(FG_APP, $patched) === false) {
|
||||
fg_fail('Konnte ' . FG_APP . ' nicht schreiben.');
|
||||
} else {
|
||||
echo "✓ index.php aktualisiert: {$old} → {$newVersion} (gepatcht)\n";
|
||||
fg_log("index.php {$old} → {$newVersion}");
|
||||
$source = $patched;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$version = fg_version($source);
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "✗ Version in public/dateien/index.php nicht lesbar.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($mode !== '--check') {
|
||||
fg_assets($version);
|
||||
}
|
||||
|
||||
fg_check($version, $source);
|
||||
|
||||
if ($errors > 0) {
|
||||
fwrite(STDERR, "\n{$errors} Problem(e) — nicht ausliefern, bis sie behoben sind.\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "\nFiles Gallery {$version}: alles an seinem Platz.\n";
|
||||
exit(0);
|
||||
@@ -16,6 +16,8 @@ if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$args = array_slice($argv, 1);
|
||||
$crop = false;
|
||||
if (($args[0] ?? '') === '--crop') {
|
||||
@@ -31,6 +33,7 @@ if (count($args) < 3) {
|
||||
[$src, $dest, $spec] = $args;
|
||||
$quality = (int) ($args[3] ?? 78);
|
||||
|
||||
if ($crop) {
|
||||
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
|
||||
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
||||
'png' => imagecreatefrompng($src),
|
||||
@@ -45,7 +48,6 @@ if (!$image) {
|
||||
$srcW = imagesx($image);
|
||||
$srcH = imagesy($image);
|
||||
|
||||
if ($crop) {
|
||||
[$w, $h] = array_map('intval', explode('x', $spec));
|
||||
$srcRatio = $srcW / $srcH;
|
||||
$dstRatio = $w / $h;
|
||||
@@ -65,12 +67,13 @@ if ($crop) {
|
||||
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";
|
||||
try {
|
||||
$variants = resize_image_variants($src, $dest, array_map('intval', explode(',', $spec)), $quality);
|
||||
} catch (RuntimeException $e) {
|
||||
fwrite(STDERR, $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
echo "{$variant['file']} ({$variant['width']}x{$variant['height']}, " . round($variant['bytes'] / 1024) . " KB)\n";
|
||||
}
|
||||
|
||||
@@ -16,35 +16,42 @@ declare(strict_types=1);
|
||||
* 2. web_profile_info-API (JSON, edges[])
|
||||
* 3. Profil-HTML nach eingebettetem JSON parsen
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
|
||||
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
|
||||
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
|
||||
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$verbose = in_array('-v', $argv, true);
|
||||
$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);
|
||||
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/instagram.log;
|
||||
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
|
||||
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
|
||||
log_write('instagram', $level, $msg);
|
||||
if ($verbose) {
|
||||
echo $line;
|
||||
echo '[' . date('c') . "] {$level} {$msg}\n";
|
||||
}
|
||||
};
|
||||
|
||||
$fail = static function (string $msg) use ($log): never {
|
||||
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
|
||||
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
|
||||
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)');
|
||||
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
|
||||
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// --- HTTP-Helfer mit Browser-typischen Headern ---
|
||||
@@ -203,12 +210,12 @@ foreach ($posts as $post) {
|
||||
if (!is_file($file)) {
|
||||
$img = $fetch($post['display_url']);
|
||||
if ($img === false) {
|
||||
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen");
|
||||
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$src = imagecreatefromstring($img);
|
||||
if ($src === false) {
|
||||
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen");
|
||||
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$w = imagesx($src);
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Logrotation für storage/logs/*.log — NUR CLI, per Cron 1×/Woche.
|
||||
* Logpflege für storage/logs/ — NUR CLI, per Cron 1×/Woche.
|
||||
*
|
||||
* Wird eine Datei größer als das Limit, wandert sie nach <name>.log.1 (eine
|
||||
* vorhandene .1 wird ersetzt) und das aktive Log startet leer. Damit bleiben
|
||||
* höchstens zwei Generationen pro Log liegen — genug zum Nachsehen, ohne dass
|
||||
* spam.log oder php-errors.log auf dem Shared Hosting unbegrenzt wachsen.
|
||||
* Zwei Aufgaben:
|
||||
* 1. Rotation nach Größe: wird eine Datei größer als das Limit, wandert sie nach
|
||||
* <name>.log.1 (eine vorhandene .1 wird ersetzt) und das aktive Log startet leer.
|
||||
* Es bleiben also höchstens zwei Generationen pro Log liegen.
|
||||
* 2. Aufbewahrungsfrist: Einträge, die älter als --keep-days sind, werden aus den
|
||||
* aktiven Logs entfernt; rotierte .1-Dateien jenseits der Frist gelöscht. Damit
|
||||
* gibt es eine echte Löschfrist statt nur einer Größengrenze — auf einer ruhigen
|
||||
* Vereinsseite läge ein Eintrag sonst jahrelang herum.
|
||||
*
|
||||
* Aufruf: php bin/log-rotate.php [--max-mb=2]
|
||||
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
@@ -19,30 +23,108 @@ if (PHP_SAPI !== 'cli') {
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$maxMb = 2.0;
|
||||
$keepDays = 90;
|
||||
$verbose = in_array('-v', $argv, true);
|
||||
foreach ($argv as $arg) {
|
||||
if (preg_match('/^--max-mb=([0-9.]+)$/', $arg, $m) === 1) {
|
||||
$maxMb = (float) $m[1];
|
||||
} elseif (preg_match('/^--keep-days=(\d+)$/', $arg, $m) === 1) {
|
||||
$keepDays = max(1, (int) $m[1]);
|
||||
}
|
||||
}
|
||||
$limit = (int) ($maxMb * 1024 * 1024);
|
||||
$cutoff = time() - $keepDays * 86400;
|
||||
$logDir = STORAGE_PATH . '/logs';
|
||||
|
||||
/**
|
||||
* Zeitstempel am Zeilenanfang: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
|
||||
* (PHP selbst) oder ein nackter ISO-Stempel. Null = keiner.
|
||||
*/
|
||||
$stampOf = static function (string $line): ?int {
|
||||
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
|
||||
$ts = strtotime($m[1]);
|
||||
return $ts === false ? null : $ts;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Einträge vor $cutoff entfernen. Zeilen ohne eigenen Zeitstempel (z. B. PHP-Stacktraces)
|
||||
* gehören zum vorherigen Eintrag und teilen dessen Schicksal. Geschrieben wird über eine
|
||||
* temporäre Datei + rename; ein Append genau in diesem Moment ginge verloren — bei einem
|
||||
* wöchentlichen Lauf nachts ein akzeptables Risiko, dafür bleibt die Datei immer konsistent.
|
||||
*/
|
||||
$prune = static function (string $file) use ($cutoff, $stampOf): int {
|
||||
$raw = (string) @file_get_contents($file);
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
$keep = [];
|
||||
$dropping = false;
|
||||
$removed = 0;
|
||||
foreach (explode("\n", $raw) as $line) {
|
||||
$ts = $stampOf($line);
|
||||
if ($ts !== null) {
|
||||
$dropping = $ts < $cutoff;
|
||||
}
|
||||
if ($dropping) {
|
||||
if (trim($line) !== '') {
|
||||
$removed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$keep[] = $line;
|
||||
}
|
||||
if ($removed === 0) {
|
||||
return 0;
|
||||
}
|
||||
$tmp = $file . '.tmp';
|
||||
if (@file_put_contents($tmp, implode("\n", $keep)) === false || !@rename($tmp, $file)) {
|
||||
@unlink($tmp);
|
||||
return 0;
|
||||
}
|
||||
@chmod($file, 0664);
|
||||
return $removed;
|
||||
};
|
||||
|
||||
$rotated = 0;
|
||||
foreach (glob(STORAGE_PATH . '/logs/*.log') ?: [] as $log) {
|
||||
$size = (int) @filesize($log);
|
||||
if ($size <= $limit) {
|
||||
continue;
|
||||
$pruned = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach (glob($logDir . '/*.log') ?: [] as $log) {
|
||||
$name = basename($log);
|
||||
|
||||
// 1. Alte Einträge raus (vor der Größenprüfung — spart oft die Rotation).
|
||||
$removed = $prune($log);
|
||||
if ($removed > 0) {
|
||||
$pruned += $removed;
|
||||
if ($verbose) {
|
||||
echo "gekürzt: {$name} ({$removed} Einträge älter als {$keepDays} Tage)\n";
|
||||
}
|
||||
if (!@rename($log, $log . '.1')) {
|
||||
fwrite(STDERR, 'Rotation fehlgeschlagen: ' . basename($log) . "\n");
|
||||
continue;
|
||||
}
|
||||
// Neue, leere Datei mit denselben Rechten anlegen, damit PHP/Cron weiterschreiben können.
|
||||
@touch($log);
|
||||
@chmod($log, 0664);
|
||||
echo 'rotiert: ' . basename($log) . ' (' . round($size / 1048576, 1) . " MB)\n";
|
||||
$rotated++;
|
||||
}
|
||||
|
||||
echo $rotated === 0
|
||||
? "Nichts zu rotieren (alle Logs unter {$maxMb} MB).\n"
|
||||
: "{$rotated} Log(s) rotiert.\n";
|
||||
// 2. Rotation nach Größe.
|
||||
$size = (int) @filesize($log);
|
||||
if ($size > $limit) {
|
||||
if (@rename($log, $log . '.1')) {
|
||||
@touch($log);
|
||||
@chmod($log, 0664);
|
||||
echo "rotiert: {$name} (" . round($size / 1048576, 1) . " MB)\n";
|
||||
$rotated++;
|
||||
} else {
|
||||
fwrite(STDERR, "Rotation fehlgeschlagen: {$name}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Rotierte Generationen jenseits der Frist löschen.
|
||||
foreach (glob($logDir . '/*.log.1') ?: [] as $old) {
|
||||
if ((int) @filemtime($old) < $cutoff && @unlink($old)) {
|
||||
echo 'gelöscht: ' . basename($old) . " (älter als {$keepDays} Tage)\n";
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
echo $rotated === 0 && $pruned === 0 && $deleted === 0
|
||||
? "Nichts zu tun (alle Logs unter {$maxMb} MB, keine Einträge älter als {$keepDays} Tage).\n"
|
||||
: "Fertig: {$rotated} rotiert, {$pruned} alte Einträge entfernt, {$deleted} Altdatei(en) gelöscht.\n";
|
||||
|
||||
166
bin/logs.php
Normal file
166
bin/logs.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Logübersicht — NUR CLI. Beantwortet in einem Aufruf: läuft alles, und wenn
|
||||
* nicht, was ist zuletzt schiefgegangen? Ersetzt das Durchklicken von fünf
|
||||
* Dateien per FTP.
|
||||
*
|
||||
* Aufruf:
|
||||
* php bin/logs.php Übersicht + letzte Fehler
|
||||
* php bin/logs.php --lines=20 mehr Zeilen je Kanal
|
||||
* php bin/logs.php --errors nur die gesammelte error.log
|
||||
* php bin/logs.php --channel=mail nur einen Kanal, dafür ausführlich
|
||||
* php bin/logs.php --days=30 anderer Auswertungszeitraum (Default 7)
|
||||
*
|
||||
* Format der eigenen Logs (log_write() in helpers.php):
|
||||
* [2026-07-27T08:11:45+00:00] ERROR Meldung
|
||||
* php-errors.log kommt von PHP selbst und hat ein eigenes Format — es wird
|
||||
* mitgezählt, aber nicht nach Level aufgeschlüsselt.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$lines = 5;
|
||||
$days = 7;
|
||||
$only = '';
|
||||
$errorsOnly = false;
|
||||
foreach ($argv as $arg) {
|
||||
if (preg_match('/^--lines=(\d+)$/', $arg, $m) === 1) {
|
||||
$lines = max(1, (int) $m[1]);
|
||||
} elseif (preg_match('/^--days=(\d+)$/', $arg, $m) === 1) {
|
||||
$days = max(1, (int) $m[1]);
|
||||
} elseif (preg_match('/^--channel=([a-z0-9-]+)$/', $arg, $m) === 1) {
|
||||
$only = $m[1];
|
||||
$lines = max($lines, 20);
|
||||
} elseif ($arg === '--errors') {
|
||||
$errorsOnly = true;
|
||||
$lines = max($lines, 20);
|
||||
}
|
||||
}
|
||||
|
||||
$logDir = STORAGE_PATH . '/logs';
|
||||
$cutoff = time() - $days * 86400;
|
||||
|
||||
/**
|
||||
* Zeitstempel am Zeilenanfang lesen: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
|
||||
* (PHP selbst) oder ein nackter ISO-Stempel ohne Klammern. Null = kein Zeitstempel,
|
||||
* die Zeile gehört dann zum vorherigen Eintrag (z. B. Stacktrace-Fortsetzung).
|
||||
*/
|
||||
$stampOf = static function (string $line): ?int {
|
||||
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
|
||||
$ts = strtotime($m[1]);
|
||||
return $ts === false ? null : $ts;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** @return string[] */
|
||||
$readLines = static function (string $file): array {
|
||||
if (!is_file($file) || filesize($file) === 0) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter(explode("\n", (string) file_get_contents($file)), static fn ($l) => trim($l) !== ''));
|
||||
};
|
||||
|
||||
$ago = static function (?int $ts): string {
|
||||
if ($ts === null) {
|
||||
return '—';
|
||||
}
|
||||
$d = time() - $ts;
|
||||
if ($d < 3600) {
|
||||
return 'vor ' . max(1, (int) round($d / 60)) . ' Min.';
|
||||
}
|
||||
if ($d < 86400) {
|
||||
return 'vor ' . (int) round($d / 3600) . ' Std.';
|
||||
}
|
||||
return 'vor ' . (int) round($d / 86400) . ' Tagen';
|
||||
};
|
||||
|
||||
$size = static function (string $file): string {
|
||||
$b = is_file($file) ? (int) filesize($file) : 0;
|
||||
return $b < 1024 ? "{$b} B" : ($b < 1048576 ? round($b / 1024) . ' KB' : round($b / 1048576, 1) . ' MB');
|
||||
};
|
||||
|
||||
$tail = static function (array $all, int $n): array {
|
||||
return array_slice($all, -$n);
|
||||
};
|
||||
|
||||
echo "TSV 08 — Logübersicht (" . date('d.m.Y H:i') . ", Auswertung der letzten {$days} Tage)\n\n";
|
||||
|
||||
// --- Gesammelte Fehler zuerst: die eine Datei, die zählt ---
|
||||
$errorFile = $logDir . '/error.log';
|
||||
$errorLines = $readLines($errorFile);
|
||||
$recentErrors = array_values(array_filter($errorLines, static fn ($l) => ($stampOf($l) ?? 0) >= $cutoff));
|
||||
|
||||
if ($recentErrors === []) {
|
||||
echo "Keine Fehler in den letzten {$days} Tagen.\n";
|
||||
} else {
|
||||
echo count($recentErrors) . " Fehler in den letzten {$days} Tagen (storage/logs/error.log):\n";
|
||||
foreach ($tail($recentErrors, $lines) as $line) {
|
||||
echo ' ' . $line . "\n";
|
||||
}
|
||||
if (count($recentErrors) > $lines) {
|
||||
echo ' … ' . (count($recentErrors) - $lines) . " weitere\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($errorsOnly) {
|
||||
exit($recentErrors === [] ? 0 : 1);
|
||||
}
|
||||
|
||||
// --- Kanäle ---
|
||||
$files = glob($logDir . '/*.log') ?: [];
|
||||
$channels = [];
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file, '.log');
|
||||
if ($name === 'error' || ($only !== '' && $name !== $only)) {
|
||||
continue;
|
||||
}
|
||||
$channels[$name] = $file;
|
||||
}
|
||||
ksort($channels);
|
||||
|
||||
if ($channels === []) {
|
||||
echo "\nKeine Kanal-Logs gefunden" . ($only !== '' ? " (--channel={$only})" : '') . ".\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
printf("%-14s %8s %-14s %s\n", 'KANAL', 'GRÖSSE', 'LETZTER EINTR.', "INFO/WARN/ERROR ({$days}d)");
|
||||
echo str_repeat('-', 68) . "\n";
|
||||
|
||||
$body = [];
|
||||
foreach ($channels as $name => $file) {
|
||||
$all = $readLines($file);
|
||||
$last = null;
|
||||
$counts = ['INFO' => 0, 'WARN' => 0, 'ERROR' => 0];
|
||||
foreach ($all as $line) {
|
||||
$ts = $stampOf($line);
|
||||
if ($ts !== null) {
|
||||
$last = $ts;
|
||||
}
|
||||
if (($ts ?? 0) >= $cutoff && preg_match('/^\[[^\]]+\]\s+(INFO|WARN|ERROR)\s/', $line, $m) === 1) {
|
||||
$counts[$m[1]]++;
|
||||
}
|
||||
}
|
||||
$level = $name === 'php-errors'
|
||||
? '(eigenes Format)'
|
||||
: "{$counts['INFO']}/{$counts['WARN']}/{$counts['ERROR']}";
|
||||
printf("%-14s %8s %-14s %s\n", $name, $size($file), $ago($last), $level);
|
||||
$body[$name] = $tail($all, $lines);
|
||||
}
|
||||
|
||||
foreach ($body as $name => $tailLines) {
|
||||
echo "\n--- {$name}.log (letzte " . count($tailLines) . ") ---\n";
|
||||
foreach ($tailLines as $line) {
|
||||
echo $line . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nRotation: php bin/log-rotate.php · Deploy-Check: php bin/preflight.php\n";
|
||||
exit($recentErrors === [] ? 0 : 1);
|
||||
@@ -21,14 +21,18 @@ declare(strict_types=1);
|
||||
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
|
||||
* setzt ihn (wie bin/instagram-sync.php).
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
|
||||
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
|
||||
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
|
||||
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$verbose = in_array('-v', $argv, true);
|
||||
$dump = in_array('--dump', $argv, true);
|
||||
$verbose = in_array('-v', $argv ?? [], true);
|
||||
$dump = in_array('--dump', $argv ?? [], true);
|
||||
|
||||
$apiHost = 'https://widget-prod.bfv.de';
|
||||
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
|
||||
@@ -61,16 +65,17 @@ $competitionLabels = [
|
||||
'Hallenrunde' => 'Halle',
|
||||
];
|
||||
|
||||
$log = static function (string $msg) use ($verbose): void {
|
||||
$line = '[' . date('c') . "] {$msg}\n";
|
||||
file_put_contents(STORAGE_PATH . '/logs/matchcenter.log', $line, FILE_APPEND);
|
||||
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/matchcenter.log;
|
||||
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
|
||||
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
|
||||
log_write('matchcenter', $level, $msg);
|
||||
if ($verbose) {
|
||||
echo $line;
|
||||
echo '[' . date('c') . "] {$level} {$msg}\n";
|
||||
}
|
||||
};
|
||||
|
||||
$fail = static function (string $msg) use ($log): never {
|
||||
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
|
||||
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
|
||||
exit(1);
|
||||
};
|
||||
|
||||
@@ -81,7 +86,9 @@ if ($teamsCfg === []) {
|
||||
// --- Lock gegen parallele Läufe ---
|
||||
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
$fail('Läuft bereits (Lock belegt)');
|
||||
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
|
||||
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
|
||||
@@ -115,7 +122,7 @@ $fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
|
||||
return null;
|
||||
}
|
||||
if ($dump) {
|
||||
$log("DUMP {$url}\n" . substr($body, 0, 4000));
|
||||
echo "DUMP {$url}\n" . substr($body, 0, 4000) . "\n";
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
return is_array($data) ? $data : null;
|
||||
@@ -139,12 +146,12 @@ $downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $cre
|
||||
}
|
||||
$bin = $fetch($crestBase . $clubId, false);
|
||||
if ($bin === false) {
|
||||
$log("Wappen-Download fehlgeschlagen: {$clubId}");
|
||||
$log("Wappen-Download fehlgeschlagen: {$clubId}", 'WARN');
|
||||
return null;
|
||||
}
|
||||
$src = @imagecreatefromstring($bin);
|
||||
if ($src === false) {
|
||||
$log("Wappen unlesbar: {$clubId}");
|
||||
$log("Wappen unlesbar: {$clubId}", 'WARN');
|
||||
return null;
|
||||
}
|
||||
$w = imagesx($src);
|
||||
@@ -277,7 +284,7 @@ foreach ($teamsCfg as $cfg) {
|
||||
$teamCfgId = (string) ($cfg['team_id'] ?? '');
|
||||
$cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
|
||||
if ($key === '' || $teamCfgId === '') {
|
||||
$log("Team-Eintrag ohne key/team_id übersprungen");
|
||||
$log("Team-Eintrag ohne key/team_id übersprungen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$prevTeam = $prevByKey[$key] ?? null;
|
||||
@@ -325,6 +332,13 @@ foreach ($teamsCfg as $cfg) {
|
||||
$upcoming = [];
|
||||
$results = [];
|
||||
foreach ($matches as $m) {
|
||||
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
|
||||
// "SPIELFREI" — kein echtes Spiel, gehört weder in "Nächste
|
||||
// Spiele" noch in Ergebnisse (tauchte sonst bis in den
|
||||
// Startseiten-Hero und die Stadionzeitung durch).
|
||||
if (($m['homeTeamName'] ?? '') === 'SPIELFREI' || ($m['guestTeamName'] ?? '') === 'SPIELFREI') {
|
||||
continue;
|
||||
}
|
||||
$hasResult = trim((string) ($m['result'] ?? '')) !== '';
|
||||
$row = $mapMatch($m, $teamCfgId);
|
||||
if ($hasResult) {
|
||||
@@ -360,7 +374,7 @@ foreach ($teamsCfg as $cfg) {
|
||||
$cacheFallbackUsed = true;
|
||||
$keepCrests($upcoming);
|
||||
$keepCrests($results);
|
||||
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen");
|
||||
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen", 'WARN');
|
||||
}
|
||||
|
||||
// --- Tabellen-Teil ---
|
||||
@@ -406,7 +420,7 @@ foreach ($teamsCfg as $cfg) {
|
||||
$table = $prevTeam['table'] ?? [];
|
||||
$cacheFallbackUsed = true;
|
||||
$keepCrests($table);
|
||||
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen");
|
||||
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen", 'WARN');
|
||||
}
|
||||
|
||||
// Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn
|
||||
@@ -416,7 +430,7 @@ foreach ($teamsCfg as $cfg) {
|
||||
$hasPrevious = $previous !== null
|
||||
&& (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []);
|
||||
if (!$hasCurrent && $matchesData === null && !$hasPrevious) {
|
||||
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen");
|
||||
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -448,7 +462,7 @@ foreach ($teamsCfg as $cfg) {
|
||||
. ($hasPrevious ? ' (+ Vorsaison archiviert)' : ''));
|
||||
} catch (\Throwable $e) {
|
||||
// Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
|
||||
$log("Team {$key} übersprungen: " . $e->getMessage());
|
||||
$log("Team {$key} übersprungen: " . $e->getMessage(), 'WARN');
|
||||
if ($prevTeam !== null) {
|
||||
$resultTeams[] = $prevTeam;
|
||||
$cacheFallbackUsed = true;
|
||||
|
||||
@@ -37,6 +37,21 @@ if (str_ends_with($baseUrl, '/')) {
|
||||
$warnings[] = 'base_url endet auf "/" — abs_url() erzeugt dann doppelte Slashes.';
|
||||
}
|
||||
|
||||
// base_url MUSS der Host sein, den public/.htaccess erzwingt. Läuft es
|
||||
// auseinander, zeigen Canonical, OG und Sitemap auf Adressen, die der Server
|
||||
// sofort per 301 weiterleitet — und gedruckte QR-Codes bekommen einen
|
||||
// unnötigen Umweg eingebacken. Genau dieser Fehler stand schon einmal drin
|
||||
// (base_url ohne www, .htaccess mit).
|
||||
$htaccess = PUBLIC_PATH . '/.htaccess';
|
||||
if (is_file($htaccess)
|
||||
&& preg_match('~RewriteRule \^ https://([a-z0-9.-]+)%\{REQUEST_URI\}~i', (string) file_get_contents($htaccess), $forced) === 1) {
|
||||
$forcedHost = strtolower($forced[1]);
|
||||
$configHost = strtolower((string) parse_url($baseUrl, PHP_URL_HOST));
|
||||
if ($configHost !== '' && $configHost !== $forcedHost && !str_contains($configHost, 'localhost')) {
|
||||
$errors[] = "base_url nutzt '{$configHost}', public/.htaccess leitet aber alles auf '{$forcedHost}' um — Canonical, OG und Sitemap zeigten auf weitergeleitete Adressen.";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abhängigkeiten ---
|
||||
if (!is_file(ROOT_PATH . '/vendor/autoload.php')) {
|
||||
$errors[] = 'vendor/ fehlt — auf dem Server "composer install --no-dev --optimize-autoloader" laufen lassen.';
|
||||
@@ -64,6 +79,86 @@ foreach (['img/instagram', 'img/crests'] as $dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dateiablage /dateien (Files Gallery) ---
|
||||
// Die App muss in ihr Upload- und Storage-Verzeichnis schreiben können, sonst bricht
|
||||
// schon der erste Aufruf ab (sie legt Cache-Verzeichnisse selbst an).
|
||||
foreach (['uploads', 'system'] as $dir) {
|
||||
$path = STORAGE_PATH . '/dateien/' . $dir;
|
||||
if (!is_dir($path)) {
|
||||
$errors[] = "storage/dateien/{$dir}/ fehlt — anlegen und beschreibbar machen.";
|
||||
} elseif (!is_writable($path)) {
|
||||
$errors[] = "storage/dateien/{$dir}/ ist nicht beschreibbar — Uploads und Thumbnails scheitern.";
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne echten Hash ist der Zugang gesperrt (fail closed) — das soll auffallen, bevor
|
||||
// jemand vor dem Login-Formular steht und sich wundert.
|
||||
if (config('dateien') === null) {
|
||||
$errors[] = "Der Block 'dateien' fehlt in config/config.php — die Dateiablage hätte keine Zugänge (siehe config.example.php).";
|
||||
} else {
|
||||
foreach (['verein', 'admin'] as $user) {
|
||||
$hash = (string) config("dateien.users.{$user}.password_hash", '');
|
||||
if ($hash === '' || $hash === 'CHANGE_ME') {
|
||||
$errors[] = "dateien.users.{$user}.password_hash fehlt — dieser Zugang ist gesperrt.";
|
||||
} elseif (password_get_info($hash)['algoName'] === 'unknown') {
|
||||
$warnings[] = "dateien.users.{$user}.password_hash sieht wie Klartext aus — besser hashen: php -r \"echo password_hash('…', PASSWORD_DEFAULT);\"";
|
||||
}
|
||||
}
|
||||
if (in_array((string) config('dateien.license_key', ''), ['', 'CHANGE_ME'], true)) {
|
||||
$warnings[] = 'dateien.license_key fehlt — die Dateiablage läuft, zeigt aber den Hinweis der unlizenzierten Version.';
|
||||
}
|
||||
}
|
||||
|
||||
// Der Patch und die self-hosted Assets entscheiden darüber, ob die App überhaupt
|
||||
// mit Login startet und ob sie ohne CDN auskommt. Details: bin/filesgallery-update.php
|
||||
$galleryApp = PUBLIC_PATH . '/dateien/index.php';
|
||||
if (!is_file($galleryApp)) {
|
||||
$errors[] = 'public/dateien/index.php fehlt — die Dateiablage ist nicht ausgeliefert.';
|
||||
} else {
|
||||
$source = (string) file_get_contents($galleryApp);
|
||||
if (!str_contains($source, "\$localconfigpath = __DIR__ . '/_filesconfig.php'")) {
|
||||
$errors[] = 'public/dateien/index.php ist ungepatcht — die App würde OHNE Login starten. php bin/filesgallery-update.php';
|
||||
}
|
||||
// Wer Einstellungen direkt in die vendorte Datei schreibt, verliert sie beim nächsten
|
||||
// Update — und Secrets hätten dort ohnehin nichts zu suchen (Regel 5). Die App warnt
|
||||
// selbst davor (index.php:33), also fangen wir es hier ab.
|
||||
if (preg_match('/^\s*\'(license_key|username|password)\' => \'.+\',/m', $source, $edited) === 1) {
|
||||
$errors[] = "public/dateien/index.php wurde direkt editiert ('{$edited[1]}') — Wert nach config/config.php (dateien.*) verschieben und die Zeile leeren, sonst ist er nach dem nächsten Update weg.";
|
||||
}
|
||||
|
||||
$version = preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : '?';
|
||||
if (!is_file(PUBLIC_PATH . "/assets/filesgallery/files.photo.gallery@{$version}/css/files.css")) {
|
||||
$errors[] = "Assets der Dateiablage fehlen (Version {$version}) — sonst lädt sie vom CDN und die CSP blockt alles: php bin/filesgallery-update.php --assets";
|
||||
}
|
||||
}
|
||||
|
||||
// Es gilt immer die kleinere von zwei Grenzen: unsere aus _filesconfig.php und die von
|
||||
// PHP. Steht PHP niedriger, wird jedes größere Video abgewiesen, ohne dass man es
|
||||
// unserer Konfiguration ansehen könnte — deshalb hier vergleichen, nicht nur ausgeben.
|
||||
$phpUpload = ini_parse_quantity((string) ini_get('upload_max_filesize'));
|
||||
$phpPost = ini_parse_quantity((string) ini_get('post_max_size'));
|
||||
// Bewusst gelesen und nicht ausgeführt: _filesconfig.php setzt display_errors und
|
||||
// Session-Parameter — Nebenwirkungen, die in diesem Skript nichts zu suchen haben.
|
||||
$ourMax = 0;
|
||||
$galleryConfigFile = PUBLIC_PATH . '/dateien/_filesconfig.php';
|
||||
if (is_file($galleryConfigFile)
|
||||
&& preg_match("/'upload_max_filesize'\s*=>\s*(\d+)/", (string) file_get_contents($galleryConfigFile), $um) === 1) {
|
||||
$ourMax = (int) $um[1];
|
||||
}
|
||||
$mb = static fn (int|float $bytes): string => round($bytes / 1048576) . ' MB';
|
||||
|
||||
echo 'INFO Uploadgrenze: ' . ($ourMax > 0 ? $mb($ourMax) : 'unbekannt')
|
||||
. " laut _filesconfig.php, diese PHP-CLI erlaubt {$mb($phpUpload)} (post_max_size {$mb($phpPost)})\n";
|
||||
|
||||
if ($ourMax > 0 && $phpUpload < $ourMax) {
|
||||
// Achtung: das hier ist die CLI-Konfiguration. Für Uploads zählt die des Webservers
|
||||
// (FPM + .user.ini), die abweichen darf — deshalb Warnung und nicht Fehler.
|
||||
$warnings[] = "Diese PHP-CLI deckelt Uploads auf {$mb($phpUpload)}, unsere Config erlaubt {$mb($ourMax)}. Für Uploads zählt aber die Webserver-Konfiguration: public/dateien/.user.ini setzt {$mb($ourMax)}. Verbindlich prüfen mit einem echten großen Upload über /dateien (oder phpinfo() im Browser).";
|
||||
}
|
||||
if ($phpPost <= $phpUpload) {
|
||||
$warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung.";
|
||||
}
|
||||
|
||||
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
|
||||
foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
|
||||
$file = DATA_PATH . '/' . $name . '.json';
|
||||
@@ -74,6 +169,133 @@ foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchce
|
||||
}
|
||||
}
|
||||
|
||||
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
|
||||
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
|
||||
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
|
||||
// Steht es in beiden, taucht es doppelt in der Termine-Liste auf und /termine
|
||||
// baut zwei Event-Knoten für dasselbe Fest — beides fällt im Alltag kaum auf,
|
||||
// deshalb prüft es die Maschine.
|
||||
$eventSlugs = array_filter(array_column(json_load('events')['events'] ?? [], 'slug'));
|
||||
$veranstaltungRows = json_load('veranstaltungen')['veranstaltungen'] ?? [];
|
||||
$veranstaltungSlugs = array_column($veranstaltungRows, 'slug');
|
||||
|
||||
$doppelt = array_intersect($eventSlugs, $veranstaltungSlugs);
|
||||
if ($doppelt !== []) {
|
||||
$errors[] = 'Diese Slugs stehen in data/events.json UND data/veranstaltungen.json: '
|
||||
. implode(', ', $doppelt) . ' — der Termin erscheint doppelt. Eintrag in events.json löschen.';
|
||||
}
|
||||
|
||||
if (count($veranstaltungSlugs) !== count(array_unique($veranstaltungSlugs))) {
|
||||
$errors[] = 'data/veranstaltungen.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
|
||||
}
|
||||
|
||||
foreach ($veranstaltungRows as $i => $row) {
|
||||
if (empty($row['slug']) || empty($row['title']) || empty($row['date_start'])) {
|
||||
$errors[] = "data/veranstaltungen.json, Eintrag #{$i}: slug, title und date_start sind Pflicht.";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Formular-Empfänger auf eigener Domain ---
|
||||
// app/pages/datenschutz.php sagt zu, dass wir "keinen zusätzlichen
|
||||
// E-Mail-Dienstleister" einsetzen. Das stimmt nur, solange jeder Empfänger ein
|
||||
// Postfach auf unserer Domain bei All-Inkl ist. Zeigt club.json auf einen
|
||||
// fremden Anbieter (die Turnabteilung lief früher auf Gmail), muss die
|
||||
// Datenschutzerklärung mit — und das fällt von allein nie auf.
|
||||
$clubMails = array_filter(
|
||||
json_load('club'),
|
||||
static fn ($v, string $k): bool => str_starts_with($k, 'email') && is_string($v) && $v !== '',
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
foreach ($clubMails as $key => $address) {
|
||||
if (!str_ends_with(strtolower((string) $address), '@tsv08kulmbach.de')) {
|
||||
$warnings[] = "club.json → {$key} ({$address}) liegt nicht auf tsv08kulmbach.de."
|
||||
. ' Formular-Mails an diese Adresse verlassen die All-Inkl-Auftragsverarbeitung —'
|
||||
. ' dann muss app/pages/datenschutz.php den Anbieter nennen.';
|
||||
}
|
||||
}
|
||||
|
||||
// --- News (data/news.json) ---
|
||||
// Meldungen mit `artikel` haben eine eigene Seite /news/<slug>. Die Fehler, die
|
||||
// dabei still ins Nichts laufen, prüft die Maschine: ein Artikel ohne Slug ist
|
||||
// nicht erreichbar, ein doppelter Slug verdeckt den zweiten Artikel, ein
|
||||
// unbekannter Blocktyp verschluckt seinen Inhalt beim Rendern.
|
||||
$newsRows = json_load('news')['news'] ?? [];
|
||||
$newsSlugs = array_values(array_filter(array_column($newsRows, 'slug')));
|
||||
$newsBlockTypen = ['text', 'zwischentitel', 'bild', 'zitat'];
|
||||
|
||||
if (count($newsSlugs) !== count(array_unique($newsSlugs))) {
|
||||
$errors[] = 'data/news.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
|
||||
}
|
||||
|
||||
// Kollision mit einem exakten Routen-Slug: der Prefix-Match für 'news/' läuft in
|
||||
// public/index.php VOR dem routes.php-Lookup. Ein künftiger Eintrag wie
|
||||
// 'news/archiv' wäre also von der Detailseite verdeckt, und die Ursache dafür
|
||||
// sieht man an der weißen Seite nicht.
|
||||
$routeSlugs = array_keys(require APP_PATH . '/routes.php');
|
||||
$verdeckt = array_intersect(array_map(static fn (string $s): string => 'news/' . $s, $newsSlugs), $routeSlugs);
|
||||
if ($verdeckt !== []) {
|
||||
$errors[] = 'Diese News-Slugs verdecken einen Routen-Eintrag: ' . implode(', ', $verdeckt)
|
||||
. ' — der Prefix-Match greift vor routes.php. Slug der Meldung ändern.';
|
||||
}
|
||||
|
||||
$gleichnamig = array_intersect($newsSlugs, $veranstaltungSlugs);
|
||||
if ($gleichnamig !== []) {
|
||||
$warnings[] = 'Diese Slugs stehen in data/news.json UND data/veranstaltungen.json: '
|
||||
. implode(', ', $gleichnamig) . ' — die URLs kollidieren nicht (verschiedene Präfixe), aber es sind'
|
||||
. ' wahrscheinlich zwei Seiten für dieselbe Sache.';
|
||||
}
|
||||
|
||||
foreach ($newsRows as $i => $row) {
|
||||
if (empty($row['title']) || empty($row['date'])) {
|
||||
$errors[] = "data/news.json, Eintrag #{$i}: title und date sind Pflicht.";
|
||||
}
|
||||
if (!empty($row['date']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $row['date'])) {
|
||||
// Der Feed sortiert Datumsangaben als Strings und german_date() fällt
|
||||
// still auf den Rohwert zurück — ein abweichendes Format sortiert also
|
||||
// falsch, ohne dass etwas kaputt aussieht.
|
||||
$errors[] = "data/news.json, Eintrag #{$i}: date muss YYYY-MM-DD sein.";
|
||||
}
|
||||
if (empty($row['artikel'])) {
|
||||
continue; // Kurzmeldung ohne Detailseite — ein Slug ist hier erlaubt, aber nicht nötig.
|
||||
}
|
||||
if (empty($row['slug'])) {
|
||||
$errors[] = "data/news.json, Eintrag #{$i}: mit artikel[] braucht die Meldung einen slug, sonst ist die Detailseite nicht erreichbar.";
|
||||
} elseif (!preg_match('/^[a-z0-9-]+$/', (string) $row['slug'])) {
|
||||
$errors[] = "data/news.json, Eintrag #{$i}: slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.";
|
||||
}
|
||||
foreach ($row['artikel'] as $b => $block) {
|
||||
$typ = (string) ($block['typ'] ?? '');
|
||||
if (!in_array($typ, $newsBlockTypen, true)) {
|
||||
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: unbekannter typ '{$typ}' — erlaubt sind "
|
||||
. implode(', ', $newsBlockTypen) . '. Der Block würde beim Rendern verschluckt.';
|
||||
continue;
|
||||
}
|
||||
if ($typ === 'bild' && empty($block['alt'])) {
|
||||
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: Bild ohne alt-Text.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cron-Endpoint (public/cron.php) ---
|
||||
// Nur relevant, wenn der Tarif keine Cronjobs hat und der Takt von außen kommt.
|
||||
// Ohne brauchbaren Schlüssel ist der Endpoint zu (403) und nichts synct — das fällt
|
||||
// sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
|
||||
$cronKey = (string) config('cron.key', '');
|
||||
$cronInterval = (int) config('cron.min_interval', 240);
|
||||
if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
|
||||
$warnings[] = 'cron.key ist nicht gesetzt — public/cron.php weist jeden Aufruf ab. Nur in Ordnung, wenn der Takt über echte Shell-Cronjobs läuft.';
|
||||
} else {
|
||||
if (strlen($cronKey) < 24) {
|
||||
$errors[] = 'cron.key ist kürzer als 24 Zeichen — public/cron.php verweigert den Dienst. Erzeugen: php -r \'echo bin2hex(random_bytes(24)), "\n";\'';
|
||||
}
|
||||
if ($cronKey === (string) config('app_secret')) {
|
||||
$errors[] = 'cron.key ist identisch mit app_secret — der Cron-Schlüssel liegt bei einem externen Dienst und darf nicht die Formular-Token aufschließen.';
|
||||
}
|
||||
if ($cronInterval >= 1800) {
|
||||
$warnings[] = "cron.min_interval ist {$cronInterval} s — bei halbstündlichem Takt am Spieltag würde jeder zweite Aufruf verworfen.";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ausgabe ---
|
||||
foreach ($warnings as $w) {
|
||||
echo "WARNUNG {$w}\n";
|
||||
|
||||
78
bin/qr.php
Normal file
78
bin/qr.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* QR-Code für DRUCKSACHEN erzeugen (nur CLI).
|
||||
*
|
||||
* php bin/qr.php <pfad|url> [ziel.png] [--scale=24]
|
||||
*
|
||||
* Beispiele:
|
||||
* php bin/qr.php stadionzeitung/aktuell
|
||||
* php bin/qr.php stadionzeitung/aktuell ~/Desktop/qr-plakat.png --scale=30
|
||||
* php bin/qr.php https://tsv08kulmbach.de/stadionzeitung/aktuell
|
||||
*
|
||||
* Abgrenzung zu den QR-Codes der Stadionzeitung: die erzeugt
|
||||
* bin/stadionzeitung-add.php selbst, klein und für die A5-Seiten
|
||||
* (generate_qr_png() Default-Skalierung, gut 200px). Dieses Skript ist für
|
||||
* Plakate und Aushänge, die in Canva zusammengebaut werden — dort braucht es
|
||||
* ein Vielfaches an Pixeln, sonst franst der Code im Druck aus.
|
||||
*
|
||||
* DIE EIGENTLICHE AUFGABE DIESES SKRIPTS IST DIE WARNUNG: ein QR-Code enthält
|
||||
* die URL fest eingebacken. Wird er gegen die Entwicklungs-Config erzeugt, steht
|
||||
* `http://localhost:8000/…` darin und das gedruckte Plakat ist Altpapier. Ein
|
||||
* interner Pfad läuft deshalb durch abs_url() (Single Source: config base_url),
|
||||
* und wenn die nicht auf https zeigt, sagt das Skript es laut.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => !str_starts_with($a, '--')));
|
||||
$scale = 24;
|
||||
foreach ($argv as $arg) {
|
||||
if (str_starts_with($arg, '--scale=')) {
|
||||
$scale = max(1, (int) substr($arg, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if ($args === []) {
|
||||
fwrite(STDERR, "Nutzung: php bin/qr.php <pfad|url> [ziel.png] [--scale=24]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$target = $args[0];
|
||||
$isAbsolute = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
|
||||
$url = $isAbsolute ? $target : abs_url(trim($target, '/'));
|
||||
|
||||
// Zielpfad: ohne Angabe neben das Skript-Verzeichnis in storage/qr/ ablegen —
|
||||
// Druckmaterial ist kein Website-Asset und hat unter public/ nichts zu suchen.
|
||||
$dest = $args[1] ?? null;
|
||||
if ($dest === null) {
|
||||
$dir = STORAGE_PATH . '/qr';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$name = preg_replace('/[^a-z0-9]+/', '-', strtolower($isAbsolute ? (string) parse_url($url, PHP_URL_PATH) : $target)) ?? 'qr';
|
||||
$dest = $dir . '/qr-' . trim($name, '-') . '.png';
|
||||
}
|
||||
|
||||
if (!$isAbsolute && !str_starts_with((string) config('base_url'), 'https://')) {
|
||||
fwrite(STDERR, "WARNUNG base_url ist '" . (string) config('base_url') . "' — dieser QR-Code führt NICHT auf die echte Seite\n");
|
||||
fwrite(STDERR, " und ist als Druckvorlage unbrauchbar. Entweder die Produktions-Config verwenden\n");
|
||||
fwrite(STDERR, " oder die vollständige URL direkt übergeben:\n");
|
||||
fwrite(STDERR, " php bin/qr.php https://tsv08kulmbach.de/{$target}\n\n");
|
||||
}
|
||||
|
||||
if (!generate_qr_png($url, $dest, $scale)) {
|
||||
fwrite(STDERR, "FEHLER QR-Code konnte nicht erzeugt werden (vendor-manual/phpqrcode vorhanden?)\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
[$w, $h] = getimagesize($dest) ?: [0, 0];
|
||||
echo "QR-Code erzeugt\n";
|
||||
echo " Ziel-URL : {$url}\n";
|
||||
echo " Datei : {$dest}\n";
|
||||
echo " Größe : {$w}x{$h} px (bei 300 dpi etwa " . round($w / 300 * 2.54, 1) . " cm)\n";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user