Matchcenter: Spielplan, Ergebnisse & Tabellen (eigene Seite statt BFV-Widget)

Neue Unterseite /matchcenter ersetzt die alten BFV-Widget-Embeds (verstoßen
gegen CSP) durch eine self-hostete Lösung: Cron-Scraper holt die Daten aus der
öffentlichen BFV-Widget-JSON-API, schreibt data/matchcenter.json und lädt die
Vereinswappen lokal — Besucher kontaktieren nur unsere Domain.

- bin/matchcenter-sync.php: CLI/Cron-Scraper (Muster wie instagram-sync), JSON-API
  team/{id}/matches + competition/{compoundId}/table, Wappen via getLogo lokal,
  atomarer Write, Fail-safe. Auto-Saison: Tabellen-compoundId wird aus der
  Matches-API abgeleitet (team_id ist saison-stabil). Per-Team-Cache-Fallback bei
  Fetch-Fehlern, damit Sektionen nie leeren.
- Seite app/pages/matchcenter.php + Route + Nav-Eintrag (unter Fußball).
- Komponenten: match-slider (nächste Spiele), match-card, match-row, league-table
  (mit Auf-/Abstiegszonen), matchcenter-section (kicker-Layout: Tabelle 2/3 +
  Spiele 1/3), matchcenter-empty, crest (weißes Chip + Initialen-Fallback).
- carousel.js: banner.js zum generischen [data-carousel]-Antrieb verallgemeinert
  (Banner- und Match-Slider teilen ihn); crest.js: Logo-Fallback im Browser.
- helpers.php: sportsevent_nodes() für SportsEvent-JSON-LD.
- Icons calendar-event; config.example + .gitignore + CLAUDE.md ergänzt.
- Responsive geprüft (kein Seiten-Overflow 360–1280, Tabelle scrollt in der Karte).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192vTfGmGpoWeyUse4TuQFj
This commit is contained in:
2026-06-19 10:57:14 +02:00
parent ea622244db
commit 185365dc79
23 changed files with 1679 additions and 135 deletions

View File

@@ -2903,3 +2903,613 @@
.facility__actions {
margin-top: 1.75rem;
}
/* === matchcenter === */
/* Vereinswappen — gemeinsam für Karten und Tabelle; fehlt das Bild, rendert die
Komponente einen Initialen-Kreis (.crest--initials) statt eines kaputten <img>. */
/* Weißes Chip hält auch helle/weiße Logos auf dunklem Grund sichtbar. */
.crest {
box-sizing: border-box;
flex: none;
width: 2.25rem;
height: 2.25rem;
padding: 3px;
object-fit: contain;
background: #fff;
border-radius: 50%;
}
.crest--initials {
display: inline-grid;
place-items: center;
padding: 0;
font-family: var(--ff-body);
font-size: var(--fs-300);
font-weight: var(--fw-bold);
line-height: 1;
color: var(--clr-text-muted);
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: 50%;
}
/* Einzelne Begegnung. */
.match-card {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: clamp(1rem, 0.8rem + 0.8vw, 1.4rem);
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
}
/* Datum oben mittig (kicker-Stil). */
.match-card__date {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4em;
font-size: var(--fs-300);
color: var(--clr-text-muted);
}
.match-card__date-icon {
font-size: 0.95em;
}
.match-card__fixture {
display: grid;
/* minmax(0, …) statt 1fr: erlaubt den Spalten, schmaler als ihr Inhalt zu
werden (sonst überläuft die Karte auf sehr schmalen Screens). */
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
}
.match-card__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 0; /* verhindert Grid-Blowout der 1fr-Spalten bei langen Namen */
text-align: center;
}
.match-card__club {
font-size: var(--fs-400);
line-height: 1.2;
overflow-wrap: anywhere; /* lange Namen brechen um statt zu überlaufen */
}
.match-card__side.is-own .match-card__club {
font-weight: var(--fw-bold);
color: var(--clr-text);
}
.match-card__center {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.25rem;
}
.match-card__score {
font-family: var(--ff-heading);
font-size: var(--fs-700);
line-height: 1;
}
.match-card__colon {
margin: 0 0.12em;
}
.match-card__vs {
font-family: var(--ff-heading);
font-size: var(--fs-650);
color: var(--clr-text-faint);
}
/* Zeit mittig unter dem Ergebnis/Anstoß (kicker-Stil). */
.match-card__time {
margin-top: 0.4rem;
font-size: var(--fs-400);
color: var(--clr-text-muted);
white-space: nowrap;
}
/* Hero-Variante (im Slider): größer und ruhiger. */
.match-card--hero {
padding: clamp(1.5rem, 1rem + 2vw, 2.5rem);
}
.match-card--hero .match-card__score {
font-size: var(--fs-800);
}
.match-card--hero .crest,
.match-card--hero .crest--initials {
width: 3rem;
height: 3rem;
}
.match-card--hero .match-card__club {
font-size: var(--fs-500);
}
/* „Nächste Spiele"-Slider — Mechanik wie .ad-banner (gemeinsamer Antrieb
carousel.js, gemeinsame Keyframe ad-progress). */
.match-slider {
--slider-interval: 6s;
}
.match-slider > .container > h2 {
text-align: center;
}
.match-slider__track {
list-style: none;
margin: 2rem auto 0;
padding: 0;
max-width: 620px;
}
.match-slider__slide[hidden] {
display: none;
}
.match-slider__team {
margin-bottom: 0.85rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: var(--fs-300);
color: var(--clr-text-muted);
}
/* Crossfade nur mit JS: Slides im selben Grid-Feld stapeln (konstante Höhe). */
.match-slider.is-enhanced .match-slider__track {
display: grid;
grid-template-columns: minmax(0, 1fr); /* gestapelte Slides nie breiter als der Track */
}
.match-slider.is-enhanced .match-slider__slide {
grid-area: 1 / 1;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.55s ease, transform 0.55s ease, visibility 0s linear 0.55s;
visibility: hidden;
}
.match-slider.is-enhanced .match-slider__slide.is-active {
opacity: 1;
transform: none;
pointer-events: auto;
visibility: visible;
transition: opacity 0.55s ease, transform 0.55s ease, visibility 0s;
}
.match-slider__controls {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.75rem;
}
.match-slider__controls[hidden] {
display: none;
}
.match-slider__toggle {
display: inline-grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
color: var(--clr-text);
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: 999px;
cursor: pointer;
transition: background var(--transition);
}
.match-slider__toggle[hidden] {
display: none;
}
.match-slider__toggle:hover {
background: rgb(255 255 255 / 0.2);
}
.match-slider__toggle:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 3px;
}
.match-slider__toggle-icon {
font-size: 1.15rem;
}
.match-slider__toggle .match-slider__toggle-icon--play {
display: none;
}
.match-slider__toggle[aria-pressed="true"] .match-slider__toggle-icon--pause {
display: none;
}
.match-slider__toggle[aria-pressed="true"] .match-slider__toggle-icon--play {
display: inline-block;
}
.match-slider__dots {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.match-slider__dot {
position: relative;
width: 48px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--clr-glass-border);
cursor: pointer;
overflow: hidden;
transition: background var(--transition);
}
.match-slider__dot:hover {
background: rgb(255 255 255 / 0.35);
}
.match-slider__dot:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 3px;
}
.match-slider__progress {
position: absolute;
inset: 0;
background: var(--clr-accent);
transform: scaleX(0);
transform-origin: left;
}
.match-slider__dot.is-active .match-slider__progress {
animation: ad-progress var(--slider-interval) linear forwards;
}
.match-slider.is-paused .match-slider__dot.is-active .match-slider__progress {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.match-slider__dot.is-active .match-slider__progress {
animation: none;
transform: scaleX(1);
}
}
/* Pro-Mannschaft-Sektion: Kopf mit Name/Liga + Link zur Mannschaftsseite. */
.matchcenter-team__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.matchcenter-team__league {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-top: 0.35rem;
color: var(--clr-text-muted);
}
/* kicker-Layout: Tabelle 2/3 links, Spiele 1/3 rechts; gestapelt auf Mobil.
align-items:start + 0-Margins der Karten → beide Spalten starten oben bündig. */
.matchcenter-team__grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: clamp(1.5rem, 1rem + 2vw, 2.75rem);
align-items: start;
}
@media (max-width: 992px) {
.matchcenter-team__grid {
grid-template-columns: 1fr;
}
}
/* min-width:0 erlaubt der Tabellenspalte, schmaler als die min-width:520 der
Tabelle zu werden — sonst schiebt die Tabelle die ganze Seite in den
horizontalen Scroll, statt selbst in ihrer Karte zu scrollen. */
.matchcenter-team__grid > * {
min-width: 0;
}
/* Mehrere Listen (Nächste Spiele / Letzte Ergebnisse) gleichmäßig gestapelt.
max-width hält die kompakten Zeilen gestapelt (≤992px) lesbar schmal, statt sie
über die volle Breite zu ziehen; im 2-spaltigen Desktop greift es nicht (die
1fr-Spalte ist ohnehin schmaler). */
.matchcenter-team__aside {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 30rem;
}
/* Kompakte Spielliste (Seitenspalte) — gleiche Karten-Optik wie die Tabelle. */
.match-list {
margin: 0;
padding: 0;
list-style: none;
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
}
.match-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: 0.75rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--clr-glass-border);
}
.match-row:last-child {
border-bottom: 0;
}
.match-row__date {
grid-column: 1 / -1;
margin-bottom: 0.45rem;
font-size: var(--fs-300);
color: var(--clr-text-muted);
}
.match-row__teams {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0; /* erlaubt Ellipsis in den Namen */
}
.match-row__team {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.match-row__team .crest,
.match-row__team .crest--initials {
width: 1.6rem;
height: 1.6rem;
font-size: 0.65rem;
}
.match-row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-400);
}
.match-row__team.is-own .match-row__name {
font-weight: var(--fw-bold);
color: var(--clr-text);
}
.match-row__outcome {
align-self: center;
}
.match-row__score {
font-family: var(--ff-heading);
font-size: var(--fs-650);
line-height: 1;
white-space: nowrap;
}
.match-row__time {
font-size: var(--fs-400);
color: var(--clr-text-muted);
white-space: nowrap;
}
/* Liga-Tabelle: horizontal scrollbar (fokussierbare Region) auf schmalen Screens. */
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
}
.table-scroll:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
}
.league-table {
width: 100%;
min-width: 520px;
border-collapse: collapse;
background: var(--clr-glass-bg);
font-size: var(--fs-400);
}
.league-table th,
.league-table td {
padding: 0.6rem 0.5rem;
text-align: center;
border-bottom: 1px solid var(--clr-glass-border);
}
.league-table tbody tr:last-child th,
.league-table tbody tr:last-child td {
border-bottom: 0;
}
/* Spaltenköpfe in Abel (Coolvetica wäre hier zu klein, < 28px). */
.league-table thead th {
font-family: var(--ff-body);
font-weight: var(--fw-bold);
font-size: var(--fs-300);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--clr-text-muted);
white-space: nowrap;
}
.league-table thead th abbr {
text-decoration: none;
}
.league-table__rank {
width: 2.75rem;
border-left: 3px solid transparent;
color: var(--clr-text-muted);
}
/* Vereinsspalte (Kopf + Zellen) linksbündig — höhere Spezifität als .league-table th. */
.league-table .league-table__club {
text-align: left;
}
.league-table tbody th.league-table__club {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0; /* erlaubt Ellipsis im Namen */
font-weight: var(--fw-regular);
}
.league-table tbody .crest,
.league-table tbody .crest--initials {
width: 1.75rem;
height: 1.75rem;
font-size: 0.7rem;
}
/* Lange Vereinsnamen sauber kürzen (voller Name im title-Attribut). */
.league-table__name {
min-width: 0;
overflow: hidden;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.league-table__pts {
font-weight: var(--fw-bold);
color: var(--clr-text);
}
/* Eigene Vereinszeile hervorheben (rote Tönung hinter weißem Text). */
.league-table tbody tr.is-own {
background: rgb(var(--clr-accent-rgb) / 0.16);
}
.league-table tbody tr.is-own th,
.league-table tbody tr.is-own td {
font-weight: var(--fw-bold);
color: var(--clr-text);
}
/* Auf-/Abstiegszonen: farbiger linker Rand am Rang. */
.league-table tbody tr.zone-promotion .league-table__rank {
border-left-color: var(--clr-success);
}
.league-table tbody tr.zone-promotion-playoff .league-table__rank {
border-left-color: color-mix(in srgb, var(--clr-success) 60%, var(--clr-text-faint));
}
.league-table tbody tr.zone-relegation-playoff .league-table__rank {
border-left-color: color-mix(in srgb, var(--clr-error) 60%, var(--clr-text-faint));
}
.league-table tbody tr.zone-relegation .league-table__rank {
border-left-color: var(--clr-error);
}
.league-table__legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin: 0.85rem 0 0;
padding: 0;
list-style: none;
font-size: var(--fs-300);
color: var(--clr-text-muted);
}
.league-table__legend-item {
display: inline-flex;
align-items: center;
gap: 0.45em;
}
.league-table__legend-item::before {
content: "";
width: 0.85rem;
height: 0.85rem;
border-radius: 3px;
background: var(--clr-text-faint);
}
.league-table__legend-item.zone-promotion::before {
background: var(--clr-success);
}
.league-table__legend-item.zone-promotion-playoff::before {
background: color-mix(in srgb, var(--clr-success) 60%, var(--clr-text-faint));
}
.league-table__legend-item.zone-relegation-playoff::before {
background: color-mix(in srgb, var(--clr-error) 60%, var(--clr-text-faint));
}
.league-table__legend-item.zone-relegation::before {
background: var(--clr-error);
}
/* Fallback ohne Cache. */
.matchcenter-empty {
max-width: 540px;
margin-inline: auto;
padding: clamp(1.5rem, 1rem + 2vw, 2.5rem);
text-align: center;
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
}
.matchcenter-empty__icon {
font-size: 2.5rem;
color: var(--clr-accent);
}
.matchcenter-empty p {
margin-top: 0.75rem;
color: var(--clr-text-muted);
}
.matchcenter-empty__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

View File

@@ -1,123 +0,0 @@
/* Werbe-/Sponsoren-Banner-Slider: Auto-Rotation über die Fortschrittsanimation
des aktiven Indikators (animationend → nächste Folie).
Barrierefreiheit:
- explizite Pause/Play-Taste (WCAG 2.2.2 „Pause, Stop, Hide"),
- zusätzlich Pause bei Hover/Fokus (Komfort),
- aria-live: im Autoplay "off" (kein Vorlese-Spam), bei Pause/manuell "polite",
- bei prefers-reduced-motion läuft keine Animation → keine Auto-Rotation;
Indikatoren bleiben per Klick bedienbar, die Pause-Taste entfällt. */
(function () {
'use strict';
var root = document.querySelector('[data-banner]');
if (!root) {
return;
}
var track = root.querySelector('#ad-banner-track');
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-banner-slide]'));
var dots = Array.prototype.slice.call(root.querySelectorAll('[data-banner-dot]'));
var controls = root.querySelector('[data-banner-controls]');
var toggle = root.querySelector('[data-banner-toggle]');
var toggleLabel = root.querySelector('[data-banner-toggle-label]');
if (slides.length <= 1) {
return;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var index = 0;
var hoverPaused = false;
var userPaused = false;
function syncPaused() {
// Bei reduzierter Bewegung gilt jeder Wechsel als manuell → ansagen.
var paused = hoverPaused || userPaused || reduce;
root.classList.toggle('is-paused', paused);
if (track) {
track.setAttribute('aria-live', paused ? 'polite' : 'off');
}
}
function activate(i) {
index = (i % slides.length + slides.length) % slides.length;
slides.forEach(function (slide, n) {
// Sichtbarkeit per Klasse → CSS blendet über (siehe .is-enhanced).
slide.classList.toggle('is-active', n === index);
});
dots.forEach(function (dot, n) {
var on = n === index;
dot.classList.toggle('is-active', on);
if (on) {
dot.setAttribute('aria-current', 'true');
} else {
dot.removeAttribute('aria-current');
}
});
}
// Sobald die Fortschrittsanimation des aktiven Indikators durchgelaufen ist,
// zur nächsten Folie wechseln. (Beim Wechsel wird die alte Animation
// abgebrochen → 'animationcancel', nicht 'animationend' → kein Doppelsprung.)
root.addEventListener('animationend', function (event) {
if (event.animationName === 'ad-progress') {
activate(index + 1);
}
});
// Pause bei Hover/Fokus.
root.addEventListener('mouseenter', function () {
hoverPaused = true;
syncPaused();
});
root.addEventListener('mouseleave', function () {
hoverPaused = false;
syncPaused();
});
root.addEventListener('focusin', function () {
hoverPaused = true;
syncPaused();
});
root.addEventListener('focusout', function (event) {
if (!root.contains(event.relatedTarget)) {
hoverPaused = false;
syncPaused();
}
});
dots.forEach(function (dot, n) {
dot.addEventListener('click', function () {
activate(n);
});
});
// Steuerung einblenden (ohne JS bleibt sie verborgen, da funktionslos).
if (controls) {
controls.hidden = false;
}
// Pause/Play nur anbieten, wenn überhaupt automatisch rotiert wird.
if (toggle && !reduce) {
toggle.hidden = false;
toggle.addEventListener('click', function () {
userPaused = !userPaused;
toggle.setAttribute('aria-pressed', String(userPaused));
if (toggleLabel) {
toggleLabel.textContent = userPaused
? 'Automatischen Wechsel fortsetzen'
: 'Automatischen Wechsel pausieren';
}
syncPaused();
});
}
// Crossfade aktivieren: Slides stapeln (CSS) und Sichtbarkeit per Klasse
// steuern. Vorher gesetzte hidden-Attribute (No-JS-Fallback) entfernen.
root.classList.add('is-enhanced');
slides.forEach(function (slide) {
slide.hidden = false;
});
activate(0);
syncPaused();
})();

View File

@@ -0,0 +1,131 @@
/* Generischer Karussell-Antrieb für alle [data-carousel]-Elemente
(Werbe-/Sponsoren-Banner und Matchcenter „Nächste Spiele"). Auto-Rotation über
die Fortschrittsanimation des aktiven Indikators (animationend → nächste Folie).
Erwartete Hooks je Wurzel:
[data-carousel] Wurzel
[data-carousel-track] Folien-Container (bekommt aria-live)
[data-carousel-slide] einzelne Folie
[data-carousel-controls] Steuerleiste (ohne JS hidden)
[data-carousel-toggle] Pause/Play-Taste
[data-carousel-toggle-label] visually-hidden Beschriftung
[data-carousel-dot] Indikator-Button
[data-carousel-progress] Fortschritts-Element im aktiven Dot (treibt den Wechsel)
Barrierefreiheit:
- explizite Pause/Play-Taste (WCAG 2.2.2 „Pause, Stop, Hide"),
- zusätzlich Pause bei Hover/Fokus,
- aria-live: im Autoplay "off", bei Pause/manuell "polite",
- bei prefers-reduced-motion läuft keine Animation → keine Auto-Rotation;
Indikatoren bleiben per Klick bedienbar, die Pause-Taste entfällt.
Ohne JS ist nur die erste Folie sichtbar (Graceful Degradation). */
(function () {
'use strict';
function initCarousel(root) {
var track = root.querySelector('[data-carousel-track]');
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-carousel-slide]'));
var dots = Array.prototype.slice.call(root.querySelectorAll('[data-carousel-dot]'));
var controls = root.querySelector('[data-carousel-controls]');
var toggle = root.querySelector('[data-carousel-toggle]');
var toggleLabel = root.querySelector('[data-carousel-toggle-label]');
if (slides.length <= 1) {
return;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var index = 0;
var hoverPaused = false;
var userPaused = false;
function syncPaused() {
var paused = hoverPaused || userPaused || reduce;
root.classList.toggle('is-paused', paused);
if (track) {
track.setAttribute('aria-live', paused ? 'polite' : 'off');
}
}
function activate(i) {
index = (i % slides.length + slides.length) % slides.length;
slides.forEach(function (slide, n) {
slide.classList.toggle('is-active', n === index);
});
dots.forEach(function (dot, n) {
var on = n === index;
dot.classList.toggle('is-active', on);
if (on) {
dot.setAttribute('aria-current', 'true');
} else {
dot.removeAttribute('aria-current');
}
});
}
// Fortschrittsanimation des aktiven Indikators durchgelaufen → nächste Folie.
// (Beim Wechsel bricht die alte Animation ab → 'animationcancel' statt
// 'animationend' → kein Doppelsprung.)
root.addEventListener('animationend', function (event) {
if (event.target && event.target.matches && event.target.matches('[data-carousel-progress]')) {
activate(index + 1);
}
});
root.addEventListener('mouseenter', function () {
hoverPaused = true;
syncPaused();
});
root.addEventListener('mouseleave', function () {
hoverPaused = false;
syncPaused();
});
root.addEventListener('focusin', function () {
hoverPaused = true;
syncPaused();
});
root.addEventListener('focusout', function (event) {
if (!root.contains(event.relatedTarget)) {
hoverPaused = false;
syncPaused();
}
});
dots.forEach(function (dot, n) {
dot.addEventListener('click', function () {
activate(n);
});
});
// Steuerung einblenden (ohne JS bleibt sie verborgen, da funktionslos).
if (controls) {
controls.hidden = false;
}
// Pause/Play nur anbieten, wenn überhaupt automatisch rotiert wird.
if (toggle && !reduce) {
toggle.hidden = false;
toggle.addEventListener('click', function () {
userPaused = !userPaused;
toggle.setAttribute('aria-pressed', String(userPaused));
if (toggleLabel) {
toggleLabel.textContent = userPaused
? 'Automatischen Wechsel fortsetzen'
: 'Automatischen Wechsel pausieren';
}
syncPaused();
});
}
// Crossfade aktivieren: Slides stapeln (CSS) und Sichtbarkeit per Klasse
// steuern. Vorher gesetzte hidden-Attribute (No-JS-Fallback) entfernen.
root.classList.add('is-enhanced');
slides.forEach(function (slide) {
slide.hidden = false;
});
activate(0);
syncPaused();
}
Array.prototype.slice.call(document.querySelectorAll('[data-carousel]')).forEach(initCarousel);
})();

39
public/assets/js/crest.js Normal file
View File

@@ -0,0 +1,39 @@
/* Vereinswappen-Fallback: lädt ein <img class="crest"> nicht (404, kaputtes Bild),
wird es durch den Initialen-Kreis ersetzt — kein kaputtes Bild-Icon. Progressive
Enhancement; ohne JS bleibt höchstens das (alt="") leere Bild. */
(function () {
'use strict';
function initials(name) {
var parts = (name || '').split(/[\s.\-]+/);
var out = '';
for (var i = 0; i < parts.length && out.length < 2; i++) {
if (parts[i]) {
out += parts[i].charAt(0);
}
}
return (out || '?').toUpperCase();
}
function replace(img) {
var span = document.createElement('span');
span.className = 'crest crest--initials';
span.setAttribute('aria-hidden', 'true');
span.textContent = initials(img.getAttribute('data-club'));
if (img.parentNode) {
img.parentNode.replaceChild(span, img);
}
}
var imgs = document.querySelectorAll('img.crest');
Array.prototype.forEach.call(imgs, function (img) {
// Bereits fehlgeschlagen, bevor JS lief?
if (img.complete && img.naturalWidth === 0) {
replace(img);
return;
}
img.addEventListener('error', function () {
replace(img);
});
});
})();