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,7 +14,7 @@
|
||||
|
||||
var MESSAGES = {
|
||||
success: 'Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.',
|
||||
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.',
|
||||
mail: 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut.'
|
||||
|
||||
151
public/assets/js/lightbox.js
Normal file
151
public/assets/js/lightbox.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/* Lightbox: rüstet eine Bildergalerie ([data-lightbox-gallery]) von normalen
|
||||
Bildlinks auf eine Großansicht im Overlay um. Ohne JS bleibt jedes
|
||||
Vorschaubild ein Link auf die größte Variante der Datei — der Klick führt
|
||||
dann einfach zum Bild, nichts ist kaputt oder tot.
|
||||
|
||||
Bewusst generisch gehalten (keine „veranstaltung"-Bezüge, Hooks nur über
|
||||
data-lightbox-*), damit eine zweite Galerie auf der Seite dieselbe Datei
|
||||
nutzen kann — dasselbe Prinzip wie beim generischen carousel.js.
|
||||
|
||||
Das Overlay-Markup liefert PHP und ist `hidden`, bis JS es übernimmt (wie die
|
||||
Steuerung des Stadionzeitungs-Viewers). Die Großansicht erbt ihr srcset vom
|
||||
angeklickten Vorschaubild: so lädt ein Handy die kleine Variante und es gibt
|
||||
nur EINE Bildliste im Markup. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function initGallery(root) {
|
||||
var links = Array.prototype.slice.call(root.querySelectorAll('[data-lightbox]'));
|
||||
var dialog = root.querySelector('[data-lightbox-dialog]');
|
||||
|
||||
if (!dialog || links.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var image = dialog.querySelector('[data-lightbox-img]');
|
||||
var counter = dialog.querySelector('[data-lightbox-current]');
|
||||
var closeBtn = dialog.querySelector('[data-lightbox-close]');
|
||||
var prevBtn = dialog.querySelector('[data-lightbox-prev]');
|
||||
var nextBtn = dialog.querySelector('[data-lightbox-next]');
|
||||
|
||||
if (!image || !closeBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
var isOpen = false;
|
||||
|
||||
function show(i) {
|
||||
index = Math.max(0, Math.min(links.length - 1, i));
|
||||
var thumb = links[index].querySelector('img');
|
||||
|
||||
image.src = links[index].getAttribute('href');
|
||||
image.srcset = thumb ? thumb.getAttribute('srcset') || '' : '';
|
||||
image.alt = thumb ? thumb.getAttribute('alt') || '' : '';
|
||||
|
||||
if (counter) {
|
||||
counter.textContent = String(index + 1);
|
||||
}
|
||||
if (prevBtn) {
|
||||
prevBtn.disabled = index === 0;
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.disabled = index === links.length - 1;
|
||||
}
|
||||
// Am ersten/letzten Bild wird der gerade fokussierte Knopf disabled
|
||||
// und der Fokus fiele auf <body> — dann griffe die Fokusfalle unten
|
||||
// nicht mehr und Tab liefe hinter das Overlay.
|
||||
if (document.activeElement && document.activeElement.disabled) {
|
||||
closeBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
show(index - 1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
show(index + 1);
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fokusfalle: im Overlay sind nur diese Knöpfe erreichbar.
|
||||
var focusable = [prevBtn, nextBtn, closeBtn].filter(function (btn) {
|
||||
return btn && !btn.disabled;
|
||||
});
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function reveal(i) {
|
||||
show(i);
|
||||
dialog.hidden = false;
|
||||
// Hintergrund festhalten, sonst scrollt die Seite unter dem Overlay
|
||||
// weg (gleiches Muster wie body.nav-open beim Burger-Menü).
|
||||
document.body.classList.add('lightbox-open');
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
isOpen = true;
|
||||
closeBtn.focus();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
dialog.hidden = true;
|
||||
document.body.classList.remove('lightbox-open');
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
isOpen = false;
|
||||
// Fokus zurück auf das ZULETZT gezeigte Vorschaubild, nicht auf das
|
||||
// zuerst angeklickte: nach mehrmals Weiter ist das die Stelle, an
|
||||
// der man im Raster gerade steht.
|
||||
links[index].focus();
|
||||
}
|
||||
|
||||
links.forEach(function (link, i) {
|
||||
link.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
reveal(i);
|
||||
});
|
||||
});
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () { show(index - 1); });
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () { show(index + 1); });
|
||||
}
|
||||
closeBtn.addEventListener('click', hide);
|
||||
|
||||
dialog.addEventListener('click', function (event) {
|
||||
// Klick auf die Fläche neben dem Bild schließt; Klicks auf Bild und
|
||||
// Knöpfe nicht.
|
||||
if (event.target === dialog) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
root.classList.add('is-enhanced');
|
||||
}
|
||||
|
||||
Array.prototype.slice.call(document.querySelectorAll('[data-lightbox-gallery]')).forEach(initGallery);
|
||||
})();
|
||||
45
public/assets/js/news-filter.js
Normal file
45
public/assets/js/news-filter.js
Normal file
@@ -0,0 +1,45 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Filtert den News-Feed (/news) nach Quelle (Meldung/Ergebnis/Instagram).
|
||||
// Ohne JS sind alle Einträge sichtbar (Default-Filter "all"), der
|
||||
// Empty-State bleibt verborgen — die Tabs sind reine Progressive
|
||||
// Enhancement. Gleiches Muster wie position-filter.js (/mitmachen).
|
||||
function init(bar) {
|
||||
var section = bar.closest('section');
|
||||
if (!section) { return; }
|
||||
|
||||
var buttons = [].slice.call(bar.querySelectorAll('[data-filter]'));
|
||||
var entries = [].slice.call(section.querySelectorAll('[data-source]'));
|
||||
var empty = section.querySelector('[data-news-empty]');
|
||||
if (!buttons.length || !entries.length) { return; }
|
||||
|
||||
function apply(filter) {
|
||||
var visible = 0;
|
||||
entries.forEach(function (entry) {
|
||||
var match = filter === 'all' || entry.getAttribute('data-source') === filter;
|
||||
entry.hidden = !match;
|
||||
if (match) { visible++; }
|
||||
});
|
||||
if (empty) { empty.hidden = visible !== 0; }
|
||||
}
|
||||
|
||||
buttons.forEach(function (btn, i) {
|
||||
btn.addEventListener('click', function () {
|
||||
buttons.forEach(function (b) { b.setAttribute('aria-pressed', 'false'); });
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
apply(btn.getAttribute('data-filter'));
|
||||
});
|
||||
btn.addEventListener('keydown', function (e) {
|
||||
var next = null;
|
||||
if (e.key === 'ArrowRight') { next = (i + 1) % buttons.length; }
|
||||
if (e.key === 'ArrowLeft') { next = (i - 1 + buttons.length) % buttons.length; }
|
||||
if (e.key === 'Home') { next = 0; }
|
||||
if (e.key === 'End') { next = buttons.length - 1; }
|
||||
if (next !== null) { e.preventDefault(); buttons[next].focus(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-news-filter]').forEach(init);
|
||||
}());
|
||||
66
public/assets/js/persons-tabs.js
Normal file
66
public/assets/js/persons-tabs.js
Normal file
@@ -0,0 +1,66 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Tab-Umschaltung der Ansprechpartner-Bereiche (Fußball/Turnen). Ohne JS
|
||||
// bleibt die Leiste hidden und beide Sektionen stehen untereinander —
|
||||
// reine Progressive Enhancement (gleiches Muster wie news-filter.js).
|
||||
// Deep-Links (#kontakt-turnen) aktivieren den passenden Tab.
|
||||
function init(root) {
|
||||
var bar = root.querySelector('[data-persons-tabs-bar]');
|
||||
if (!bar) { return; }
|
||||
|
||||
var buttons = [].slice.call(bar.querySelectorAll('[data-tab-target]'));
|
||||
if (buttons.length < 2) { return; }
|
||||
|
||||
var sections = buttons.map(function (btn) {
|
||||
return root.querySelector('#' + btn.getAttribute('data-tab-target'));
|
||||
});
|
||||
if (sections.some(function (s) { return !s; })) { return; }
|
||||
|
||||
// Sichtbare Sektions-Überschriften weichen den Tabs (bleiben für
|
||||
// Screenreader/Outline als visually-hidden erhalten).
|
||||
root.querySelectorAll('.persons h2').forEach(function (h) {
|
||||
h.classList.add('visually-hidden');
|
||||
});
|
||||
|
||||
function activate(index, writeHash) {
|
||||
buttons.forEach(function (btn, i) {
|
||||
btn.setAttribute('aria-pressed', i === index ? 'true' : 'false');
|
||||
sections[i].hidden = i !== index;
|
||||
});
|
||||
// Aktiven Bereich in die URL spiegeln (replaceState statt Hash-
|
||||
// Zuweisung: kein Scroll-Sprung, kein History-Eintrag pro Klick) —
|
||||
// #kontakt-turnen ist damit direkt teilbar.
|
||||
if (writeHash) {
|
||||
window.history.replaceState(null, '', '#' + buttons[index].getAttribute('data-tab-target'));
|
||||
}
|
||||
}
|
||||
|
||||
buttons.forEach(function (btn, i) {
|
||||
btn.addEventListener('click', function () { activate(i, true); });
|
||||
btn.addEventListener('keydown', function (e) {
|
||||
var next = null;
|
||||
if (e.key === 'ArrowRight') { next = (i + 1) % buttons.length; }
|
||||
if (e.key === 'ArrowLeft') { next = (i - 1 + buttons.length) % buttons.length; }
|
||||
if (e.key === 'Home') { next = 0; }
|
||||
if (e.key === 'End') { next = buttons.length - 1; }
|
||||
if (next !== null) { e.preventDefault(); buttons[next].focus(); }
|
||||
});
|
||||
});
|
||||
|
||||
// Deep-Link auf eine Sektion (#kontakt-turnen) → passenden Tab öffnen.
|
||||
function fromHash() {
|
||||
var id = window.location.hash.replace('#', '');
|
||||
var index = buttons.findIndex(function (btn) {
|
||||
return btn.getAttribute('data-tab-target') === id;
|
||||
});
|
||||
activate(index >= 0 ? index : 0);
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', fromHash);
|
||||
bar.hidden = false;
|
||||
fromHash();
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-persons-tabs]').forEach(init);
|
||||
}());
|
||||
22
public/assets/js/stadionzeitung-druck.js
Normal file
22
public/assets/js/stadionzeitung-druck.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/* Druckfassung der Stadionzeitung: blendet den „Jetzt drucken"-Button ein und
|
||||
bindet ihn an den Browser-Druckdialog. Progressive enhancement — ohne JS
|
||||
bleibt der Button [hidden] und der Dialog kommt wie immer über Strg/Cmd+P,
|
||||
die Druckansicht selbst ist reines server-gerendertes HTML+CSS. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* Alle lazy geladenen Bilder sofort laden: Chrome druckt Bilder, die nie
|
||||
im Sichtfeld waren, sonst als leere Flächen (Wappen, QR, Porträts —
|
||||
die großen Seiten-Bilder rendert PHP in der Druckfassung schon eager,
|
||||
das hier fängt die kleinen Bilder aus den geteilten Komponenten). */
|
||||
Array.prototype.slice.call(document.querySelectorAll('img[loading="lazy"]')).forEach(function (img) {
|
||||
img.loading = 'eager';
|
||||
});
|
||||
|
||||
Array.prototype.slice.call(document.querySelectorAll('[data-druck-print]')).forEach(function (btn) {
|
||||
btn.hidden = false;
|
||||
btn.addEventListener('click', function () {
|
||||
window.print();
|
||||
});
|
||||
});
|
||||
})();
|
||||
118
public/assets/js/stadionzeitung-viewer.js
Normal file
118
public/assets/js/stadionzeitung-viewer.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/* Stadionzeitung-Swipe-Viewer: rüstet den gestapelten No-JS-Fallback
|
||||
([data-stadionzeitung-viewer]) auf einen horizontalen Swiper um. Kein
|
||||
eigener Touch-Gesten-Code — natives CSS-Scroll-Snap (components.css) macht
|
||||
das Swipen butterweich, JS liefert nur Zähler, Pfeiltasten und die
|
||||
Vor-/Zurück-Buttons obendrauf. Ohne JS bleiben alle Seiten normal gestapelt. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function initViewer(root) {
|
||||
var track = root.querySelector('[data-viewer-track]');
|
||||
var pages = Array.prototype.slice.call(root.querySelectorAll('[data-viewer-page]'));
|
||||
var controls = root.querySelector('[data-viewer-controls]');
|
||||
var counter = root.querySelector('[data-viewer-current]');
|
||||
var prevBtn = root.querySelector('[data-viewer-prev]');
|
||||
var nextBtn = root.querySelector('[data-viewer-next]');
|
||||
var fullBtn = root.querySelector('[data-viewer-full]');
|
||||
|
||||
if (!track || pages.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var index = 0;
|
||||
var ticking = false;
|
||||
|
||||
function updateUI() {
|
||||
if (counter) {
|
||||
counter.textContent = String(index + 1);
|
||||
}
|
||||
if (prevBtn) {
|
||||
prevBtn.disabled = index === 0;
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.disabled = index === pages.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToIndex(i) {
|
||||
index = Math.max(0, Math.min(pages.length - 1, i));
|
||||
track.scrollTo({ left: index * track.clientWidth, behavior: reduce ? 'auto' : 'smooth' });
|
||||
updateUI();
|
||||
}
|
||||
|
||||
track.addEventListener('scroll', function () {
|
||||
if (ticking) {
|
||||
return;
|
||||
}
|
||||
ticking = true;
|
||||
requestAnimationFrame(function () {
|
||||
index = Math.round(track.scrollLeft / track.clientWidth);
|
||||
updateUI();
|
||||
ticking = false;
|
||||
});
|
||||
}, { passive: true });
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
// Re-snap ohne Animation, z. B. nach Rotation am Handy — sonst
|
||||
// steht der Track auf einer krummen Zwischenposition.
|
||||
track.scrollTo({ left: index * track.clientWidth, behavior: 'auto' });
|
||||
});
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () { scrollToIndex(index - 1); });
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () { scrollToIndex(index + 1); });
|
||||
}
|
||||
|
||||
/* Vollbild-Lesemodus. Bewusst NICHT die Fullscreen-API: iOS Safari
|
||||
unterstützt requestFullscreen() nur auf <video>, der Knopf wäre auf
|
||||
iPhones wirkungslos. Zwei Klassen und CSS erledigen dasselbe überall
|
||||
gleich — und die Adressleiste des Browsers bleibt erreichbar. */
|
||||
function setFull(on) {
|
||||
document.body.classList.toggle('is-sz-full', on);
|
||||
root.classList.toggle('is-full', on);
|
||||
if (fullBtn) {
|
||||
fullBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
}
|
||||
// Die Track-Breite hat sich gerade geändert — ohne Neu-Einrasten
|
||||
// stünde der Swiper auf einer krummen Zwischenposition.
|
||||
requestAnimationFrame(function () {
|
||||
track.scrollTo({ left: index * track.clientWidth, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
if (fullBtn) {
|
||||
fullBtn.addEventListener('click', function () {
|
||||
setFull(!root.classList.contains('is-full'));
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
var active = document.activeElement;
|
||||
if (active && /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName)) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && root.classList.contains('is-full')) {
|
||||
event.preventDefault();
|
||||
setFull(false);
|
||||
if (fullBtn) {
|
||||
fullBtn.focus();
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
scrollToIndex(index - 1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
scrollToIndex(index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
root.classList.add('is-enhanced');
|
||||
if (controls) {
|
||||
controls.hidden = false;
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
Array.prototype.slice.call(document.querySelectorAll('[data-stadionzeitung-viewer]')).forEach(initViewer);
|
||||
})();
|
||||
Reference in New Issue
Block a user