From 11a44f6a909cd7805982e3fbd4f8b2d68eda4c12 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 4 Oct 2026 12:34:57 +0200 Subject: [PATCH] =?UTF-8?q?Ein=20Aufmacher=20=C3=BCber=20die=20volle=20Bre?= =?UTF-8?q?ite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/app/src/hero.ts | 194 ++++++++++++++++++++++++++++++++++++++ web/app/src/style.css | 92 ++++++++++++++++++ web/app/src/views/home.ts | 25 +++++ 3 files changed, 311 insertions(+) create mode 100644 web/app/src/hero.ts diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts new file mode 100644 index 0000000..7e9d5e5 --- /dev/null +++ b/web/app/src/hero.ts @@ -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 `
`s: mit der Tastatur + // und mit Vorlesesoftware ist ein `
` 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, + }; +} diff --git a/web/app/src/style.css b/web/app/src/style.css index a8c0a3e..121cdc3 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -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); } +} diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index a4353c7..bd413e2 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -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(); + 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))));