Compare commits

...

2 Commits

Author SHA1 Message Date
fs
0ee75aa93b Stadionzeitung: Ergebnis- und Gegnerseite neu gestaltet
Ergebnisse laufen als Anzeigetafel (Wappen und Name je Seite, Ergebnis gross
in der Mitte) statt als Listenzeilen. Verworfen und im CSS begruendet: Tore
am rechten Blattrand, getoente Karten je Spiel, roter Strich unter der
Mannschaftszeile, graue Verlierer-Ziffern. Vereinsnamen einzeilig, sonst
ausgepunktet.

Die Gegnerseite bekommt ein symmetrisches Duell, eine aus den Tabellenwerten
abgeleitete Aufmacher-Zeile, den Vergleich als Balkenpaar aus der Mitte und
einen Tabellenausschnitt ueber die geteilte league-table-Komponente. Die
Balkenlaengen kommen aus Klassen in 5-Prozent-Stufen, nie aus einem
style-Attribut (die CSP verwirft inline styles stillschweigend).

Das Mitglied-werden-Poster sagt jetzt "Werde Teil unseres Vereins".

Alle geaenderten Seiten auf A5-Hoehe nachgemessen, kein Ueberstand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hv9SGDu3m3diiPrW9Fmj3v
2026-09-19 10:54:05 +02:00
fs
8f864368f0 Stadionzeitung: Ausgabe 19.09., Gegnerseiten ohne Fest, Gegner-Kurzname
Gegnerseiten lassen sich jetzt ueber `gegner_teams` auch ohne Veranstaltung
fuer weitere Mannschaften anlegen (Fenster zwei Tage vor bis drei Tage nach
dem Ausgabedatum, auch auswaerts), und `opponent_short` liefert den
gedruckten Anzeigenamen, waehrend `opponent` die BFV-Schreibweise fuer den
Namensvergleich behaelt.

Dazu die Ausgabe zum Heimspiel gegen Wirsberg/Kupferberg als Entwurf samt
Seitenbildern. Die gemeinsamen QR-Codes sind neu erzeugt (die vendorte
Bibliothek arbeitet nicht reproduzierbar, das Maskenmuster wechselt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hv9SGDu3m3diiPrW9Fmj3v
2026-09-19 10:53:56 +02:00
30 changed files with 1059 additions and 136 deletions

View File

@@ -54,7 +54,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Partner/Sponsoren | `data/partners.json` | — | | Partner/Sponsoren | `data/partners.json` | — |
| Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen | | Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php <post-url>`** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen |
| Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) | | Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) |
| Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`) und `cover` (`teaser` einer Sonderausgabe).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt | | Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`), `cover` (`teaser` einer Sonderausgabe), `opponent_short` (gedruckter Anzeigename des Gegners) und `gegner_teams` (weitere Mannschaften mit Gegnerseite).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt |
| Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen | | Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen |
| Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) | | Termine/Events (`/termine`) | `data/events.json` (Vereins-Termine **ohne** eigene Detailseite) — Spieltermine kommen weiterhin nur aus `data/matchcenter.json`, Feste mit eigener Seite nur aus `data/veranstaltungen.json`; die Termine-Seite mergt alle drei | Von Claude im Chat gepflegt wie `teams.json`/`partners.json` — **keine** Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht **nur** in `veranstaltungen.json` (`bin/preflight.php` prüft das) |
| Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) | | Veranstaltungen (`/veranstaltungen` + Detailseiten) | `data/veranstaltungen.json` + `public/assets/img/veranstaltungen/` | Termin, Texte, Programm, Turnierpläne (`turniere[]`, nur fürs Heft), Abschnitte von Claude im Chat gepflegt — **außer `plakate` und `galerie`: die schreibt ausschließlich `bin/veranstaltung-bilder.php`**. Adresse nie in die Texte schreiben (steht in `club.json`) |
@@ -795,6 +795,49 @@ keinen farbigen Rahmen über das Spielerfoto** — der Kicker bleibt „Saison
die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder die Ausgabe nicht, in die die Felder gehören. `bin/preflight.php` prüft, dass der Slug und jeder
Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler. Turnier-Key wirklich existieren — ein Tippfehler ergäbe sonst eine **leere** A5-Seite ohne Fehler.
**Gegnerseiten ohne Fest, und der Anzeigename des Gegners (17.09.2026).** Zwei Felder, beide
chat-gepflegt und von `bin/stadionzeitung-add.php` über erneute Läufe durchgetragen wie `vorwort`:
- **`gegner_teams`** (Liste von Team-Keys) gibt weiteren Mannschaften eine Gegnerseite, ohne dass es
eine Veranstaltung braucht. Der Sportfest-Weg blieb unverändert daneben stehen (Heimspiele im
Festzeitraum); dieser hier nimmt das nächste Spiel im Fenster **zwei Tage vor bis drei Tage nach**
dem Ausgabedatum, **auch auswärts** — am normalen Spielwochenende fährt eine der drei Mannschaften
fast immer weg, und ihr Gegner ist trotzdem eine Seite wert. `stadionzeitung_gegner_fixtures()`
bleibt die eine entscheidende Stelle. Findet eine benannte Mannschaft kein Spiel im Fenster,
entsteht keine Seite und das Skript **warnt** — ein Tippfehler im Team-Key sähe sonst aus wie ein
spielfreies Wochenende. Auf der Gegnerseite steht seitdem **immer unsere Mannschaft links**
(Klasse `--own`), bei einem Auswärtsspiel läuft die Zeile „… bei …" statt „… gegen …"; vorher war
die linke Spalte fest die Heimmannschaft und die Hervorhebung lag beim Gegner.
- **`opponent_short`** ist der gedruckte Anzeigename des Gegners, gelesen über
**`stadionzeitung_opponent_display()`** (Cover, Archivzeile, Ausgaben-Kopf, Titel, Meta, Alt-Text
der Rückseite). **`opponent` selbst muss die BFV-Schreibweise behalten** — die Gegnerseite sucht
damit ihre Tabellenzeile und ihre Begegnung in `matchcenter.json` über einen exakten
Namensvergleich. Bei Spielgemeinschaften ist das eine Zeile wie „(SG1) TSV Wirsberg I/ FC
Kupferberg I", die auf einem Cover nichts zu suchen hat; `opponent_short` trägt dann
„Wirsberg/Kupferberg". Fehlt das Feld, bleibt es beim vollen Namen.
Nebenbei löst das oft die Seitenzahl: drei Gegnerseiten statt einer sind **zwei Seiten mehr**, und
ein Heft, das bei 42 stand, steht damit auf 44.
**Gegner- und Ergebnisseite neu gestaltet (19.09.2026).** Beide Seiten waren inhaltlich richtig und
gestalterisch leer, der Umbau lief über mehrere verworfene Entwürfe. Was davon bleibt:
- **Ergebnisse als Anzeigetafel** (Vorbild kicker/Sofascore): Wappen und Name je Seite, das Ergebnis
groß in der Mitte. Vorher waren es Listenzeilen, und eine Zeile stellt Heim und Gast untereinander
statt gegenüber. Verworfen und nicht wieder einzubauen: Tore am rechten Blattrand (halbe
Seitenbreite Leere zwischen Name und Ziffer), eine getönte Karte je Spiel („altbacken"), ein roter
Strich unter der Mannschaftszeile (lauter als die Ergebnisse) und graue Verlierer-Ziffern (auf
Papier schlechter lesbar). Vereinsnamen laufen **einzeilig** und werden sonst ausgepunktet.
- **Gegnerseite**: Duell symmetrisch und leise (beide Wappen gleich groß, der Name des Gegners steht
nur in der Überschrift laut da), darunter eine abgeleitete Aufmacher-Zeile („Sieben Siege aus acht
Spielen", aus den Tabellenwerten gebaut), der Vergleich als **Balkenpaar aus der Mitte** und zum
Schluss ein **Tabellenausschnitt** über die geteilte `league-table`-Komponente. Der Tabellenplatz
steht bewusst dort und nicht als Balken (klein ist besser, der Balken läge falsch herum) und auch
nicht als lose Ziffer im Kopf (war gebaut, wirkte unruhig).
- **Balkenlängen kommen aus Klassen in 5-Prozent-Stufen**, nie aus einem `style`-Attribut — die CSP
verwirft inline styles stillschweigend, die Balken wären erst nach dem Deploy unsichtbar.
- Die Gegnerseite druckt **`opponent_display`** (Kurzname der Ausgabe, wenn die Seite den Gegner der
Ausgabe zeigt); `opponent` bleibt die BFV-Schreibweise für den Namensvergleich.
**Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite — **Cover (automatisch generiert, Phase 3):** einzige fotografische Stadionzeitungs-Seite —
`app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein** `app/components/stadionzeitung-cover.php`. Echtes Magazin-Cover statt Website-Baustein: **kein**
Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das

View File

@@ -31,7 +31,7 @@ $issues = $issues ?? [];
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="stadionzeitung-card__body"> <div class="stadionzeitung-card__body">
<h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e($issue['opponent']) ?></h3> <h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e(stadionzeitung_opponent_display($issue)) ?></h3>
<p class="stadionzeitung-card__meta"> <p class="stadionzeitung-card__meta">
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time> <time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
· <?= (int) $issue['pages'] ?> Seiten · <?= (int) $issue['pages'] ?> Seiten

View File

@@ -32,7 +32,7 @@ declare(strict_types=1);
* der Gegner läuft über den Initialen-Fallback der crest-Komponente. * der Gegner läuft über den Initialen-Fallback der crest-Komponente.
*/ */
$issue = $issue ?? []; $issue = $issue ?? [];
$opponent = (string) ($issue['opponent'] ?? ''); $opponent = stadionzeitung_opponent_display($issue);
$date = (string) ($issue['date'] ?? ''); $date = (string) ($issue['date'] ?? '');
$teamKey = (string) ($issue['team_key'] ?? ''); $teamKey = (string) ($issue['team_key'] ?? '');
$playerImage = $issue['player_image'] ?? null; $playerImage = $issue['player_image'] ?? null;

View File

@@ -40,19 +40,28 @@ $matchcenterLink = club_domain('matchcenter');
<section class="stadionzeitung-ergebnisse__team"> <section class="stadionzeitung-ergebnisse__team">
<h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3> <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): ?> <?php foreach ($group['matches'] ?? [] as $match): ?>
<div class="stadionzeitung-ergebnisse__match"> <?php
<p class="stadionzeitung-ergebnisse__meta"> $sides = [
<?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?> ['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],
];
?>
<article class="stadionzeitung-ergebnisse__match">
<?php foreach ($sides as $i => $side): ?>
<?php if ($i === 1): ?>
<div class="stadionzeitung-ergebnisse__score">
<p class="stadionzeitung-ergebnisse__result">
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $sides[0]['goals']) ?></span><span class="stadionzeitung-ergebnisse__colon">:</span><span class="stadionzeitung-ergebnisse__goals"><?= e((string) $sides[1]['goals']) ?></span>
</p> </p>
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null], <p class="stadionzeitung-ergebnisse__meta"><?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?></p>
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null]] as $side): ?> </div>
<div class="stadionzeitung-ergebnisse__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__row--own' : '' ?>"> <?php endif; ?>
<div class="stadionzeitung-ergebnisse__side<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__side--own' : '' ?>">
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?> <?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
<span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span> <span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span>
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $side['goals']) ?></span>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </article>
<?php endforeach; ?> <?php endforeach; ?>
</section> </section>
<?php endforeach; ?> <?php endforeach; ?>

View File

@@ -18,15 +18,29 @@ $teamName = trim((string) ($info['team_name'] ?? ''));
$fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : []; $fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : [];
$own = is_array($info['own'] ?? null) ? $info['own'] : null; $own = is_array($info['own'] ?? null) ? $info['own'] : null;
$opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null; $opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null;
$opponentName = (string) ($info['opponent'] ?? 'Unser Gegner'); // Gedruckt wird der Anzeigename (Kurzname der Ausgabe, wenn gepflegt) — die
// BFV-Schreibweise „(SG1) TSV Wirsberg I/ FC Kupferberg I" ist ein Suchschlüssel,
// keine Überschrift.
$ausschnitt = is_array($info['table_excerpt'] ?? null) ? $info['table_excerpt'] : [];
$opponentName = (string) ($info['opponent_display'] ?? $info['opponent'] ?? 'Unser Gegner');
// Links steht IMMER unsere Mannschaft (Klasse --own hebt sie hervor), rechts der
// Gegner. Bei einem Heimspiel ist das ohnehin die Reihenfolge Heim/Gast; fährt die
// Mannschaft aber auswärts, trüge die Hervorhebung sonst der gegnerische Verein.
$istHeim = !empty($fixture['is_home']);
$ownName = (string) (($istHeim ? ($fixture['home'] ?? null) : ($fixture['away'] ?? null)) ?? 'TSV 08 Kulmbach');
$ownCrest = ($istHeim ? ($fixture['home_crest'] ?? null) : ($fixture['away_crest'] ?? null)) ?? $own['crest'] ?? null;
$gegnerLabel = $opponentName;
$gegnerCrest = ($istHeim ? ($fixture['away_crest'] ?? null) : ($fixture['home_crest'] ?? null)) ?? $opponent['crest'] ?? null;
$kickoff = (string) ($fixture['kickoff'] ?? ''); $kickoff = (string) ($fixture['kickoff'] ?? '');
$kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : ''; $kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : '';
$kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : ''; $kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : '';
// Der Tabellenplatz steht NICHT in der Balkenliste: klein ist dort besser,
// ein Balken läge genau falsch herum. Er gehört nach oben neben die Wappen.
// „Spiele“ fällt ebenfalls raus (für beide gleich, steht in der Kopfzeile).
$rows = [ $rows = [
['label' => 'Tabellenplatz', 'key' => 'rank'],
['label' => 'Spiele', 'key' => 'played'],
['label' => 'Siege', 'key' => 'won'], ['label' => 'Siege', 'key' => 'won'],
['label' => 'Unentschieden', 'key' => 'drawn'], ['label' => 'Unentschieden', 'key' => 'drawn'],
['label' => 'Niederlagen', 'key' => 'lost'], ['label' => 'Niederlagen', 'key' => 'lost'],
@@ -41,6 +55,57 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
$value = (int) $row[$key]; $value = (int) $row[$key];
return $signed && $value > 0 ? '+' . $value : (string) $value; return $signed && $value > 0 ? '+' . $value : (string) $value;
}; };
// Balkenlänge als KLASSE, nie als style-Attribut: die CSP verwirft inline
// styles stillschweigend (siehe CLAUDE.md), der Balken wäre in Produktion
// unsichtbar. Deshalb 5-Prozent-Stufen, die components.css ausdefiniert.
$barStep = static function (?array $row, ?array $other, string $key): int {
if ($row === null) {
return 0;
}
$value = abs((int) ($row[$key] ?? 0));
$scale = max($value, $other === null ? 0 : abs((int) ($other[$key] ?? 0)));
if ($scale <= 0) {
return 0;
}
return (int) round($value / $scale * 20);
};
// Ein Satz statt einer Zahlenkolonne: derselbe Bestand, nur in Sprache. Die
// erste zutreffende Regel gewinnt, damit immer genau eine Aussage dasteht.
$zahlwort = static function (int $n): string {
$woerter = [0 => 'Kein', 1 => 'Ein', 2 => 'Zwei', 3 => 'Drei', 4 => 'Vier', 5 => 'Fünf',
6 => 'Sechs', 7 => 'Sieben', 8 => 'Acht', 9 => 'Neun', 10 => 'Zehn', 11 => 'Elf',
12 => 'Zwölf', 13 => 'Dreizehn', 14 => 'Vierzehn', 15 => 'Fünfzehn'];
return $woerter[$n] ?? (string) $n;
};
$aufmacher = '';
if ($opponent !== null) {
$played = (int) ($opponent['played'] ?? 0);
$won = (int) ($opponent['won'] ?? 0);
$lost = (int) ($opponent['lost'] ?? 0);
$rank = (int) ($opponent['rank'] ?? 0);
$points = (int) ($opponent['points'] ?? 0);
$diff = (int) ($opponent['goal_diff'] ?? 0);
if ($played > 0 && $lost === 0) {
$aufmacher = 'Noch ohne Niederlage';
} elseif ($played > 0 && $won === 0) {
$aufmacher = 'Noch ohne Sieg';
} elseif ($played > 0 && $won / $played >= 0.7) {
$aufmacher = $zahlwort($won) . ' Siege aus ' . mb_strtolower($zahlwort($played)) . ' Spielen';
} elseif ($rank === 1) {
$aufmacher = 'Tabellenführer mit ' . $points . ' Punkten';
} elseif ($played > 0 && $diff <= -8) {
$aufmacher = $zahlwort(abs($diff)) . ' Tore mehr kassiert als geschossen';
} elseif ($played > 0 && $diff >= 8) {
$aufmacher = $zahlwort($diff) . ' Tore mehr geschossen als kassiert';
} elseif ($played > 0) {
$aufmacher = $points . ' Punkte aus ' . $played . ' Spielen';
}
}
$spieltagInfo = $opponent !== null && (int) ($opponent['played'] ?? 0) > 0
? 'Stand nach ' . (int) $opponent['played'] . ' Spieltagen'
: '';
?> ?>
<div class="stadionzeitung-gegner"> <div class="stadionzeitung-gegner">
<header class="stadionzeitung-page__head"> <header class="stadionzeitung-page__head">
@@ -53,25 +118,26 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
<span class="stadionzeitung-page__rule" aria-hidden="true"></span> <span class="stadionzeitung-page__rule" aria-hidden="true"></span>
<div class="stadionzeitung-gegner__duell"> <div class="stadionzeitung-gegner__duell">
<div class="stadionzeitung-gegner__club stadionzeitung-gegner__club--own"> <div class="stadionzeitung-gegner__club">
<?php component('crest', ['src' => $fixture['home_crest'] ?? $own['crest'] ?? null, 'name' => $fixture['home'] ?? 'TSV 08 Kulmbach']); ?> <?php component('crest', ['src' => $ownCrest, 'name' => $ownName]); ?>
<strong><?= e((string) ($fixture['home'] ?? 'TSV 08 Kulmbach')) ?></strong> <span class="stadionzeitung-gegner__club-name"><?= e($ownName) ?></span>
</div> </div>
<div class="stadionzeitung-gegner__match"> <div class="stadionzeitung-gegner__vs">
<span>gegen</span> <p class="stadionzeitung-gegner__vs-wort"><?= $istHeim ? 'gegen' : 'bei' ?></p>
<?php if ($kickoffDate !== ''): ?> <?php if ($kickoffDate !== ''): ?>
<strong><?= e(german_date($kickoffDate)) ?></strong> <p class="stadionzeitung-gegner__anstoss"><?= e(german_date($kickoffDate)) ?><?php if ($kickoffTime !== ''): ?><br><?= e($kickoffTime) ?> Uhr<?php endif; ?></p>
<?php endif; ?>
<?php if ($kickoffTime !== ''): ?>
<span><?= e($kickoffTime) ?> Uhr</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="stadionzeitung-gegner__club"> <div class="stadionzeitung-gegner__club">
<?php component('crest', ['src' => $fixture['away_crest'] ?? $opponent['crest'] ?? null, 'name' => $fixture['away'] ?? $opponentName]); ?> <?php component('crest', ['src' => $gegnerCrest, 'name' => $gegnerLabel]); ?>
<strong><?= e((string) ($fixture['away'] ?? $opponentName)) ?></strong> <span class="stadionzeitung-gegner__club-name"><?= e($gegnerLabel) ?></span>
</div> </div>
</div> </div>
<?php if ($aufmacher !== ''): ?>
<p class="stadionzeitung-gegner__aufmacher"><?= e($aufmacher) ?></p>
<?php endif; ?>
<?php if ($own !== null && $opponent !== null): ?> <?php if ($own !== null && $opponent !== null): ?>
<div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich"> <div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich">
<div class="stadionzeitung-gegner__vergleich-head" role="row"> <div class="stadionzeitung-gegner__vergleich-head" role="row">
@@ -79,18 +145,33 @@ $format = static function (?array $row, string $key, bool $signed = false): stri
Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe
mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?> mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?>
<span role="columnheader">Wir</span> <span role="columnheader">Wir</span>
<span role="columnheader">Vergleich</span> <span role="columnheader"><?= e($spieltagInfo !== '' ? $spieltagInfo : 'Vergleich') ?></span>
<span role="columnheader">Gegner</span> <span role="columnheader">Gegner</span>
</div> </div>
<?php foreach ($rows as $row): ?> <?php foreach ($rows as $row): ?>
<div class="stadionzeitung-gegner__vergleich-row" role="row"> <div class="stadionzeitung-gegner__vergleich-row" role="row">
<strong role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong> <strong class="stadionzeitung-gegner__wert" role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
<span role="rowheader"><?= e((string) $row['label']) ?></span> <span class="stadionzeitung-gegner__balken stadionzeitung-gegner__balken--own" aria-hidden="true"><span class="stadionzeitung-gegner__balken-fuellung stadionzeitung-gegner__balken-fuellung--s<?= $barStep($own, $opponent, (string) $row['key']) ?>"></span></span>
<strong role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong> <span class="stadionzeitung-gegner__label" role="rowheader"><?= e((string) $row['label']) ?></span>
<span class="stadionzeitung-gegner__balken" aria-hidden="true"><span class="stadionzeitung-gegner__balken-fuellung stadionzeitung-gegner__balken-fuellung--s<?= $barStep($opponent, $own, (string) $row['key']) ?>"></span></span>
<strong class="stadionzeitung-gegner__wert" role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php else: ?> <?php else: ?>
<p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p> <p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p>
<?php endif; ?> <?php endif; ?>
<?php /* Der Tabellenplatz stand kurz als zwei lose Ziffern oben im Duell und
wirkte dort unruhig (Felix, 19.09.2026). Hier zeigt ihn der Ausschnitt
im Zusammenhang: wer steht zwischen uns und ihnen. Gerendert mit der
geteilten league-table-Komponente, nie mit einer zweiten Tabelle. */ ?>
<?php if ($ausschnitt !== []): ?>
<div class="stadionzeitung-gegner__ausschnitt">
<p class="stadionzeitung-gegner__ausschnitt-titel">Die Tabelle dazwischen</p>
<?php foreach ($ausschnitt as $block): ?>
<?php component('league-table', ['table' => $block, 'caption' => 'Tabellenausschnitt ' . (string) ($info['league'] ?? '')]); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/** /**
* Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste: * Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste:
* riesige zentrierte Coolvetica-Headline („Werde Teil des Nullachters", das * riesige zentrierte Coolvetica-Headline („Werde Teil unseres Vereins", das
* letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile, * letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile,
* unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein * unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein
* jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den * jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den
@@ -25,8 +25,8 @@ $mitgliedLink = club_domain('mitglied-werden');
<img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97"> <img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<h2 class="stadionzeitung-mitglied__headline"> <h2 class="stadionzeitung-mitglied__headline">
<span class="stadionzeitung-mitglied__line">Werde Teil</span> <span class="stadionzeitung-mitglied__line">Werde Teil</span>
<span class="stadionzeitung-mitglied__line">des</span> <span class="stadionzeitung-mitglied__line">unseres</span>
<span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Nullachters</span> <span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Vereins</span>
</h2> </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__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> <p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p>

View File

@@ -26,7 +26,7 @@ $total = count($images);
if ($images === []) { if ($images === []) {
return; return;
} }
$opponent = (string) ($issue['opponent'] ?? ''); $opponent = stadionzeitung_opponent_display($issue);
// Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält. // Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält.
$hasLive = false; $hasLive = false;

View File

@@ -992,6 +992,22 @@ function stadionzeitung_teaser(array $issue): string
return trim((string) ($issue['interview']['teaser'] ?? '')); return trim((string) ($issue['interview']['teaser'] ?? ''));
} }
/**
* Der Name des Gegners, wie er GEDRUCKT wird — Cover, Archivzeile, Kopf, Meta.
*
* `opponent` muss die BFV-Schreibweise tragen, weil die Gegnerseite damit ihre
* Tabellenzeile und ihre Begegnung in matchcenter.json findet (exakter
* Namensvergleich). Bei Spielgemeinschaften ist das eine Zeile wie
* „(SG1) TSV Wirsberg I/ FC Kupferberg I", die auf einem Cover nichts zu
* suchen hat. `opponent_short` ist deshalb der im Chat gepflegte Anzeigename
* („Wirsberg/Kupferberg"); fehlt er, bleibt es beim vollen Namen.
*/
function stadionzeitung_opponent_display(array $issue): string
{
$short = trim((string) ($issue['opponent_short'] ?? ''));
return $short !== '' ? $short : trim((string) ($issue['opponent'] ?? ''));
}
/** /**
* Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE * Welche Mannschaften bekommen in einer Ausgabe eine Gegnerseite — die EINE
* Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND * Logik dafür, benutzt von bin/stadionzeitung-add.php (Seitenplanung) UND
@@ -999,10 +1015,19 @@ function stadionzeitung_teaser(array $issue): string
* oder Daten ohne Seite entstehen. * oder Daten ohne Seite entstehen.
* *
* Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`). * Immer: die tragende Mannschaft (Gegner = `opponent`, Tag = `date`).
* Zusätzlich, wenn die Ausgabe eine `veranstaltung` (Slug) mit Zeitraum trägt * Zusätzlich auf zwei Wegen, die sich nicht in die Quere kommen:
* (Sportfest-Ausgabe): jede weitere Mannschaft aus matchcenter.json mit einem *
* HEIMspiel in diesem Zeitraum — drei Heimspiele am Fest, drei Gegnerseiten. * 1. `veranstaltung` (Slug) mit Zeitraum — die Sportfest-Ausgabe: jede weitere
* Der Gegner ist dann der andere Verein der Begegnung. Reihenfolge wie in * Mannschaft aus matchcenter.json mit einem HEIMspiel in diesem Zeitraum.
* Am Fest spielen alle zuhause, das ist der Aufhänger.
* 2. `gegner_teams` (Liste von Team-Keys, im Chat gepflegt) — für ein normales
* Spielwochenende, an dem auch die anderen Mannschaften antreten. Genommen
* wird ihr nächstes Spiel im Fenster von zwei Tagen vor bis drei Tage nach
* dem Ausgabedatum, AUCH auswärts: der Gegner der Frauen ist auch dann eine
* Seite wert, wenn wir zu ihm fahren. Ohne Spiel in diesem Fenster entsteht
* keine Seite; bin/stadionzeitung-add.php meldet das.
*
* Der Gegner ist jeweils der andere Verein der Begegnung. Reihenfolge wie in
* matchcenter.json (= config 'matchcenter.teams'). * matchcenter.json (= config 'matchcenter.teams').
* *
* Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']] * Rückgabe: [team_key => ['opponent' => string, 'date' => 'YYYY-MM-DD']]
@@ -1013,14 +1038,19 @@ function stadionzeitung_gegner_fixtures(array $issue): array
$issueTeam = (string) ($issue['team_key'] ?? ''); $issueTeam = (string) ($issue['team_key'] ?? '');
$issueOpponent = trim((string) ($issue['opponent'] ?? '')); $issueOpponent = trim((string) ($issue['opponent'] ?? ''));
$von = ''; $eventVon = '';
$bis = ''; $eventBis = '';
$veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? '')); $veranstaltung = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
if ($veranstaltung !== null) { if ($veranstaltung !== null) {
$von = (string) ($veranstaltung['date_start'] ?? ''); $eventVon = (string) ($veranstaltung['date_start'] ?? '');
$bis = (string) ($veranstaltung['date_end'] ?? $von); $eventBis = (string) ($veranstaltung['date_end'] ?? $eventVon);
} }
$extraTeams = array_values(array_filter(array_map(
static fn ($k): string => trim((string) $k),
is_array($issue['gegner_teams'] ?? null) ? $issue['gegner_teams'] : []
)));
$fixtures = []; $fixtures = [];
foreach (json_load('matchcenter')['teams'] ?? [] as $team) { foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
$key = (string) ($team['key'] ?? ''); $key = (string) ($team['key'] ?? '');
@@ -1033,18 +1063,32 @@ function stadionzeitung_gegner_fixtures(array $issue): array
} }
continue; continue;
} }
// Weg 2: ausdrücklich benannte Mannschaft, Fenster ums Ausgabedatum,
// Heim wie auswärts. Steht sie in `gegner_teams`, gilt dieses Fenster
// und nicht der Veranstaltungszeitraum.
if (in_array($key, $extraTeams, true) && $date !== '') {
$von = date('Y-m-d', strtotime($date . ' -2 days'));
$bis = date('Y-m-d', strtotime($date . ' +3 days'));
$nurHeim = false;
} else {
$von = $eventVon;
$bis = $eventBis;
$nurHeim = true;
}
if ($von === '') { if ($von === '') {
continue; continue;
} }
foreach ($team['next_matches'] ?? [] as $match) { foreach ($team['next_matches'] ?? [] as $match) {
if (empty($match['is_home'])) { $istHeim = !empty($match['is_home']);
if ($nurHeim && !$istHeim) {
continue; continue;
} }
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10); $matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
if ($matchDate < $von || $matchDate > $bis) { if ($matchDate < $von || $matchDate > $bis) {
continue; continue;
} }
$opponent = trim((string) ($match['away'] ?? '')); $opponent = trim((string) ($istHeim ? ($match['away'] ?? '') : ($match['home'] ?? '')));
if ($opponent === '' || $opponent === 'SPIELFREI') { if ($opponent === '' || $opponent === 'SPIELFREI') {
continue; continue;
} }
@@ -1140,14 +1184,53 @@ function stadionzeitung_build_snapshot(array $issue): array
} }
} }
// `opponent` behält die BFV-Schreibweise (der Namensvergleich oben
// hängt daran), gedruckt wird `opponent_display`: bei der tragenden
// Mannschaft der Kurzname der Ausgabe, sonst der volle Name.
$gegnerDisplay = strcasecmp($gegnerName, trim((string) ($issue['opponent'] ?? ''))) === 0
? stadionzeitung_opponent_display($issue)
: $gegnerName;
// Tabellenausschnitt statt zweier loser Platz-Ziffern im Kopf: ein
// Fenster um beide Mannschaften herum (mit einer Zeile Luft davor
// und dahinter). Liegen sie weit auseinander, werden es zwei
// Blöcke — gerendert wird beides mit der geteilten
// league-table-Komponente, nie mit einer zweiten Tabelle.
$ausschnitt = [];
if ($ownRow !== null && $opponentRow !== null && $table !== []) {
$ranks = [(int) ($ownRow['rank'] ?? 0), (int) ($opponentRow['rank'] ?? 0)];
sort($ranks);
$fenster = static function (int $von, int $bis) use ($table): array {
return array_values(array_filter($table, static function (array $row) use ($von, $bis): bool {
$rank = (int) ($row['rank'] ?? 0);
return $rank >= $von && $rank <= $bis;
}));
};
if ($ranks[1] - $ranks[0] <= 5) {
$block = $fenster($ranks[0], $ranks[1]);
if ($block !== []) {
$ausschnitt[] = $block;
}
} else {
foreach ($ranks as $rank) {
$block = $fenster($rank, $rank + 1);
if ($block !== []) {
$ausschnitt[] = $block;
}
}
}
}
$gegner[$key] = [ $gegner[$key] = [
'team_name' => (string) ($team['name'] ?? ''), 'team_name' => (string) ($team['name'] ?? ''),
'opponent' => $gegnerName, 'opponent' => $gegnerName,
'opponent_display' => $gegnerDisplay,
'league' => (string) ($team['league'] ?? ''), 'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''), 'season' => (string) ($team['season'] ?? ''),
'fixture' => $fixture, 'fixture' => $fixture,
'own' => $ownRow, 'own' => $ownRow,
'opponent_stats' => $opponentRow, 'opponent_stats' => $opponentRow,
'table_excerpt' => $ausschnitt,
]; ];
} }

View File

@@ -78,14 +78,14 @@ $firstImage = $issue['images'][0] ?? null;
$cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null; $cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null;
$meta = [ $meta = [
'title' => 'Stadionzeitung gegen ' . $issue['opponent'] . ($isDruck ? ' (Druckfassung)' : ''), 'title' => 'Stadionzeitung gegen ' . stadionzeitung_opponent_display($issue) . ($isDruck ? ' (Druckfassung)' : ''),
'description' => 'Stadionzeitung zum Heimspiel gegen ' . $issue['opponent'] . ' am ' . german_date($issue['date']) . ' – digital zum Durchblättern.', 'description' => 'Stadionzeitung zum Heimspiel gegen ' . stadionzeitung_opponent_display($issue) . ' am ' . german_date($issue['date']) . ' – digital zum Durchblättern.',
'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'], 'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'],
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'], ['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'], ['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
['name' => 'Gegen ' . $issue['opponent'], 'slug' => 'stadionzeitung/' . $issue['slug']], ['name' => 'Gegen ' . stadionzeitung_opponent_display($issue), 'slug' => 'stadionzeitung/' . $issue['slug']],
]), ]),
]; ];
if ($cover !== null) { if ($cover !== null) {
@@ -113,7 +113,7 @@ $druckBlaetter = (int) ceil($druckSeiten / 4);
<a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>"> <a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>">
<?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?> <?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?>
</a> </a>
<p class="stadionzeitung-issue-head__title">Gegen <?= e($issue['opponent']) ?></p> <p class="stadionzeitung-issue-head__title">Gegen <?= e(stadionzeitung_opponent_display($issue)) ?></p>
<?php if ($isDruck): ?> <?php if ($isDruck): ?>
<p class="stadionzeitung-issue-head__actions"> <p class="stadionzeitung-issue-head__actions">
<button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden> <button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden>

View File

@@ -202,6 +202,11 @@ $oldAufruf = $oldIssue['aufruf'] ?? null;
// Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt. // Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt.
$oldVeranstaltung = $oldIssue['veranstaltung'] ?? null; $oldVeranstaltung = $oldIssue['veranstaltung'] ?? null;
$oldCover = $oldIssue['cover'] ?? null; $oldCover = $oldIssue['cover'] ?? null;
// Anzeigename des Gegners fürs Cover und die Liste ("Wirsberg/Kupferberg" statt
// der BFV-Zeile) und die Liste der Mannschaften, die zusätzlich eine
// Gegnerseite bekommen — beides Chat-Pflege, überlebt erneute Läufe wie vorwort.
$oldOpponentShort = $oldIssue['opponent_short'] ?? null;
$oldGegnerTeams = is_array($oldIssue['gegner_teams'] ?? null) ? array_values($oldIssue['gegner_teams']) : null;
$veranstaltung = null; $veranstaltung = null;
if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') { if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') {
$veranstaltung = veranstaltung_find($oldVeranstaltung); $veranstaltung = veranstaltung_find($oldVeranstaltung);
@@ -376,13 +381,22 @@ try {
// dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut, // dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut,
// damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen. // damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen.
$gegnerSeiten = []; $gegnerSeiten = [];
foreach (stadionzeitung_gegner_fixtures([ $gegnerFixtures = stadionzeitung_gegner_fixtures([
'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent, 'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent,
'veranstaltung' => $oldVeranstaltung, 'veranstaltung' => $oldVeranstaltung, 'gegner_teams' => $oldGegnerTeams ?? [],
]) as $gegnerTeam => $fixture) { ]);
foreach ($gegnerFixtures as $gegnerTeam => $fixture) {
$gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]); $gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]);
$log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']})."); $log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']}).");
} }
// Eine benannte Mannschaft ohne Spiel im Fenster bekommt keine Seite. Das ist
// richtig so, darf aber nicht stillschweigend passieren: ein Tippfehler im
// Team-Key sähe genauso aus wie ein spielfreies Wochenende.
foreach ($oldGegnerTeams ?? [] as $gewuenscht) {
if (!isset($gegnerFixtures[(string) $gewuenscht])) {
$log("gegner_teams: '{$gewuenscht}' hat kein Spiel im Fenster um {$date} (oder der Team-Key ist falsch) — keine Gegnerseite.", 'WARN');
}
}
if ($gegnerSeiten === []) { if ($gegnerSeiten === []) {
// Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft — // Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft —
// sie meldet dann selbst, dass keine Vergleichsdaten vorliegen. // sie meldet dann selbst, dass keine Vergleichsdaten vorliegen.
@@ -425,7 +439,7 @@ try {
if ($rueckseite !== null) { if ($rueckseite !== null) {
$bloecke[] = [ $bloecke[] = [
'name' => 'rueckseite', 'name' => 'rueckseite',
'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen {$opponent}")], 'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen " . stadionzeitung_opponent_display(['opponent' => $opponent, 'opponent_short' => $oldOpponentShort]))],
'anzeigen_danach' => false, 'anzeigen_danach' => false,
]; ];
} }
@@ -734,6 +748,8 @@ if ($oldIssue !== null && ($oldIssue['status'] ?? '') === 'veroeffentlicht') {
$issues[] = [ $issues[] = [
'slug' => $slug, 'slug' => $slug,
'opponent' => $opponent, 'opponent' => $opponent,
'opponent_short' => $oldOpponentShort,
'gegner_teams' => $oldGegnerTeams,
'date' => $date, 'date' => $date,
'team_key' => $teamKey, 'team_key' => $teamKey,
'status' => 'entwurf', 'status' => 'entwurf',

View File

@@ -1,5 +1,520 @@
{ {
"issues": [ "issues": [
{
"slug": "2026-09-19-sg-wirsberg-kupferberg",
"opponent": "(SG1) TSV Wirsberg I/ FC Kupferberg I",
"opponent_short": "Wirsberg/Kupferberg",
"gegner_teams": [
"zweite-mannschaft",
"damen"
],
"date": "2026-09-19",
"team_key": "erste-mannschaft",
"status": "entwurf",
"pages": 44,
"player_image": {
"base": "img/stadionzeitung/2026-09-19-sg-wirsberg-kupferberg/spieler",
"widths": [
320,
640,
1000
],
"ext": "png",
"alt": ""
},
"qr_image": "img/stadionzeitung/2026-09-19-sg-wirsberg-kupferberg/qr.png",
"vorwort": {
"heading": "Herzlich willkommen im Katzbachtal",
"paragraphs": [
"Liebe Fußballfreunde,",
"herzlich willkommen im Katzbachtal. Ein besonderer Gruß gilt dem Schiedsrichtergespann und unseren Gästen von der SG Wirsberg/Kupferberg.",
"Das 0:2 in Trebgast war ein Rückschlag. Die letzten beiden Heimspiele haben wir aber gewonnen, und an diese Auftritte wollen wir heute gegen den Spitzenreiter anknüpfen.",
"Und es bleibt ein volles Wochenende: Um 16 Uhr spielt unsere Zweite gegen Leuchau II, am Sonntag fahren unsere Frauen als Tabellenführer nach Weidenberg. Darauf freuen wir uns. Auf geht's!"
],
"closing": "Rot-schwarze Grüße",
"signee_name": "Benedikt Höfner",
"signee_role": "Sportlicher Leiter"
},
"interview": {
"teaser": "Unser Trainer Ali Kilic über die Niederlage in Trebgast und den heutigen Gegner",
"heading": "Zwei Fragen an Ali Kilic",
"person_name": "Ali Kilic",
"person_role": "Trainer 1. Mannschaft",
"intro": null,
"fragen": [
{
"frage": "Ali, in Trebgast hat es mit 0:2 nicht geklappt. Wie hast du das Spiel gesehen?",
"antwort": "Der Sieg für Trebgast war grundsätzlich verdient. Wir sind früh in Rückstand geraten, und das hat ihnen bei dem regnerischen Wetter und den schwierigen Verhältnissen in die Karten gespielt. Gerade in unserem Ballbesitzspiel hatten wir zu viele einfache Fehler in der Annahme und der Mitnahme des Balls. Nach der Halbzeitpause hatten wir zwar eine gute Drangphase, aber ohne echte Durchschlagskraft und ohne klare Chancen."
},
{
"frage": "Heute kommt der Spitzenreiter mit sieben Siegen aus acht Spielen. Was erwartest du?",
"antwort": "Wirsberg hat sich mit teilweise starken Spielern verstärkt und steht nicht unerwartet oben in der Tabelle. Wir rechnen damit, dass sie sich anders als Trebgast nicht so tief hinten reinstellen, sondern aktiver sind. Darauf müssen wir uns vorbereiten und gerade in der Defensivarbeit wieder konzentriert sein. Nach vorne bekommen wir dafür vielleicht mehr Räume, die wir trotz einiger Ausfälle nutzen wollen."
}
],
"photo": "img/persons/ali-kilic.jpg"
},
"aufruf": null,
"veranstaltung": null,
"cover": null,
"verfasser_photo": {
"base": "img/stadionzeitung/2026-09-19-sg-wirsberg-kupferberg/verfasser",
"widths": [
120,
222
],
"alt": ""
},
"termine_snapshot": [
{
"date": "2026-09-20",
"date_end": null,
"title": "(SG) SV Weidenberg/TSV Bayreuth-St. Johannis 2 – TSV 08 Kulmbach",
"department": "fussball",
"type": "spiel",
"meta": "Auswärtsspiel · Frauen KL Ost",
"href": "/matchcenter",
"kickoff": "2026-09-20T13:00",
"team_name": "Frauenmannschaft",
"league": "Frauen KL Ost",
"raw": {
"kickoff": "2026-09-20T13:00",
"date_display": "So, 20.09.",
"time_display": "13:00 Uhr",
"competition": "Liga",
"home": "(SG) SV Weidenberg/TSV Bayreuth-St. Johannis 2",
"away": "TSV 08 Kulmbach",
"home_crest": "img/crests/00ES8GNK4O000013VV0AG08LVUPGND5I.png",
"away_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png",
"is_home": false,
"team_key": "damen",
"team_name": "Frauenmannschaft"
}
},
{
"date": "2026-09-27",
"date_end": null,
"title": "TSV 08 Kulmbach – (SG) SpVgg Döbra/FC Konradsreuth/ATS Selbitz",
"department": "fussball",
"type": "spiel",
"meta": "Heimspiel · Frauen KL Ost",
"href": "/matchcenter",
"kickoff": "2026-09-27T10:30",
"team_name": "Frauenmannschaft",
"league": "Frauen KL Ost",
"raw": {
"kickoff": "2026-09-27T10:30",
"date_display": "So, 27.09.",
"time_display": "10:30 Uhr",
"competition": "Liga",
"home": "TSV 08 Kulmbach",
"away": "(SG) SpVgg Döbra/FC Konradsreuth/ATS Selbitz",
"home_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png",
"away_crest": "img/crests/00ES8GNK8G00000IVV0AG08LVUPGND5I.png",
"is_home": true,
"team_key": "damen",
"team_name": "Frauenmannschaft"
}
},
{
"date": "2026-09-27",
"date_end": null,
"title": "1. FC Kirchleus – TSV 08 Kulmbach",
"department": "fussball",
"type": "spiel",
"meta": "Auswärtsspiel · Kreisklasse",
"href": "/matchcenter",
"kickoff": "2026-09-27T14:00",
"team_name": "1. Mannschaft",
"league": "Kreisklasse",
"raw": {
"kickoff": "2026-09-27T14:00",
"date_display": "So, 27.09.",
"time_display": "14:00 Uhr",
"competition": "Liga",
"home": "1. FC Kirchleus",
"away": "TSV 08 Kulmbach",
"home_crest": "img/crests/00ES8GNK3G00000RVV0AG08LVUPGND5I.png",
"away_crest": "img/crests/00ES8GNK3G000012VV0AG08LVUPGND5I.png",
"is_home": false,
"team_key": "erste-mannschaft",
"team_name": "1. Mannschaft"
}
},
{
"date": "2027-02-20",
"date_end": null,
"title": "Tagesskifahrt 2027",
"department": "verein",
"type": "event",
"meta": "Skigebiet Hochzillertal",
"href": "/veranstaltungen/tagesskifahrt-2027",
"time": "",
"location": "Skigebiet Hochzillertal",
"raw": {
"slug": "tagesskifahrt-2027",
"title": "Tagesskifahrt 2027",
"date_start": "2027-02-20",
"date_end": null,
"time": "",
"location": "Skigebiet Hochzillertal",
"department": "verein",
"status": "veroeffentlicht",
"text": "Wir fahren für einen Tag zum Skifahren ins Skigebiet Hochzillertal. Die Plätze sind auf 40 Personen begrenzt, angemeldet wird bei Fabian Früh.",
"ankuendigung": [
"Einmal im Winter machen wir uns gemeinsam auf und fahren für einen Tag ins Skigebiet Hochzillertal bei Kaltenbach. Ob Erwachsene, Jugendliche oder Kinder: Auf den Pisten ist für alle etwas dabei. Die Fahrt ist auf 40 Personen begrenzt, wer dabei sein möchte, meldet sich am besten früh.",
"Erwachsene zahlen 115 Euro, Jugendliche 95 Euro und Kinder 70 Euro. Anmelden könnt ihr euch bei Fabian Früh unter 0160 97516797."
],
"rueckblick": null,
"karte": "",
"anfahrt": [],
"plakate": [
{
"base": "img/veranstaltungen/tagesskifahrt-2027/plakat-01",
"widths": [
640,
1000,
1024
],
"alt": "Plakat zu unserer Tagesskifahrt am Samstag, 20. Februar 2027 ins Skigebiet Hochzillertal, mit den Preisen für Erwachsene, Jugendliche und Kinder und der Anmeldung bei Fabian Früh.",
"source": "1-ankuendigung.jpeg"
}
],
"galerie": []
}
}
],
"images": [
{
"type": "live",
"page": "cover"
},
{
"type": "live",
"page": "vorwort"
},
{
"type": "live",
"page": "interview"
},
{
"type": "image",
"base": "img/anzeigen/eldorado",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: eldorado Radsporttechnik Kulmbach"
},
{
"type": "image",
"base": "img/anzeigen/egu",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Erfrischungsgetränke-Union Kulmbacher Gruppe GmbH"
},
{
"type": "live",
"page": "ergebnisse"
},
{
"type": "image",
"base": "img/anzeigen/hofmann-graef",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Buchhandlung Hofmann, Kulmbach, und Automobilwerkstatt Gräf, Thurnau"
},
{
"type": "image",
"base": "img/anzeigen/muench-richterstegner",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Planungsbüro Münch und Richter & Stegner Steuerungstechnik"
},
{
"type": "live",
"page": "gegner",
"team": "erste-mannschaft"
},
{
"type": "live",
"page": "gegner",
"team": "zweite-mannschaft"
},
{
"type": "live",
"page": "gegner",
"team": "damen"
},
{
"type": "image",
"base": "img/anzeigen/malteser-herrmann",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Malteser-Hilfsdienst und Schlosserei Herrmann, Kulmbach"
},
{
"type": "image",
"base": "img/anzeigen/apotheken-wenkel",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Apotheke im Fritz, Stadtpark Apotheke und Schlüsseldienst Wenkel, Kulmbach"
},
{
"type": "live",
"page": "tabelle",
"team": "erste-mannschaft"
},
{
"type": "live",
"page": "tabelle",
"team": "zweite-mannschaft"
},
{
"type": "live",
"page": "tabelle",
"team": "damen"
},
{
"type": "image",
"base": "img/anzeigen/karl-geyer",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Geyer Farbe + Raum, Kulmbach, Starthilfe Boden"
},
{
"type": "live",
"page": "vorschau"
},
{
"type": "image",
"base": "img/anzeigen/gsb",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: GSB Security und GSB Gebäude Service, Bindlach"
},
{
"type": "image",
"base": "img/anzeigen/physiofit-wohlfart",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Physio Fit Neudrossenfeld und Textilpflege Wohlfart, Kulmbach"
},
{
"type": "live",
"page": "termine"
},
{
"type": "image",
"base": "img/anzeigen/sport2000-pizza",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Sport 2000 Downstairs und Kulmbacher Pizza-Service"
},
{
"type": "image",
"base": "img/anzeigen/schulz-csu",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Schreinerei Schulz, Mainleus, und CSU Stadtverband Kulmbach"
},
{
"type": "live",
"page": "news"
},
{
"type": "image",
"base": "img/anzeigen/mattis-ka2",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Karl Mattis Flaschnerei und KA2 Fitness, Kulmbach"
},
{
"type": "live",
"page": "kontakt"
},
{
"type": "image",
"base": "img/anzeigen/motor-nuetzel",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Motor-Nützel Vertriebs-GmbH, Bayreuth"
},
{
"type": "image",
"base": "img/anzeigen/vereinskarte-edeka",
"widths": [
480,
960,
1410
],
"alt": "Eigenanzeige: So funktioniert die Vereinskarte beim E center Seidl Kulmbach"
},
{
"type": "live",
"page": "mitglied"
},
{
"type": "image",
"base": "img/anzeigen/trapper-verkehrsakademie",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Trapper GmbH Entsorgungsfachbetrieb und Verkehrsakademie, Kulmbach"
},
{
"type": "image",
"base": "img/anzeigen/ohnemueller-dippold",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Gaststätte und Metzgerei Ohne-Müller, Kulmbach-Weiher, und Bäckerei Dippold, Melkendorf"
},
{
"type": "live",
"page": "historie"
},
{
"type": "image",
"base": "img/anzeigen/hanft",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Hanft Bürotechnik GmbH, Kulmbach"
},
{
"type": "live",
"page": "sportheim"
},
{
"type": "image",
"base": "img/anzeigen/elma-kleinheinz",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: elma GmbH Bayreuth und Metzgerei Kleinheinz, Kulmbach"
},
{
"type": "image",
"base": "img/anzeigen/stadionzeitung-digital",
"widths": [
480,
960,
1440
],
"alt": "Eigenanzeige: Unsere Stadionzeitung gibt es jetzt auch online, mit QR-Code zum Scannen"
},
{
"type": "live",
"page": "partner"
},
{
"type": "live",
"page": "danke",
"anzeige": {
"base": "img/anzeigen/emons",
"widths": [
480,
960,
1141
],
"alt": "Anzeige: Emons Spedition Himmelkron, Ihr Partner für nationale und internationale Transporte"
}
},
{
"type": "image",
"base": "img/anzeigen/gagel-pickelmann",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Gagel Bäder und Mineralölhandel Pickelmann"
},
{
"type": "image",
"base": "img/anzeigen/schramm",
"widths": [
480,
960,
1440
],
"alt": "Anzeige: Landgasthof und Metzgerei Schramm, Forstlahm"
},
{
"type": "live",
"page": "momente"
},
{
"type": "image",
"base": "img/anzeigen/tagesskifahrt-2027",
"widths": [
480,
960,
1440
],
"alt": "Eigenanzeige: Unsere Tagesskifahrt ins Hochzillertal am 20. Februar 2027"
},
{
"type": "live",
"page": "impressum"
},
{
"type": "image",
"base": "img/stadionzeitung/2026-09-19-sg-wirsberg-kupferberg/rueckseite",
"widths": [
480,
960,
1410
],
"alt": "Rückseite der Stadionzeitung gegen Wirsberg/Kupferberg"
}
]
},
{ {
"slug": "2026-09-04-ats-wartenfels", "slug": "2026-09-04-ats-wartenfels",
"opponent": "ATS Wartenfels", "opponent": "ATS Wartenfels",

View File

@@ -7261,74 +7261,136 @@ a.news-feature__link:hover .news-feature__more {
} }
/* === stadionzeitung-ergebnisse (Live-Seite) === */ /* === stadionzeitung-ergebnisse (Live-Seite) === */
/* Ergebnis-Rückblick: zwei Zeilen pro Spiel (Heim über Gast, wie das /* Ergebnis-Rückblick als Anzeigetafel: Wappen und Vereinsname je Seite, das
„Nächste Spiele"-Band) — lange Vereinsnamen passen auf der schmalen Ergebnis groß in der Mitte (Vorbild kicker/Sofascore). Vierter Entwurf,
A5-Seite nie in eine Zeile. Eigene Mannschaft über Gewicht der Body-Schrift 19.09.2026 — die drei Vorgänger scheiterten alle an derselben Ursache:
markiert (Abel hat echte Schnitte — anders als Coolvetica erlaubt, siehe sie waren Listenzeilen, und eine Zeile stellt Heim und Gast untereinander
CLAUDE.md „nie über font-weight" bei Coolvetica). */ statt gegenüber, obwohl eine Begegnung etwas Symmetrisches ist. Mit den
Toren rechts am Blattrand lag halbe Seitenbreite Leere zwischen Name und
Ziffer, eine getönte Karte dagegen sah „altbacken" aus, und mit den Toren
links fehlte der Seite die Hierarchie.
Die Mittelachse löst zusätzlich das Namensproblem: lange Namen wie
„(SG1) TSV Melkendorf III/ TSV 08 Kulmbach II" umbrechen in ihrer Spalte
über mehrere Zeilen, ohne dass irgendetwas verrutscht — deshalb steht der
Name UNTER dem Wappen und mittig, nicht daneben.
Flächen gibt es keine, nur Linien: Haarlinie zwischen den Begegnungen,
roter Strich unter der Mannschaftszeile, sonst weißes Blatt. */
/* Trennung der Mannschaften: Haarlinie plus reichlich Abstand auf beiden
Seiten. Die Linie liegt am Abschnitt, nicht an der letzten Begegnung darin —
sonst wanderte sie bei mehreren Spielen pro Mannschaft mitten in den Block. */
.stadionzeitung-ergebnisse__team { .stadionzeitung-ergebnisse__team {
margin-bottom: 4.2cqi; padding-bottom: 4.6cqi;
} margin-bottom: 4.6cqi;
.stadionzeitung-ergebnisse__team-name {
margin-bottom: 1.7cqi;
font-family: var(--ff-body);
font-weight: var(--fw-bold);
font-size: 2.7cqi;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-muted);
}
.stadionzeitung-ergebnisse__match {
padding-bottom: 1.7cqi;
margin-bottom: 1.7cqi;
border-bottom: 1px solid var(--clr-glass-border); border-bottom: 1px solid var(--clr-glass-border);
} }
.stadionzeitung-ergebnisse__match:last-child { .stadionzeitung-ergebnisse__team:last-of-type {
padding-bottom: 0;
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0; border-bottom: 0;
} }
.stadionzeitung-ergebnisse__meta { /* Mannschaftszeile bewusst leise: die Gliederung macht der Abstand zwischen
font-size: 2.5cqi; den Mannschaften, keine Linie (ein roter Strich darunter war gebaut und am
19.09.2026 wieder entfernt — er zog mehr Aufmerksamkeit als die Ergebnisse
darunter). */
.stadionzeitung-ergebnisse__team-name {
margin-bottom: 1.6cqi;
font-family: var(--ff-body);
font-size: 2.4cqi;
text-align: center;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint); color: var(--clr-text-faint);
} }
/* Das Zeilen-Layout gehört auf die ZEILE, nicht auf das Wappen darin (stand .stadionzeitung-ergebnisse__match {
hier mal als `.stadionzeitung-ergebnisse__row .crest` — dann wurde das Wappen
selbst zum Grid, lag als Block-Element auf eigener Zeile, und Vereinsname und
Tore rutschten als Inline-Spans darunter zusammen). `--crest-size` bleibt
trotzdem hier: Custom Properties erben, das Wappen bleibt eine dumme
Komponente und der Kontext gibt ihm seine Größe — so machen es alle anderen
Wappen-Kontexte im Projekt auch. */
.stadionzeitung-ergebnisse__row {
--crest-size: 5.8cqi;
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center; align-items: center;
gap: 1.7cqi; gap: 2.1cqi;
padding-block: 0.6cqi; padding-block: 1.4cqi;
border-bottom: 1px solid var(--clr-glass-border);
} }
.stadionzeitung-ergebnisse__match:last-child {
padding-bottom: 0;
border-bottom: 0;
}
/* Beide Seiten sind gleich gebaut und gleich breit (1fr), damit die Mittelachse
wirklich in der Mitte liegt — auch wenn eine Spielgemeinschaft drei Zeilen
Namen mitbringt und der Gegner eine. `--crest-size` setzt der Kontext, die
crest-Komponente bleibt dumm (gleiches Muster wie in allen anderen
Wappen-Kontexten). */
.stadionzeitung-ergebnisse__side {
--crest-size: 8.5cqi;
display: grid;
justify-items: center;
gap: 1.2cqi;
text-align: center;
}
/* Einzeilig, notfalls ausgepunktet: ein umbrechender Name schiebt die
Anzeigetafel auseinander und die Zeilenpaare stehen dann ungleich hoch
nebeneinander. Die volle Schreibweise steht ohnehin im Matchcenter. */
.stadionzeitung-ergebnisse__club { .stadionzeitung-ergebnisse__club {
min-width: 0; max-width: 100%;
font-size: 3.75cqi; overflow: hidden;
line-height: 1.2; font-size: 3.2cqi;
line-height: 1.15;
white-space: nowrap;
text-overflow: ellipsis;
} }
.stadionzeitung-ergebnisse__row--own .stadionzeitung-ergebnisse__club { .stadionzeitung-ergebnisse__side--own .stadionzeitung-ergebnisse__club {
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
} }
.stadionzeitung-ergebnisse__goals { .stadionzeitung-ergebnisse__score {
display: grid;
justify-items: center;
gap: 0.8cqi;
}
.stadionzeitung-ergebnisse__result {
display: flex;
align-items: baseline;
gap: 0.8cqi;
font-family: var(--ff-heading); font-family: var(--ff-heading);
font-size: 5.8cqi; font-size: 7.4cqi;
line-height: 1; line-height: 1;
} }
/* Das Ergebnis läuft durchgehend in voller Textfarbe: eine zurückgenommene
Verlierer-Ziffer war gebaut und am 19.09.2026 verworfen (Felix) — auf dem
gedruckten Blatt ist eine graue Ziffer schlechter lesbar, und wer gewonnen
hat, sagt die Zahl selbst. */
.stadionzeitung-ergebnisse__meta {
font-size: 2.5cqi;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint);
white-space: nowrap;
}
/* === stadionzeitung-gegner (Live-Seite) === */ /* === stadionzeitung-gegner (Live-Seite) === */
/* Gegnerseite als Duell-Aufmacher plus Balkenvergleich (19.09.2026). Vorher
war das eine rosa getönte Karte mit der Begegnung und darunter eine
Zahlenkolonne aus sieben Zeilen: inhaltlich richtig, gestalterisch ohne
Blickfang, und die untere Blatthälfte blieb leer.
Drei Entscheidungen tragen den Aufbau:
- Das GEGNERWAPPEN ist der Bildanker (deutlich größer als unseres). Die
Seite handelt von ihm, nicht von der Begegnung.
- Der Tabellenplatz steht oben bei den Wappen, NICHT als Balken: dort ist
klein besser, ein Balken läge genau falsch herum.
- Der Vergleich läuft als Balkenpaar aus der Mitte (Vorbild Sofascore).
Die Länge kommt aus einer KLASSE in 5-Prozent-Stufen, nie aus einem
style-Attribut: die CSP verwirft inline styles stillschweigend, der
Balken wäre in Produktion unsichtbar (siehe CLAUDE.md). */
.stadionzeitung-gegner { .stadionzeitung-gegner {
min-height: 100%; min-height: 100%;
} }
@@ -7336,90 +7398,204 @@ a.news-feature__link:hover .news-feature__more {
.stadionzeitung-gegner__duell { .stadionzeitung-gegner__duell {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center; align-items: start;
gap: 2.5cqi; gap: 2.5cqi;
margin-bottom: 5cqi; margin-top: 2.5cqi;
padding: 3.3cqi;
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-btn);
background: color-mix(in srgb, var(--clr-accent) 5%, #fff);
} }
/* Beide Seiten sind gleich gebaut und gleich groß. Der Gegner war hier kurz
mit doppelt so großem Wappen und seinem Namen in Coolvetica gesetzt — damit
stand er zweimal laut auf derselben Seite (Überschrift und Duell) und das
Blatt wirkte unruhig (Felix, 19.09.2026). Die Überschrift nennt ihn, das
Duell zeigt nur noch, wer gegen wen und auf welchem Platz. */
.stadionzeitung-gegner__club { .stadionzeitung-gegner__club {
--crest-size: 12cqi; --crest-size: 11cqi;
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto minmax(7.4cqi, auto);
align-items: center; justify-items: center;
gap: 1.7cqi; gap: 1.2cqi;
min-width: 0; min-width: 0;
text-align: center; text-align: center;
} }
.stadionzeitung-gegner__club strong { .stadionzeitung-gegner__club-name {
font-size: 3.2cqi; font-size: 3.2cqi;
line-height: 1.15; line-height: 1.15;
} }
.stadionzeitung-gegner__club--own strong { /* Mittelspalte: „gegen" und der Anstoß stehen zusammen, damit im Duell nur
color: var(--clr-accent); noch drei Dinge auf einer Höhe liegen (wir, wann, sie). Zwei lose
} Platz-Ziffern und eine freistehende Datumszeile standen hier kurz und
machten den Kopf unruhig (Felix, 19.09.2026) — der Tabellenplatz steht
.stadionzeitung-gegner__match { jetzt im Ausschnitt unter den Balken. */
display: flex; .stadionzeitung-gegner__vs {
flex-direction: column; display: grid;
align-items: center; justify-items: center;
gap: 0.5cqi; gap: 1.2cqi;
font-size: 2.5cqi; padding-top: 3.3cqi;
color: var(--clr-text-muted); text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.stadionzeitung-gegner__match strong { .stadionzeitung-gegner__vs-wort {
font-size: 2.9cqi; font-size: 2.7cqi;
color: var(--clr-text); text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint);
}
.stadionzeitung-gegner__anstoss {
font-size: 2.7cqi;
line-height: 1.4;
color: var(--clr-text-muted);
}
/* Im Ausschnitt entfällt die Zonen-Legende: sie erklärt Auf- und Abstieg,
und das ist die Aufgabe der vollständigen Tabellenseite weiter hinten im
Heft. Hier geht es nur um die Nachbarschaft der beiden Mannschaften. */
.stadionzeitung-gegner__ausschnitt {
margin-top: 3.3cqi;
}
.stadionzeitung-gegner__ausschnitt .league-table__legend {
display: none;
}
/* Der Ausschnitt läuft eine Spur enger als die vollständige Tabellenseite:
er teilt sich das Blatt mit Duell, Aufmacher und Balken. Gemessen: mit den
Maßen der Tabellenseite stand die Seite 16px über der A5-Kante. */
.stadionzeitung-gegner__ausschnitt .league-table th,
.stadionzeitung-gegner__ausschnitt .league-table td {
padding: 0.7cqi 0.9cqi;
}
.stadionzeitung-gegner__ausschnitt .league-table tbody .crest {
--crest-size: 4cqi;
}
.stadionzeitung-gegner__ausschnitt-titel {
margin-bottom: 1.4cqi;
font-size: 2.4cqi;
text-transform: uppercase;
letter-spacing: var(--ls-caps);
color: var(--clr-text-faint);
}
/* Zwei Blöcke (wenn die Mannschaften weit auseinanderliegen) bekommen Luft
dazwischen, sonst sähe es aus wie eine durchgehende Tabelle mit Sprung. */
.stadionzeitung-gegner__ausschnitt .table-scroll-frame + .table-scroll-frame {
margin-top: 2.1cqi;
}
/* Die Tabelle in einem Satz: derselbe Bestand, nur in Sprache. Coolvetica
trägt hier, weil die Zeile kurz ist und nie umbricht. */
.stadionzeitung-gegner__aufmacher {
margin-top: 3.3cqi;
padding-top: 2.5cqi;
border-top: 0.5cqi solid var(--clr-accent);
font-family: var(--ff-heading);
font-size: 5.8cqi;
line-height: 1;
letter-spacing: var(--ls-heading);
text-transform: uppercase;
} }
.stadionzeitung-gegner__vergleich { .stadionzeitung-gegner__vergleich {
border-block: 1px solid var(--clr-glass-border); margin-top: 2.5cqi;
} }
.stadionzeitung-gegner__vergleich-head, .stadionzeitung-gegner__vergleich-head,
.stadionzeitung-gegner__vergleich-row { .stadionzeitung-gegner__vergleich-row {
display: grid; display: grid;
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: 6cqi minmax(0, 1fr) 24cqi minmax(0, 1fr) 6cqi;
align-items: center; align-items: center;
gap: 1.7cqi;
text-align: center; text-align: center;
} }
.stadionzeitung-gegner__vergleich-head { .stadionzeitung-gegner__vergleich-head {
padding-block: 1.7cqi; padding-bottom: 1.4cqi;
font-size: 2.5cqi; margin-bottom: 1.4cqi;
font-weight: var(--fw-bold); border-bottom: 1px solid var(--clr-glass-border);
font-size: 2.4cqi;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: var(--ls-caps); letter-spacing: var(--ls-caps);
color: var(--clr-text-muted); color: var(--clr-text-faint);
}
/* Die beiden äußeren Spaltenköpfe sitzen über den Zahlen, der mittlere über
dem Label — deshalb spannt er über die drei inneren Spalten. */
.stadionzeitung-gegner__vergleich-head span:nth-child(2) {
grid-column: 2 / 5;
} }
.stadionzeitung-gegner__vergleich-row { .stadionzeitung-gegner__vergleich-row {
min-height: 6.5cqi; min-height: 5.8cqi;
border-top: 1px solid var(--clr-glass-border);
} }
.stadionzeitung-gegner__vergleich-row span { .stadionzeitung-gegner__wert {
font-family: var(--ff-heading);
font-size: 4.4cqi;
line-height: 1;
}
.stadionzeitung-gegner__label {
font-size: 2.8cqi; font-size: 2.8cqi;
color: var(--clr-text-muted); color: var(--clr-text-muted);
} }
.stadionzeitung-gegner__vergleich-row strong { /* Balkenpaar: der linke wächst aus der Mitte nach links, der rechte nach
font-family: var(--ff-heading); rechts. Die Spur bleibt sichtbar (helle Fläche), damit auch ein Wert von
font-size: 4.8cqi; null als Zeile lesbar ist. */
line-height: 1; .stadionzeitung-gegner__balken {
display: flex;
justify-content: flex-start;
block-size: 2.2cqi;
border-radius: 1.1cqi;
background: var(--clr-glass-border);
overflow: hidden;
} }
.stadionzeitung-gegner__vergleich-row strong:first-child { .stadionzeitung-gegner__balken--own {
color: var(--clr-accent); justify-content: flex-end;
} }
.stadionzeitung-gegner__balken-fuellung {
display: block;
block-size: 100%;
border-radius: 1.1cqi;
background: var(--clr-text);
}
/* Unsere Seite rot, die des Gegners schwarz: die einzige Farbe der Seite
steht damit bei uns, ohne dass Rot Fließtext tragen müsste. */
.stadionzeitung-gegner__balken--own .stadionzeitung-gegner__balken-fuellung {
background: var(--clr-accent);
}
/* 5-Prozent-Stufen, siehe Kommentar oben (kein style-Attribut). */
.stadionzeitung-gegner__balken-fuellung--s0 { inline-size: 0%; }
.stadionzeitung-gegner__balken-fuellung--s1 { inline-size: 5%; }
.stadionzeitung-gegner__balken-fuellung--s2 { inline-size: 10%; }
.stadionzeitung-gegner__balken-fuellung--s3 { inline-size: 15%; }
.stadionzeitung-gegner__balken-fuellung--s4 { inline-size: 20%; }
.stadionzeitung-gegner__balken-fuellung--s5 { inline-size: 25%; }
.stadionzeitung-gegner__balken-fuellung--s6 { inline-size: 30%; }
.stadionzeitung-gegner__balken-fuellung--s7 { inline-size: 35%; }
.stadionzeitung-gegner__balken-fuellung--s8 { inline-size: 40%; }
.stadionzeitung-gegner__balken-fuellung--s9 { inline-size: 45%; }
.stadionzeitung-gegner__balken-fuellung--s10 { inline-size: 50%; }
.stadionzeitung-gegner__balken-fuellung--s11 { inline-size: 55%; }
.stadionzeitung-gegner__balken-fuellung--s12 { inline-size: 60%; }
.stadionzeitung-gegner__balken-fuellung--s13 { inline-size: 65%; }
.stadionzeitung-gegner__balken-fuellung--s14 { inline-size: 70%; }
.stadionzeitung-gegner__balken-fuellung--s15 { inline-size: 75%; }
.stadionzeitung-gegner__balken-fuellung--s16 { inline-size: 80%; }
.stadionzeitung-gegner__balken-fuellung--s17 { inline-size: 85%; }
.stadionzeitung-gegner__balken-fuellung--s18 { inline-size: 90%; }
.stadionzeitung-gegner__balken-fuellung--s19 { inline-size: 95%; }
.stadionzeitung-gegner__balken-fuellung--s20 { inline-size: 100%; }
.stadionzeitung-gegner__empty { .stadionzeitung-gegner__empty {
padding: 4.2cqi; padding: 4.2cqi;
border: 1px solid var(--clr-glass-border); border: 1px solid var(--clr-glass-border);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 B

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 B

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 427 B