Ein Aufmacher über die volle Breite

Die Startseite begann bei einer Reihe aus 150 px breiten Plakaten — das
grösste Element über dem Falz war 225 px hoch. Plex und Netflix öffnen mit
einem Bild über die ganze Breite, und das ist der grösste Einzelunterschied
im Eindruck. Ein Layout-, kein Farbproblem.

Bis zu sieben Titel, aus dem, was die Startseite ohnehin holt: erst
Angefangenes, dann Neuzugänge, dann Vorschläge. Kein zusätzlicher Abruf.

Nur mit Hintergrundbild. Ein Hero ohne Bild ist schlechter als keiner —
eine graue Fläche über den halben Bildschirm sagt nichts und nimmt den
Reihen den Platz. Gibt es keinen einzigen Titel mit Backdrop, entfällt er
ersatzlos.

Zwei Verläufe statt einem: der von unten lässt die erste Reihe sich
darüberschieben, der von links sorgt dafür, dass der Text trägt, auch wenn
das Bild dort hell ist.

Pause bei Mauszeiger und bei Tastaturfokus — wer gerade liest, soll nicht
nach neun Sekunden woanders landen. Bei "Bewegung reduzieren" wechselt er
gar nicht von selbst. Die Punkte sind echte Knöpfe mit Beschriftung; ein
div ist für Tastatur und Vorlesesoftware kein Bedienelement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:34:57 +02:00
co-authored by Claude Opus 5
parent 6bd9cdb12d
commit 11a44f6a90
3 changed files with 311 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
import { bildAdresse, type Film, type Serie } from './api';
import { icons } from './icons';
import { knopf } from './util';
/** Was im Hero stehen kann — Film oder Serie, mehr braucht es nicht. */
export type Heroeintrag = {
art: 'filme' | 'serien';
id: string;
titel: string;
schriftzug: string | null;
hintergrund: string;
jahr: number | null;
laufzeit: number | null;
freigabe: string | null;
bewertung: number | null;
beschreibung: string | null;
};
const WECHSEL_MS = 9000;
const HOECHSTENS = 7;
/** Der Aufmacher der Startseite.
*
* **Warum überhaupt.** Die Startseite begann bei einer Reihe aus 150 px
* breiten Plakaten — das grösste Element über dem Falz war 225 px hoch. Plex
* und Netflix öffnen mit einem Bild über die volle Breite, und das ist der
* grösste Einzelunterschied im Eindruck. Es ist ein Layout-, kein
* Farbproblem.
*
* **Nur mit Hintergrundbild.** Ein Hero ohne Bild ist schlechter als keiner:
* eine graue Fläche über den halben Bildschirm sagt nichts und nimmt den
* Reihen den Platz. Gibt es keinen einzigen Titel mit Backdrop, entfällt er
* ersatzlos.
*
* **Pause bei Mauszeiger und Fokus.** Wer gerade liest oder mit der Tastatur
* unterwegs ist, soll nicht nach neun Sekunden woanders landen. Und wer
* „Bewegung reduzieren" eingestellt hat, bekommt gar keinen selbsttätigen
* Wechsel — nur die Punkte zum Weiterklicken.
*/
export function hero(
eintraege: Heroeintrag[],
oeffnen: (art: 'filme' | 'serien', id: string) => void,
abspielen: (art: 'filme' | 'serien', id: string) => void,
signal?: AbortSignal,
): HTMLElement | null {
const mitBild = eintraege.filter(e => e.hintergrund).slice(0, HOECHSTENS);
if (mitBild.length === 0) return null;
const wurzel = document.createElement('section');
wurzel.className = 'hero';
wurzel.setAttribute('aria-roledescription', 'Karussell');
wurzel.setAttribute('aria-label', 'Vorgestellte Titel');
const buehne = document.createElement('div');
buehne.className = 'hero-buehne';
const folien = mitBild.map((e, i) => {
const folie = document.createElement('div');
folie.className = 'hero-folie' + (i === 0 ? ' an' : '');
folie.style.backgroundImage = `url("${bildAdresse(e.hintergrund)}")`;
folie.setAttribute('aria-hidden', i === 0 ? 'false' : 'true');
buehne.appendChild(folie);
return folie;
});
const inhalt = document.createElement('div');
inhalt.className = 'hero-inhalt';
const schriftzug = document.createElement('img');
schriftzug.className = 'hero-schriftzug';
const titel = document.createElement('h1');
titel.className = 'hero-titel';
const meta = document.createElement('div');
meta.className = 'hero-meta';
const text = document.createElement('p');
text.className = 'hero-text';
const spielen = knopf('Abspielen', 'btn btn-primary');
spielen.prepend(iconElement(icons.abspielen));
const mehr = knopf('Mehr', 'btn');
const knoepfe = document.createElement('div');
knoepfe.className = 'hero-knoepfe';
knoepfe.append(spielen, mehr);
inhalt.append(schriftzug, titel, meta, text, knoepfe);
const punkte = document.createElement('div');
punkte.className = 'hero-punkte';
const punktknoepfe = mitBild.map((e, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'hero-punkt' + (i === 0 ? ' an' : '');
// **Echte Knöpfe mit Beschriftung**, keine `<div>`s: mit der Tastatur
// und mit Vorlesesoftware ist ein `<div>` kein Bedienelement.
b.setAttribute('aria-label', `Zu „${e.titel}“`);
b.onclick = () => { zeige(i); anhalten(); };
punkte.appendChild(b);
return b;
});
wurzel.append(buehne, inhalt);
if (mitBild.length > 1) wurzel.appendChild(punkte);
let jetzt = 0;
let takt: number | undefined;
function zeige(i: number): void {
jetzt = ((i % mitBild.length) + mitBild.length) % mitBild.length;
const e = mitBild[jetzt]!;
folien.forEach((f, n) => {
f.classList.toggle('an', n === jetzt);
f.setAttribute('aria-hidden', n === jetzt ? 'false' : 'true');
});
punktknoepfe.forEach((b, n) => b.classList.toggle('an', n === jetzt));
const logo = bildAdresse(e.schriftzug);
if (logo) {
schriftzug.src = logo;
schriftzug.alt = e.titel;
schriftzug.classList.remove('hidden');
titel.classList.add('hidden');
} else {
schriftzug.classList.add('hidden');
titel.textContent = e.titel;
titel.classList.remove('hidden');
}
meta.textContent = [
e.jahr?.toString(),
e.laufzeit ? laufzeit(e.laufzeit) : null,
e.freigabe,
e.bewertung ? `★ ${e.bewertung.toFixed(1)}` : null,
].filter(Boolean).join(' · ');
text.textContent = e.beschreibung ?? '';
spielen.onclick = () => abspielen(e.art, e.id);
mehr.onclick = () => oeffnen(e.art, e.id);
}
function weiter(): void { zeige(jetzt + 1); }
function anhalten(): void {
if (takt !== undefined) { window.clearInterval(takt); takt = undefined; }
}
function starten(): void {
if (takt !== undefined || mitBild.length < 2) return;
// **Kein selbsttätiger Wechsel, wenn das System es abbestellt hat.**
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
takt = window.setInterval(weiter, WECHSEL_MS);
}
wurzel.addEventListener('mouseenter', anhalten);
wurzel.addEventListener('mouseleave', starten);
wurzel.addEventListener('focusin', anhalten);
wurzel.addEventListener('focusout', starten);
signal?.addEventListener('abort', anhalten);
zeige(0);
starten();
return wurzel;
}
/** „2 Std. 14 Min." — aus Minuten, wie TMDB sie liefert. */
function laufzeit(minuten: number): string {
const h = Math.floor(minuten / 60), m = minuten % 60;
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
}
/** Ein Icon als Element — `innerHTML` am Knopf löschte dessen Text. */
function iconElement(svg: string): HTMLElement {
const s = document.createElement('span');
s.className = 'icon';
s.innerHTML = svg;
return s;
}
/** Baut Heroeinträge aus dem, was die Startseite ohnehin geholt hat. */
export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroeintrag {
return {
art,
id: eintrag.id,
titel: eintrag.title,
schriftzug: eintrag.logoImage,
hintergrund: eintrag.backdropImage ?? '',
jahr: eintrag.year,
laufzeit: eintrag.runtimeMinutes,
freigabe: eintrag.contentRating,
bewertung: eintrag.rating,
beschreibung: eintrag.overview,
};
}
+92
View File
@@ -770,3 +770,95 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.kachel:hover .plakat,
.kachel:focus-visible .plakat { transform: none; }
}
/* --- Hero ----------------------------------------------------------------
*
* Die Startseite begann bei einer Reihe aus 150 px breiten Plakaten; das
* grösste Element über dem Falz war 225 px hoch. Ein Bild über die volle
* Breite ist der grösste Einzelunterschied im Eindruck — und ein Layout-,
* kein Farbproblem. */
.hero {
position: relative;
height: min(62vh, 560px);
/* Randlos: der Inhaltsbereich hat seitlich Polster, das Bild soll es
nicht haben. Negative Ränder statt eines eigenen Containers. */
margin: calc(var(--s-page) * -1) calc(var(--s-page) * -1) var(--s-section);
overflow: hidden;
display: flex;
align-items: flex-end;
}
.hero-buehne { position: absolute; inset: 0; }
.hero-folie {
position: absolute; inset: 0;
background-size: cover;
background-position: center 20%;
opacity: 0;
transition: opacity .9s ease-in-out;
}
.hero-folie.an { opacity: 1; }
/* **Zwei Verläufe, nicht einer.** Der von unten lässt die erste Reihe sich
darüberschieben; der von links sorgt dafür, dass der Text trägt, auch wenn
das Bild dort hell ist. */
.hero-buehne::after {
content: '';
position: absolute; inset: 0;
background:
linear-gradient(to top, var(--canvas) 2%, rgba(11, 11, 13, .55) 45%, transparent 85%),
linear-gradient(to right, rgba(11, 11, 13, .88) 0%, rgba(11, 11, 13, .35) 45%, transparent 75%);
}
.hero-inhalt {
position: relative;
padding: var(--s-page);
max-width: 620px;
display: flex; flex-direction: column; gap: var(--s-regular);
}
.hero-schriftzug {
max-width: 380px; max-height: 120px;
object-fit: contain; object-position: left bottom;
}
.hero-titel {
margin: 0;
font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em;
}
.hero-meta { font-size: var(--t-normal); color: var(--text-dim); }
/* Drei Zeilen, dann Schluss — eine volle Inhaltsangabe gehört auf die
Detailseite, nicht in den Aufmacher. */
.hero-text {
margin: 0;
color: var(--text-dim);
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
.hero-knoepfe { display: flex; gap: var(--s-snug); margin-top: var(--s-tight); }
.hero-knoepfe .btn { height: 40px; padding: 0 var(--s-section); gap: 6px; }
.hero-punkte {
position: absolute; right: var(--s-page); bottom: var(--s-page);
display: flex; gap: 6px;
}
.hero-punkt {
width: 26px; height: 3px; padding: 0; border: 0; border-radius: 2px;
background: rgba(255, 255, 255, .28);
transition: background .2s ease;
}
.hero-punkt.an { background: var(--text); }
.hero-punkt:hover { background: rgba(255, 255, 255, .6); }
@media (max-width: 860px) {
.hero {
height: min(52vh, 420px);
margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section);
}
.hero-inhalt { padding: var(--s-loose); max-width: none; }
.hero-punkte { right: var(--s-loose); bottom: var(--s-loose); }
}
+25
View File
@@ -4,6 +4,7 @@ import {
} from '../api';
import { leer, fehlerzeile } from '../util';
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
import { hero, alsHero, type Heroeintrag } from '../hero';
type Art = 'filme' | 'serien';
@@ -43,6 +44,30 @@ export function startseite(
return;
}
// **Der Aufmacher, aus dem, was ohnehin schon geholt wurde.**
// Erst Angefangenes — wer mittendrin ist, will dort weiter —, dann
// Neuzugänge, dann Vorschläge. Titel ohne Hintergrundbild fallen im
// Hero selbst heraus; ohne einen einzigen entfällt er ganz.
const heroTitel: Heroeintrag[] = [];
const schonDa = new Set<string>();
const dazu = (e: Heroeintrag) => {
if (schonDa.has(e.id) || !e.hintergrund) return;
schonDa.add(e.id);
heroTitel.push(e);
};
weiter.items.forEach(e => dazu(alsHero(e as never, 'serien' in e ? 'serien' : 'filme')));
bibliotheken.forEach((b, i) => {
const art: Art = b.kind === 'Shows' ? 'serien' : 'filme';
bestaende[i]?.items.forEach(e => dazu(alsHero(e as never, art)));
});
vorschlag.items.forEach(e => dazu(alsHero(e as never, 'filme')));
const aufmacher = hero(heroTitel,
(art, id) => oeffnen(art, id),
(art, id) => oeffnen(art, id));
if (aufmacher) wurzel.appendChild(aufmacher);
if (weiter.items.length) {
wurzel.appendChild(reihe('Fortsetzen',
weiter.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));