diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 4e7d481..a7099f2 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -5,7 +5,10 @@ namespace Flaro.Api.Endpoints; // und bricht Clients, die niemand mehr aktualisiert. public sealed record SystemInfo( - string Version, bool NeedsSetup, IReadOnlyList Attributions); + string Version, string ServerName, bool NeedsSetup, + IReadOnlyList Attributions); + +public sealed record ServerNameRequest(string Name); public sealed record SetupRequest(string UserName, string Password); diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 9344a42..50c34a5 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -1,4 +1,5 @@ using Flaro.Core.Users; +using Flaro.Core; using Flaro.Metadata; using Flaro.Data; using Microsoft.EntityFrameworkCore; @@ -16,11 +17,23 @@ public static class SystemEndpoints /// private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1); + /// Unter welchem Schlüssel der Servername liegt. + public const string NameSchlüssel = "server.name"; + public static void MapSystem(this IEndpointRouteBuilder app) { app.MapGet("/api/v1/system/info", async (FlaroContext db) => Results.Ok(new SystemInfo( Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0", + // **Wie dieser Server heisst.** Ohne ihn zeigen die Clients + // den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei + // Server hat, unterscheidet sie an einer Zahl. + // + // Vorgabe ist der Rechnername des Containers: besser als + // nichts und besser als „Flaro", weil davon jeder einen hat. + ServerName: (await db.Settings.AsNoTracking() + .FirstOrDefaultAsync(e => e.Key == NameSchlüssel))?.Value + ?? Environment.MachineName, NeedsSetup: !await db.Users.AnyAsync(), // Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch // auf dem Anmeldebildschirm zeigen können. @@ -30,6 +43,23 @@ public static class SystemEndpoints ]))) .AllowAnonymous(); + app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) => + { + var name = anfrage.Name.Trim(); + if (name.Length == 0) + return Results.ValidationProblem(new Dictionary + { + ["name"] = ["Ein Name darf nicht leer sein."], + }); + + var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel); + if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name }); + else vorhanden.Value = name; + + await db.SaveChangesAsync(); + return Results.NoContent(); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) => { if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8) diff --git a/web/app/index.html b/web/app/index.html index a587552..63001e4 100644 --- a/web/app/index.html +++ b/web/app/index.html @@ -8,6 +8,7 @@
+ `; anmeldeflaeche.classList.remove('hidden'); + kopfleiste.classList.add('hidden'); app.classList.add('hidden'); } } @@ -59,45 +66,74 @@ function zeigeAnmeldung(karte: HTMLElement): void { anmeldeflaeche.innerHTML = ''; anmeldeflaeche.appendChild(karte); anmeldeflaeche.classList.remove('hidden'); + kopfleiste.classList.add('hidden'); app.classList.add('hidden'); } -/** Lädt die Mediathekenliste neu — nach dem Anlegen oder Löschen einer. */ export async function mediathekenNeuLaden(): Promise { mediatheken = await bibliotheken(); leisteBauen(); zeichnen(); } +/* ---------- Kopfleiste ---------- */ + +function kopfleisteBauen(): void { + kopfleiste.innerHTML = ''; + + const marke = document.createElement('div'); + marke.className = 'marke'; + marke.textContent = 'Flaro'; + + // **Die Suche gehört nach oben, nicht in jede Mediathek.** Bei Plex steht + // sie in der Kopfleiste und geht quer durch alles; wer einen Titel sucht, + // weiss oft gar nicht, in welcher Mediathek er liegt. + const suche = document.createElement('form'); + suche.className = 'suchfeld'; + suche.innerHTML = icons.suche; + const eingabe = document.createElement('input'); + eingabe.type = 'search'; + eingabe.placeholder = 'Suchen'; + eingabe.setAttribute('aria-label', 'Mediathek durchsuchen'); + suche.appendChild(eingabe); + suche.onsubmit = e => { + e.preventDefault(); + const text = eingabe.value.trim(); + if (text) location.hash = `#/suche/${encodeURIComponent(text)}`; + }; + + const rechts = document.createElement('div'); + rechts.className = 'rechts'; + + const konto = document.createElement('button'); + konto.className = 'konto'; + const name = sitzung.wer?.userName ?? ''; + konto.innerHTML = `${esc(name.slice(0, 1).toUpperCase())}`; + konto.title = `${name} — abmelden`; + konto.appendChild(icon('abmelden')); + konto.onclick = () => { abmelden(); location.reload(); }; + rechts.appendChild(konto); + + kopfleiste.append(marke, suche, rechts); +} + /* ---------- Seitenleiste ---------- */ function leisteBauen(): void { leiste.innerHTML = ''; - const marke = document.createElement('div'); - marke.className = 'marke'; - marke.textContent = 'Flaro'; - leiste.appendChild(marke); + eintrag('Start', '#/', 'start'); - eintrag('Start', '#/'); - - // **Jede Mediathek ihre eigene Zeile**, mit Namen und Zahl. Eine Zeile - // „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme die - // Entscheidung zurück, sie zu trennen. + // **Jede Mediathek ihre eigene Zeile**, mit eigenem Icon und Zahl — eine + // Zeile „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme + // die Entscheidung zurück, sie zu trennen. if (mediatheken.length) { gruppe('Mediatheken'); - mediatheken.forEach(b => eintrag(b.name, `#/m/${b.id}`, b.itemCount)); + mediatheken.forEach(b => eintrag( + b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount)); } - if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung'); } - - const fuss = document.createElement('div'); - fuss.className = 'nav-fuss'; - const ab = document.createElement('button'); - ab.textContent = `Abmelden · ${sitzung.wer?.userName ?? ''}`; - ab.onclick = () => { abmelden(); location.reload(); }; - fuss.appendChild(ab); - leiste.appendChild(fuss); + if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); } function gruppe(text: string): void { const d = document.createElement('div'); @@ -106,10 +142,13 @@ function leisteBauen(): void { leiste.appendChild(d); } - function eintrag(text: string, ziel: string, anzahl?: number | null): void { + function eintrag( + text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null, + ): void { const b = document.createElement('button'); b.dataset.ziel = ziel; - b.innerHTML = `${esc(text)}` + b.innerHTML = `${icons[bild]}` + + `${esc(text)}` + (anzahl != null ? `${anzahl}` : ''); b.onclick = () => { location.hash = ziel; }; leiste.appendChild(b); @@ -124,7 +163,7 @@ function zeichnen(): void { leiste.querySelectorAll('button[data-ziel]').forEach(b => { const ziel = (b as HTMLElement).dataset.ziel!; - b.classList.toggle('check', ziel === `#/${teile.join('/')}`); + b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`); }); inhalt.innerHTML = ''; @@ -143,8 +182,10 @@ function zeichnen(): void { // Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem // anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger // als eine Fehlermeldung über eine Kennung, die niemand gelesen hat. - if (b) inhalt.appendChild(mediathek(b, oeffnen)); + if (b) inhalt.appendChild(mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen)); else location.hash = '#/'; + } else if (teile[0] === 'suche' && kennung) { + inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen)); } else if (teile[0] === 'verwaltung' && istAdmin()) { inhalt.appendChild(verwaltung(mediathekenNeuLaden)); } else { diff --git a/web/app/src/style.css b/web/app/src/style.css index cd701ef..863d46a 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1,292 +1,473 @@ -/* Flaro — Weboberfläche +/* Flaro — Weboberfläche. * - * Der Aufbau folgt Plex: Seitenleiste links, Inhalt als waagerechte Reihen, - * Detailseite mit grossem unscharfem Hintergrund. Die Farben nicht — Orchid - * stimmt mit der iOS-App überein, und Plex' Orange ist deren Marke. - */ + * Der Aufbau folgt Plex: Kopfleiste oben, Seitenleiste links mit Icons, + * waagerechte Reihen auf der Startseite, Plakate im Verhältnis 2:3 mit Haken + * und Fortschrittsbalken, eine Detailseite mit unscharfem Hintergrund und + * dem Schriftzug des Films. + * + * **Die Farbe nicht.** Plex ist bernsteingelb, Flaro ist Orchidee. Das + * Vorbild ist die Anordnung, nicht das Aussehen. */ :root { --accent: #C04AC9; --accent-dim: #8E3595; + --accent-soft: rgba(192, 74, 201, .16); + --canvas: #0B0B0D; --surface: #141417; - --elevated: #1D1D21; - --placeholder: #26262B; - --text: #F5F5F7; - --text-dim: #A1A1A8; - --text-faint: #6E6E76; - --separator: rgba(255, 255, 255, .10); + --elevated: #1D1D22; + --raised: #26262C; + --placeholder: #202026; + --line: rgba(255, 255, 255, .08); - --r-card: 6px; - --r-control: 8px; - --r-panel: 14px; + --text: #F2F2F5; + --text-dim: #B4B4BE; + --text-muted: #8A8A95; + --text-faint: #65656F; + --destructive: #E5484D; --s-tight: 4px; --s-snug: 8px; --s-regular: 12px; - --s-loose: 16px; --s-section: 28px; + --s-loose: 16px; --s-section: 24px; --s-page: 32px; + + --r-control: 8px; --r-card: 6px; --r-pill: 999px; - --card-w: 150px; --sidebar-w: 240px; + --topbar-h: 56px; - --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif; color-scheme: dark; } * { box-sizing: border-box; } -html, body { - margin: 0; padding: 0; height: 100%; - background: var(--canvas); color: var(--text); - font-family: var(--font); font-size: 14px; line-height: 1.45; +html, body { height: 100%; } + +body { + margin: 0; + background: var(--canvas); + color: var(--text); + font: 400 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; } -button, input, select { font: inherit; color: inherit; } +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } .hidden { display: none !important; } -/* ---------- Gerüst ---------- */ +/* **Jedes SVG bekommt eine Grösse, nicht nur die in `.icon`.** Ein rohes + `` ohne Breite füllt sonst seinen Container — und die Lupe in der + Suchleiste war dreihundert Bildpunkte gross und lag über der Seite. */ +svg { width: 20px; height: 20px; display: block; flex: none; } +.icon { width: 20px; height: 20px; display: block; flex: none; } -#app { display: flex; min-height: 100%; } +/* ---------- Kopfleiste ---------- */ -#chrome { - width: var(--sidebar-w); flex: 0 0 var(--sidebar-w); - background: var(--surface); - border-right: 1px solid var(--separator); - padding: var(--s-loose) 0; - display: flex; flex-direction: column; gap: var(--s-tight); - position: sticky; top: 0; height: 100vh; overflow-y: auto; +#topbar { + position: fixed; inset: 0 0 auto 0; height: var(--topbar-h); z-index: 30; + display: flex; align-items: center; gap: var(--s-loose); + padding: 0 var(--s-loose); + background: #0E0E11; + border-bottom: 1px solid var(--line); +} +#topbar .marke { + font-size: 20px; font-weight: 700; letter-spacing: -.02em; + color: var(--accent); user-select: none; +} +#topbar .suchfeld { + flex: 1; max-width: 420px; + display: flex; align-items: center; gap: var(--s-snug); + background: var(--elevated); border: 1px solid transparent; + border-radius: var(--r-pill); padding: 0 var(--s-regular); height: 34px; + color: var(--text-muted); +} +#topbar .suchfeld:focus-within { border-color: var(--accent); color: var(--text); } +#topbar .suchfeld input { + flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--text); +} +#topbar .rechts { margin-left: auto; display: flex; align-items: center; gap: var(--s-regular); } +#topbar .konto { + display: flex; align-items: center; gap: var(--s-snug); + background: none; border: 0; color: var(--text-dim); padding: 0; +} +#topbar .konto .kreis { + width: 28px; height: 28px; border-radius: 50%; + background: var(--accent); color: #fff; + display: grid; place-items: center; font-size: 13px; font-weight: 600; } -.marke { - font-size: 20px; font-weight: 700; letter-spacing: -.02em; - padding: 0 var(--s-loose) var(--s-loose); - color: var(--accent); +/* ---------- Grundgerüst ---------- */ + +#app { display: flex; min-height: 100%; padding-top: var(--topbar-h); } + +#chrome { + position: fixed; top: var(--topbar-h); bottom: 0; left: 0; + width: var(--sidebar-w); overflow-y: auto; + background: #0E0E11; + border-right: 1px solid var(--line); + padding: var(--s-loose) 0; + display: flex; flex-direction: column; gap: 2px; } .nav-gruppe { - font-size: 11px; text-transform: uppercase; letter-spacing: .08em; - color: var(--text-faint); padding: var(--s-loose) var(--s-loose) var(--s-tight); + font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; + color: var(--text-faint); padding: var(--s-loose) var(--s-loose) var(--s-snug); } #chrome button { - display: block; width: calc(100% - var(--s-regular)); margin: 0 var(--s-snug); - text-align: left; background: none; border: 0; cursor: pointer; - padding: var(--s-snug) var(--s-regular); border-radius: var(--r-control); - color: var(--text-dim); font-size: 14px; + display: flex; align-items: center; gap: var(--s-regular); + background: none; border: 0; text-align: left; + padding: 9px var(--s-loose); color: var(--text-dim); + border-left: 3px solid transparent; } -/* Jede Mediathek eine Zeile: Name links, Zahl rechts. */ -#chrome button { display: flex; align-items: center; gap: var(--s-tight); } -#chrome button > span:first-child { +#chrome button > span.name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#chrome button .icon { flex: none; color: var(--text-muted); } +#chrome button:hover { color: var(--text); background: rgba(255, 255, 255, .04); } +#chrome button:hover .icon { color: var(--text-dim); } + +/* **Der Balken links, nicht ein gefüllter Knopf.** Bei Plex markiert ein + schmaler farbiger Strich die aktive Zeile; das liest sich in einer langen + Liste ruhiger als ein leuchtender Block. */ +#chrome button.check { + color: var(--accent); border-left-color: var(--accent); + background: linear-gradient(90deg, var(--accent-soft), transparent 60%); +} +#chrome button.check .icon { color: var(--accent); } + #chrome button .zahl { flex: none; font-size: 11px; font-variant-numeric: tabular-nums; - color: var(--text-muted); background: var(--elevated); - padding: 1px 6px; border-radius: 8px; + color: var(--text-faint); } -#chrome button.check .zahl { background: rgba(255, 255, 255, .22); color: #fff; } - -#chrome button:hover { background: var(--elevated); color: var(--text); } -#chrome button.check { background: var(--accent-dim); color: #fff; } +#chrome button.check .zahl { color: var(--accent); } .nav-fuss { margin-top: auto; padding-top: var(--s-loose); } -#content { flex: 1; min-width: 0; padding: var(--s-section); } +#content { + flex: 1; min-width: 0; + margin-left: var(--sidebar-w); + padding: var(--s-section) var(--s-page) var(--s-page); +} /* ---------- Anmeldung ---------- */ #login { - display: flex; align-items: center; justify-content: center; - min-height: 100vh; padding: var(--s-loose); + min-height: 100vh; display: grid; place-items: center; padding: var(--s-section); + background: var(--canvas); } .login-card { - width: 100%; max-width: 360px; background: var(--surface); - border: 1px solid var(--separator); border-radius: var(--r-panel); - padding: var(--s-section); display: flex; flex-direction: column; gap: var(--s-regular); + width: 100%; max-width: 360px; + display: flex; flex-direction: column; gap: var(--s-regular); + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r-control); padding: var(--s-section); } -.login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; } +.login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; color: var(--accent); } -/* ---------- Bedienelemente ---------- */ +/* ---------- Kopf einer Mediathek ---------- */ -.field { - width: 100%; background: var(--elevated); border: 1px solid var(--separator); - border-radius: var(--r-control); padding: 10px var(--s-regular); outline: none; +.seitenkopf { + display: flex; align-items: flex-start; gap: var(--s-section); + margin-bottom: var(--s-section); } -.field:focus { border-color: var(--accent); } - -.btn { - background: var(--elevated); border: 1px solid var(--separator); - border-radius: var(--r-control); padding: 9px var(--s-loose); - cursor: pointer; white-space: nowrap; +.seitenkopf .wer { min-width: 0; } +.seitenkopf h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; } +.seitenkopf .quelle { + font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint); } -.btn:hover:not(:disabled) { background: #2A2A30; } -.btn:disabled { opacity: .5; cursor: default; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } -.btn-primary:hover:not(:disabled) { background: #CB5BD3; } -.btn-row { display: flex; gap: var(--s-snug); flex-wrap: wrap; align-items: center; } -.seg { display: inline-flex; background: var(--elevated); border-radius: var(--r-control); padding: 2px; } -.seg button { - background: none; border: 0; cursor: pointer; color: var(--text-dim); - padding: 6px var(--s-loose); border-radius: 6px; +/* Reiter in der Mitte, aktiver unterstrichen — wie bei Plex. */ +.reiter { display: flex; gap: var(--s-section); margin: 0 auto; } +.reiter button { + background: none; border: 0; padding: 4px 0 8px; + color: var(--text-muted); font-weight: 500; + border-bottom: 2px solid transparent; } -.seg button.check { background: var(--accent); color: #fff; } +.reiter button:hover { color: var(--text); } +.reiter button.check { color: var(--accent); border-bottom-color: var(--accent); } -.seitentitel { margin: 0; font-size: 24px; letter-spacing: -.02em; } -.row { display: flex; gap: var(--s-regular); align-items: center; margin-bottom: var(--s-section); } -.row .field { max-width: 280px; margin-left: auto; } -/* Die Sortierung steht neben der Suche und braucht weniger Platz. */ -.row .field.schmal { max-width: 190px; margin-left: auto; } -.row .field.schmal + .field { margin-left: 0; } +/* Filterzeile darunter. */ +.filterzeile { + display: flex; align-items: center; gap: var(--s-regular); + margin-bottom: var(--s-section); flex-wrap: wrap; +} +.filterzeile .anzahl { + background: var(--elevated); border-radius: var(--r-card); + padding: 2px 10px; font-size: 13px; color: var(--text-dim); + font-variant-numeric: tabular-nums; +} +.filterzeile select, .filterzeile input { + background: var(--elevated); border: 1px solid transparent; + border-radius: var(--r-control); padding: 6px 10px; color: var(--text); outline: none; +} +.filterzeile select:focus, .filterzeile input:focus { border-color: var(--accent); } +.filterzeile .suche { margin-left: auto; min-width: 220px; } -/* ---------- Karten und Raster ---------- */ +/* ---------- Kacheln ---------- */ -.grid { +.raster { display: grid; gap: var(--s-loose); - grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } -.card { - background: none; border: 0; padding: 0; cursor: pointer; text-align: left; +.kachel { display: flex; flex-direction: column; gap: var(--s-snug); + background: none; border: 0; padding: 0; text-align: left; min-width: 0; } -.card:hover .poster { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.5); } -.poster { - aspect-ratio: 2 / 3; width: 100%; background: var(--placeholder); - border-radius: var(--r-card); overflow: hidden; position: relative; +.plakat { + position: relative; width: 100%; aspect-ratio: 2 / 3; + background: var(--placeholder); + border-radius: var(--r-card); overflow: hidden; transition: transform .15s ease, box-shadow .15s ease; } -.poster img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .2s; } -.poster.geladen img { opacity: 1; } +.plakat img { + width: 100%; height: 100%; object-fit: cover; + opacity: 0; transition: opacity .2s; +} +.plakat.geladen img { opacity: 1; } -/* Der Fortschrittsbalken auf der Kachel. Flaro zeigt ihn ab der ersten - Sekunde — es gibt keinen Anteil, der zu klein wäre, um ihn zu merken. */ +.kachel:hover .plakat { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, .55); +} + +/* Der Abspielknopf erscheint beim Überfahren, mittig. */ +.schweber { + position: absolute; inset: 0; display: grid; place-items: center; + background: rgba(0, 0, 0, .45); + opacity: 0; transition: opacity .15s ease; +} +.schweber svg { width: 44px; height: 44px; color: #fff; filter: drop-shadow(0 2px 8px rgba(0,0,0,.6)); } +.kachel:hover .schweber { opacity: 1; } + +/* **Haken und Zahl sitzen in derselben Ecke**, weil sie dieselbe Frage + beantworten: ist hier noch etwas offen? */ +.ecke { + position: absolute; top: 0; right: 0; z-index: 2; + min-width: 26px; height: 26px; padding: 0 6px; + display: grid; place-items: center; + font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; + border-bottom-left-radius: var(--r-card); +} +.ecke.haken { background: rgba(0, 0, 0, .72); color: #fff; } +.ecke.haken svg { width: 15px; height: 15px; } +.ecke.zahl { background: var(--accent); color: #fff; } + +/* Ab der ersten Sekunde sichtbar — darum die Mindestbreite. */ .fortschritt { - position: absolute; left: 0; right: 0; bottom: 0; height: 3px; + position: absolute; left: 0; right: 0; bottom: 0; height: 3px; z-index: 2; background: rgba(255, 255, 255, .22); } -/* Eine Mindestbreite, damit auch eine einzige Minute sichtbar ist — bei - 2:36 Laufzeit wären das sonst anderthalb Bildpunkte. */ .fortschritt > div { height: 100%; min-width: 4px; background: var(--accent); } -/* Die Plakette mit den offenen Folgen, wie bei Plex in der Ecke. */ -.offen { - position: absolute; top: 6px; right: 6px; - min-width: 18px; padding: 1px 5px; border-radius: 9px; - background: var(--accent); color: #fff; - font-size: 11px; font-weight: 600; line-height: 16px; text-align: center; +.kacheltext { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.kacheltext strong { + font-weight: 500; font-size: 13px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -.rowline { display: flex; flex-direction: column; gap: 1px; min-width: 0; } -.rowline strong { font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -/* ---------- Waagerechte Reihen, wie bei Plex ---------- */ - -.reihe { margin-bottom: var(--s-section); } -.reihe h2 { - font-size: 17px; font-weight: 600; margin: 0; - letter-spacing: -.01em; +.kacheltext span { + font-size: 12px; color: var(--text-muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kacheltext .dritte { color: var(--text-faint); } + +/* ---------- Waagerechte Reihen ---------- */ + +.reihe { margin-bottom: var(--s-page); } -/* Überschrift links, „Alle zeigen" rechts — steht mehr drin, als die Reihe - zeigt, soll die Reihe keine Sackgasse sein. */ .reihe-kopf { - display: flex; align-items: baseline; gap: var(--s-regular); + display: flex; align-items: center; gap: var(--s-regular); margin-bottom: var(--s-regular); } +.reihe-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; } .reihe-kopf .mehr { - margin-left: auto; flex: none; - background: none; border: 0; cursor: pointer; padding: 0; - font-size: 13px; color: var(--accent); + background: none; border: 0; padding: 0; font-size: 13px; color: var(--accent); } .reihe-kopf .mehr:hover { text-decoration: underline; } -.strip { - display: grid; grid-auto-flow: column; grid-auto-columns: var(--card-w); - gap: var(--s-loose); overflow-x: auto; padding-bottom: var(--s-snug); - scrollbar-width: thin; scroll-snap-type: x proximity; +.reihe-pfeile { margin-left: auto; display: flex; gap: var(--s-tight); } +.pfeil { + width: 28px; height: 28px; display: grid; place-items: center; + background: var(--elevated); border: 0; border-radius: 50%; color: var(--text-dim); } +.pfeil:hover { background: var(--raised); color: var(--text); } +.pfeil svg { width: 16px; height: 16px; } + +.strip { + display: grid; grid-auto-flow: column; grid-auto-columns: 150px; + gap: var(--s-loose); + overflow-x: auto; scroll-snap-type: x proximity; + scrollbar-width: none; padding-bottom: var(--s-tight); +} +.strip::-webkit-scrollbar { display: none; } .strip > * { scroll-snap-align: start; } -.strip::-webkit-scrollbar { height: 8px; } -.strip::-webkit-scrollbar-thumb { background: var(--elevated); border-radius: 4px; } /* ---------- Detailseite ---------- */ -.detail-head { - position: relative; margin: calc(var(--s-section) * -1) calc(var(--s-section) * -1) var(--s-section); - padding: var(--s-section); overflow: hidden; +.detail { position: relative; } + +/* **Der Hintergrund füllt die Seite, nicht nur einen Streifen.** Bei Plex + liegt er unscharf und abgedunkelt hinter allem und gibt der Seite die + Farbe des Films. */ +.detail-hintergrund { + position: fixed; top: var(--topbar-h); left: var(--sidebar-w); right: 0; bottom: 0; + z-index: -1; + background-size: cover; background-position: center top; + opacity: .28; filter: blur(2px); + mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,.45) 55%, transparent 92%); } -/* Der unscharfe Hintergrund, der die Seite trägt — Plex' auffälligster Zug. */ -.detail-head::before { - content: ''; position: absolute; inset: -40px; - background-image: var(--backdrop); background-size: cover; background-position: center; - filter: blur(28px) saturate(1.2); opacity: .35; z-index: 0; + +.detail-kopf { + display: flex; gap: var(--s-page); align-items: flex-start; + margin-bottom: var(--s-page); } -.detail-head::after { - content: ''; position: absolute; inset: 0; z-index: 0; - background: linear-gradient(to bottom, rgba(11,11,13,.3), var(--canvas)); +.detail-kopf .plakat { + flex: none; width: 240px; + box-shadow: 0 12px 40px rgba(0, 0, 0, .6); } -.detail-head > * { position: relative; z-index: 1; } +.detail-text { min-width: 0; flex: 1; padding-top: var(--s-snug); } -.detail-inner { display: flex; gap: var(--s-section); margin-top: var(--s-loose); align-items: flex-start; } -.detail-inner .poster { width: 200px; flex: 0 0 200px; } -.detail-text h1 { margin: 0 0 var(--s-snug); font-size: 30px; letter-spacing: -.02em; } -.detail-text p { margin: 0; color: var(--text-dim); max-width: 60ch; } - -.detail h2 { font-size: 17px; margin: var(--s-section) 0 var(--s-regular); } - -/* ---------- Listen und Einstellungen ---------- */ - -.settings { display: flex; flex-direction: column; gap: var(--s-regular); } -.setting { - background: var(--surface); border: 1px solid var(--separator); - border-radius: var(--r-panel); padding: var(--s-loose); - display: flex; flex-direction: column; gap: var(--s-snug); +/* Der Schriftzug des Films statt der Systemschrift — wenn es einen gibt. */ +.detail-text .schriftzug { + max-width: 420px; max-height: 110px; margin-bottom: var(--s-regular); + object-fit: contain; object-position: left bottom; display: block; } -button.setting { cursor: pointer; text-align: left; width: 100%; } -button.setting:hover { background: var(--elevated); } +.detail-text h1 { margin: 0 0 var(--s-snug); font-size: 34px; font-weight: 700; letter-spacing: -.03em; } +.detail-text .unter { margin: 0 0 var(--s-regular); font-size: 17px; color: var(--text-dim); } -.hint { color: var(--text-dim); font-size: 13px; } -.tiny { color: var(--text-faint); font-size: 12px; } -.note { color: var(--text-faint); font-size: 12px; } - -.error { - background: rgba(220, 60, 60, .14); border: 1px solid rgba(220, 60, 60, .4); - color: #FF9A9A; border-radius: var(--r-control); padding: var(--s-regular); +.eckdaten { + display: flex; align-items: center; gap: var(--s-regular); flex-wrap: wrap; + color: var(--text-dim); font-size: 13px; margin-bottom: var(--s-loose); } -.empty { color: var(--text-faint); padding: var(--s-section) 0; grid-column: 1 / -1; } +.freigabe { + border: 1px solid var(--text-faint); border-radius: 3px; + padding: 0 6px; font-size: 11px; font-weight: 700; letter-spacing: .04em; + color: var(--text-dim); +} +.bewertung { display: flex; align-items: center; gap: 5px; color: var(--text); font-weight: 600; } +.bewertung svg { width: 15px; height: 15px; color: var(--accent); } + +.werbezeile { color: var(--text-muted); font-style: italic; margin: 0 0 var(--s-loose); } +.beschreibung { max-width: 70ch; margin: 0 0 var(--s-section); color: var(--text-dim); } + +.aktionen { display: flex; align-items: center; gap: var(--s-regular); margin-bottom: var(--s-section); flex-wrap: wrap; } + +.btn { + display: inline-flex; align-items: center; gap: var(--s-snug); + background: var(--elevated); border: 0; border-radius: var(--r-pill); + padding: 9px var(--s-loose); color: var(--text); +} +.btn:hover { background: var(--raised); } +.btn:disabled { opacity: .5; cursor: default; } +.btn svg { width: 18px; height: 18px; } +.btn-primary { background: var(--accent); color: #fff; font-weight: 600; } +.btn-primary:hover { background: var(--accent-dim); } + +/* Runde Knöpfe für alles ohne Beschriftung — wie Plex' Merkliste und Haken. */ +.rund { + width: 38px; height: 38px; padding: 0; border-radius: 50%; + display: grid; place-items: center; + background: none; border: 1px solid var(--line); color: var(--text-dim); +} +.rund:hover { border-color: var(--text-muted); color: var(--text); } +.rund.an { background: var(--accent); border-color: var(--accent); color: #fff; } +.rund svg { width: 19px; height: 19px; } + +/* Die Medienangaben als Tabelle — Plex' „Video / Ton / Untertitel". */ +.medientabelle { display: grid; grid-template-columns: auto 1fr; gap: var(--s-snug) var(--s-section); margin-bottom: var(--s-section); } +.medientabelle dt { color: var(--text-muted); font-size: 13px; } +.medientabelle dd { margin: 0; font-size: 13px; } + +/* Besetzung: runde Porträts, wie bei Plex. */ +.besetzung { display: grid; grid-auto-flow: column; grid-auto-columns: 110px; gap: var(--s-loose); overflow-x: auto; scrollbar-width: none; } +.besetzung::-webkit-scrollbar { display: none; } +.kopf-kachel { display: flex; flex-direction: column; align-items: center; gap: var(--s-snug); text-align: center; } +.kopf-kachel .portrait { + width: 92px; height: 92px; border-radius: 50%; overflow: hidden; + background: var(--placeholder); display: grid; place-items: center; + color: var(--text-faint); font-size: 26px; font-weight: 600; +} +.kopf-kachel .portrait img { width: 100%; height: 100%; object-fit: cover; } +.kopf-kachel strong { font-size: 12px; font-weight: 500; } +.kopf-kachel span { font-size: 11px; color: var(--text-muted); } + +/* Genre-Kacheln, Plex' „Kategorien". */ +.kategorien { display: grid; gap: var(--s-loose); grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); } +.kategorie { + aspect-ratio: 16 / 9; border: 0; border-radius: var(--r-control); + display: grid; place-items: center; color: #fff; + font-size: 19px; font-weight: 700; letter-spacing: -.01em; + position: relative; overflow: hidden; +} +.kategorie:hover { filter: brightness(1.15); } + +/* ---------- Kleinteile ---------- */ + +h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(--s-regular); } +.seitentitel { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; } + +.einstellungen { display: flex; flex-direction: column; gap: var(--s-regular); margin-bottom: var(--s-page); } +.einstellung { + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r-control); padding: var(--s-loose); + display: flex; flex-direction: column; gap: var(--s-regular); +} +.einstellung.waehlbar { text-align: left; width: 100%; } +.einstellung.waehlbar:hover { border-color: var(--accent); } + +.zeile { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.zeile strong { font-weight: 600; } +.winzig { font-size: 12px; color: var(--text-muted); } +.hinweis { color: var(--text-dim); font-size: 13px; } + +.feld { + background: var(--elevated); border: 1px solid transparent; + border-radius: var(--r-control); padding: 9px var(--s-regular); + color: var(--text); outline: none; min-width: 0; +} +.feld:focus { border-color: var(--accent); } +.knopfreihe { display: flex; gap: var(--s-snug); flex-wrap: wrap; align-items: center; } + +.fehler { + background: rgba(229, 72, 77, .12); border: 1px solid rgba(229, 72, 77, .35); + border-radius: var(--r-control); padding: var(--s-regular); color: #FF9A9D; font-size: 13px; +} +.leer { color: var(--text-muted); padding: var(--s-section) 0; } .spinner { width: 22px; height: 22px; margin: var(--s-section) auto; - border: 2px solid var(--separator); border-top-color: var(--accent); + border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: dreh .7s linear infinite; } @keyframes dreh { to { transform: rotate(360deg); } } -/* ---------- Abspielen ---------- */ - -.pl-fill { position: fixed; inset: 0; background: #000; z-index: 50; } - -/* ---------- Hinweis auf TMDB ---------- */ - .attribution { - margin-top: var(--s-section); padding-top: var(--s-loose); - border-top: 1px solid var(--separator); - color: var(--text-faint); font-size: 11px; display: flex; gap: var(--s-snug); align-items: center; + display: flex; align-items: center; gap: var(--s-snug); + margin-top: var(--s-page); padding-top: var(--s-loose); + border-top: 1px solid var(--line); + color: var(--text-faint); font-size: 12px; } .attribution img { height: 14px; opacity: .7; } -/* ---------- Schmale Fenster ---------- */ +/* Der Spieler über allem. */ +.pl-fill { + position: fixed; inset: 0; z-index: 100; background: #000; +} -@media (max-width: 760px) { +/* ---------- Schmale Bildschirme ---------- */ + +@media (max-width: 860px) { #app { flex-direction: column; } #chrome { - width: 100%; flex: none; height: auto; position: static; - flex-direction: row; overflow-x: auto; padding: var(--s-snug); - border-right: 0; border-bottom: 1px solid var(--separator); + position: static; width: auto; flex-direction: row; overflow-x: auto; + border-right: 0; border-bottom: 1px solid var(--line); padding: var(--s-snug); } - .marke, .nav-gruppe { display: none; } + #chrome button { border-left: 0; border-bottom: 3px solid transparent; white-space: nowrap; } + #chrome button.check { border-left-color: transparent; border-bottom-color: var(--accent); background: none; } + .nav-gruppe { display: none; } .nav-fuss { margin: 0; padding: 0; display: flex; } - #content { padding: var(--s-loose); } - .detail-inner { flex-direction: column; } - .detail-inner .poster { width: 140px; flex: none; } + #content { margin-left: 0; padding: var(--s-loose); } + .detail-hintergrund { left: 0; } + .detail-kopf { flex-direction: column; gap: var(--s-loose); } + .detail-kopf .plakat { width: 160px; } + .detail-text h1 { font-size: 26px; } + #topbar .suchfeld { max-width: none; } } diff --git a/web/app/src/util.ts b/web/app/src/util.ts index efc5f8c..680f3ea 100644 --- a/web/app/src/util.ts +++ b/web/app/src/util.ts @@ -1,4 +1,4 @@ -import { bildAdresse } from './api'; + export function esc(wert: unknown): string { return String(wert ?? '').replace(/[&<>"']/g, z => @@ -20,42 +20,6 @@ export function groesse(bytes: number): string { return `${Math.round(bytes / 1024)} KB`; } -/** Ein Plakat mit Platzhalter dahinter. - * - * Der Platzhalter hält die Fläche frei, bis das Bild da ist — ohne ihn - * springt das Raster beim Nachladen jedes Mal um. */ -export function plakat( - schluessel: string | null, beschriftung: string, fortschritt?: number | null, -): HTMLElement { - const rahmen = document.createElement('div'); - rahmen.className = 'poster'; - rahmen.setAttribute('role', 'img'); - rahmen.setAttribute('aria-label', beschriftung); - - const adresse = bildAdresse(schluessel); - if (adresse) { - const img = document.createElement('img'); - img.src = adresse; - img.alt = ''; - img.loading = 'lazy'; - // Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch. - img.onload = () => rahmen.classList.add('geladen'); - rahmen.appendChild(img); - } - - // Der Balken unten, wie bei Plex. Er erscheint, sobald überhaupt etwas - // gesehen wurde — es gibt keinen Anteil, der dafür zu klein wäre. - if (fortschritt != null && fortschritt > 0) { - const balken = document.createElement('div'); - balken.className = 'fortschritt'; - const gefuellt = document.createElement('div'); - gefuellt.style.width = `${Math.min(100, fortschritt * 100)}%`; - balken.appendChild(gefuellt); - rahmen.appendChild(balken); - } - return rahmen; -} - /** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */ export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined) : number | null { @@ -79,7 +43,7 @@ export function knopf(text: string, klasse = 'btn'): HTMLButtonElement { export function feld(platzhalter: string, art = 'text'): HTMLInputElement { const i = document.createElement('input'); - i.className = 'field'; + i.className = 'feld'; i.type = art; i.placeholder = platzhalter; return i; @@ -88,14 +52,14 @@ export function feld(platzhalter: string, art = 'text'): HTMLInputElement { /** Zeigt einen Fehler, ohne die Seite zu verlassen. */ export function fehlerzeile(text: string): HTMLElement { const d = document.createElement('div'); - d.className = 'error'; + d.className = 'fehler'; d.textContent = text; return d; } export function leer(text: string): HTMLElement { const d = document.createElement('div'); - d.className = 'empty'; + d.className = 'leer'; d.textContent = text; return d; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 9003621..b950ae6 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -22,7 +22,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { h.textContent = 'Bibliotheken'; const liste = document.createElement('div'); - liste.className = 'settings'; + liste.className = 'einstellungen'; bereich.append(h, liste, anlegeform()); void laden(); @@ -45,10 +45,10 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { function block(b: Bibliothek): HTMLElement { const d = document.createElement('div'); - d.className = 'setting'; + d.className = 'einstellung'; const kopf = document.createElement('div'); - kopf.className = 'rowline'; + kopf.className = 'zeile'; // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. const serie = b.kind === 'Shows'; const anzahl = b.itemCount == null ? '' @@ -56,11 +56,11 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { ? (serie ? 'Serie' : 'Film') : (serie ? 'Serien' : 'Filme')); kopf.innerHTML = `${esc(b.name)}` - + `${esc(b.path)}${esc(anzahl)}`; + + `${esc(b.path)}${esc(anzahl)}`; d.appendChild(kopf); const stand = document.createElement('div'); - stand.className = 'hint'; + stand.className = 'hinweis'; const los = knopf('Scannen', 'btn'); const umbenennen = knopf('Umbenennen', 'btn'); const entfernen = knopf('Entfernen', 'btn'); @@ -71,7 +71,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { eingabe.value = b.name; const fertig = knopf('Speichern', 'btn btn-primary'); const reihe = document.createElement('div'); - reihe.className = 'btn-row'; + reihe.className = 'knopfreihe'; reihe.append(eingabe, fertig); d.appendChild(reihe); eingabe.focus(); @@ -140,7 +140,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { } const knoepfe = document.createElement('div'); - knoepfe.className = 'btn-row'; + knoepfe.className = 'knopfreihe'; knoepfe.append(los, umbenennen, entfernen); d.append(knoepfe, stand); void verfolgen(); @@ -149,7 +149,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { function anlegeform(): HTMLElement { const form = document.createElement('div'); - form.className = 'setting'; + form.className = 'einstellung'; const name = feld('Name, etwa Filme'); const pfad = feld('Pfad im Container, etwa /media/Filme'); @@ -159,7 +159,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { const los = knopf('Anlegen', 'btn btn-primary'); const reihe = document.createElement('div'); - reihe.className = 'btn-row'; + reihe.className = 'knopfreihe'; reihe.append(name, art, pfad, los); form.append(reihe); @@ -187,21 +187,21 @@ function benutzerbereich(): HTMLElement { const h = document.createElement('h2'); h.textContent = 'Benutzer'; const liste = document.createElement('div'); - liste.className = 'settings'; + liste.className = 'einstellungen'; const name = feld('Benutzername'); const passwort = feld('Passwort, mindestens 8 Zeichen', 'password'); const admin = document.createElement('label'); - admin.className = 'hint'; + admin.className = 'hinweis'; const haken = document.createElement('input'); haken.type = 'checkbox'; admin.append(haken, document.createTextNode(' Administrator')); const los = knopf('Anlegen', 'btn'); const form = document.createElement('div'); - form.className = 'setting'; + form.className = 'einstellung'; const reihe = document.createElement('div'); - reihe.className = 'btn-row'; + reihe.className = 'knopfreihe'; reihe.append(name, passwort, admin, los); form.append(reihe); @@ -215,9 +215,9 @@ function benutzerbereich(): HTMLElement { liste.innerHTML = ''; alle.forEach(b => { const d = document.createElement('div'); - d.className = 'setting'; - d.innerHTML = `
${esc(b.userName)}` - + `${b.isAdministrator ? 'Administrator' : 'Benutzer'}
`; + d.className = 'einstellung'; + d.innerHTML = `
${esc(b.userName)}` + + `${b.isAdministrator ? 'Administrator' : 'Benutzer'}
`; liste.appendChild(d); }); } catch (fehler) { @@ -247,7 +247,7 @@ function metadatenbereich(): HTMLElement { const h = document.createElement('h2'); h.textContent = 'Metadaten'; const block = document.createElement('div'); - block.className = 'setting'; + block.className = 'einstellung'; bereich.append(h, block); void (async () => { @@ -255,7 +255,7 @@ function metadatenbereich(): HTMLElement { const stand = await metadatenEinstellung(); const auskunft = document.createElement('div'); - auskunft.className = 'hint'; + auskunft.className = 'hinweis'; auskunft.textContent = stand.usingProjectKey ? 'Es wird der mitgelieferte TMDB-Zugang benutzt. Ein eigener löst ihn ab.' : stand.hasApiKey ? 'Ein eigener TMDB-Zugang ist hinterlegt.' @@ -267,7 +267,7 @@ function metadatenbereich(): HTMLElement { const los = knopf('Speichern', 'btn'); const reihe = document.createElement('div'); - reihe.className = 'btn-row'; + reihe.className = 'knopfreihe'; reihe.append(schluessel, sprache, los); los.onclick = async () => { diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 822ff5f..272055d 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -1,14 +1,20 @@ import { film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse, - vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, + vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen, type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand, + type Mitwirkender, } from '../api'; -import { - esc, dauer, groesse, plakat, knopf, feld, fehlerzeile, leer, anteil, verbleibend, -} from '../util'; +import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util'; +import { icons, icon } from '../icons'; type Art = 'filme' | 'serien'; +/** Die Detailseite — im Aufbau von Plex. + * + * Unscharfer Hintergrund über die ganze Seite, Plakat links, rechts der + * Schriftzug des Films statt der Systemschrift, darunter Eckdaten, + * Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt + * die Besetzung mit runden Porträts. */ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement { const wurzel = document.createElement('div'); wurzel.className = 'detail'; @@ -17,23 +23,26 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem async function laden(): Promise { wurzel.innerHTML = '
'; try { - // **Einzeln geholt, nicht aus einer Liste gefischt.** Vorher wurden - // 200 Titel geladen und darin gesucht; bei 201 Filmen hätte die - // Detailseite des letzten „gibt es nicht mehr" gesagt. if (art === 'filme') { - const film = await filmLaden(id); + const eintrag = await filmLaden(id); wurzel.innerHTML = ''; wurzel.append( - kopfbereich(film, film.state), - dateiliste(film.files, film.id, film.state), - zuordnungsbereich(film.title, film.year)); + hintergrund(eintrag.backdropImage), + zurueckKnopf(), + kopfbereich(eintrag, eintrag.state, eintrag.files[0] ?? null), + dateiliste(eintrag.files, eintrag.id, eintrag.state), + besetzung(eintrag.credits), + zuordnungsbereich(eintrag.title, eintrag.year)); } else { - const serie = await serieLaden(id); + const eintrag = await serieLaden(id); wurzel.innerHTML = ''; wurzel.append( - kopfbereich(serie, null), - await staffeln(serie), - zuordnungsbereich(serie.title, serie.year)); + hintergrund(eintrag.backdropImage), + zurueckKnopf(), + kopfbereich(eintrag, null, null), + await staffeln(eintrag), + besetzung(eintrag.credits), + zuordnungsbereich(eintrag.title, eintrag.year)); } } catch (fehler) { wurzel.innerHTML = ''; @@ -43,159 +52,297 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem } } + function zurueckKnopf(): HTMLElement { + const b = knopf('Zurück', 'btn'); + b.prepend(icon('links')); + b.style.marginBottom = 'var(--s-loose)'; + b.onclick = zurueck; + return b; + } + + /* ---------- Hintergrund ---------- */ + + function hintergrund(schluessel: string | null): HTMLElement { + const d = document.createElement('div'); + d.className = 'detail-hintergrund'; + const adresse = bildAdresse(schluessel); + if (adresse) d.style.backgroundImage = `url("${adresse}")`; + return d; + } + /* ---------- Kopf ---------- */ - function kopfbereich(eintrag: Film | Serie, stand: Stand | null): HTMLElement { + function kopfbereich(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement { const kopf = document.createElement('header'); - kopf.className = 'detail-head'; + kopf.className = 'detail-kopf'; - const hintergrund = bildAdresse(eintrag.backdropImage); - if (hintergrund) kopf.style.setProperty('--backdrop', `url("${hintergrund}")`); - - const zurueckKnopf = knopf('Zurück', 'btn'); - zurueckKnopf.onclick = zurueck; + // Plakat + const rahmen = document.createElement('div'); + rahmen.className = 'plakat geladen'; + const plakatAdresse = bildAdresse(eintrag.posterImage); + if (plakatAdresse) { + const bild = document.createElement('img'); + bild.src = plakatAdresse; + bild.alt = ''; + rahmen.appendChild(bild); + } + if (anteil(stand)) { + const balken = document.createElement('div'); + balken.className = 'fortschritt'; + const gefuellt = document.createElement('div'); + gefuellt.style.width = `${Math.min(100, anteil(stand)! * 100)}%`; + balken.appendChild(gefuellt); + rahmen.appendChild(balken); + } const text = document.createElement('div'); text.className = 'detail-text'; - const jahr = eintrag.year ? ` (${eintrag.year})` : ''; - const offen = 'unwatchedCount' in eintrag && eintrag.unwatchedCount > 0 - ? `${eintrag.unwatchedCount} von ${eintrag.episodeCount} Folgen offen` - : ''; - text.innerHTML = - `

${esc(eintrag.title)}${esc(jahr)}

` + - (stand?.watched ? 'Gesehen' : offen) + - `

${esc(eintrag.overview ?? 'Keine Beschreibung.')}

`; - const innen = document.createElement('div'); - innen.className = 'detail-inner'; - innen.append(plakat(eintrag.posterImage, eintrag.title, anteil(stand)), text); + // **Der Schriftzug, wenn es einen gibt.** „Blade Runner 2049" in seiner + // eigenen Schrift sagt mehr als derselbe Titel in der Systemschrift. + const logo = bildAdresse(eintrag.logoImage); + if (logo) { + const bild = document.createElement('img'); + bild.className = 'schriftzug'; + bild.src = logo; + bild.alt = eintrag.title; + text.appendChild(bild); + } else { + const h = document.createElement('h1'); + h.textContent = eintrag.title; + text.appendChild(h); + } - kopf.append(zurueckKnopf, innen); + text.appendChild(eckdaten(eintrag)); + + if (eintrag.tagline) { + const zeile = document.createElement('p'); + zeile.className = 'werbezeile'; + zeile.textContent = eintrag.tagline; + text.appendChild(zeile); + } + + text.appendChild(aktionsreihe(eintrag, stand, datei)); + + const beschreibung = document.createElement('p'); + beschreibung.className = 'beschreibung'; + beschreibung.textContent = eintrag.overview ?? 'Keine Beschreibung.'; + text.appendChild(beschreibung); + + if (datei) text.appendChild(medienangaben(datei)); + + kopf.append(rahmen, text); return kopf; } - /* ---------- Dateien und Spuren ---------- */ + /** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */ + function eckdaten(eintrag: Film | Serie): HTMLElement { + const zeile = document.createElement('div'); + zeile.className = 'eckdaten'; - function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement { + const teile: string[] = []; + if (eintrag.year) teile.push(String(eintrag.year)); + if (eintrag.genres?.length) teile.push(eintrag.genres.join(', ')); + if (eintrag.runtimeMinutes) teile.push(`${eintrag.runtimeMinutes} Min`); + if ('episodeCount' in eintrag) { + teile.push(`${eintrag.episodeCount} Folgen`); + if (eintrag.unwatchedCount > 0) teile.push(`${eintrag.unwatchedCount} offen`); + } + if (teile.length) { + const t = document.createElement('span'); + t.textContent = teile.join(' · '); + zeile.appendChild(t); + } + + if (eintrag.contentRating) { + const f = document.createElement('span'); + f.className = 'freigabe'; + f.textContent = eintrag.contentRating; + f.title = 'Altersfreigabe'; + zeile.appendChild(f); + } + + if (eintrag.rating) { + const b = document.createElement('span'); + b.className = 'bewertung'; + b.innerHTML = icons.stern; + b.append(document.createTextNode(eintrag.rating.toFixed(1))); + b.title = 'Bewertung bei TMDB'; + zeile.appendChild(b); + } + + return zeile; + } + + /* ---------- Knöpfe ---------- */ + + function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement { + const reihe = document.createElement('div'); + reihe.className = 'aktionen'; + const meldung = document.createElement('div'); + meldung.className = 'hinweis'; + + if (datei) { + const stelle = stand?.positionSeconds ?? 0; + const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary'); + los.prepend(icon('abspielen')); + los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung); + reihe.appendChild(los); + + if (stelle > 0) { + const vonVorn = knopf('Von vorn', 'btn'); + vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung); + reihe.appendChild(vonVorn); + } + + const laden = knopf('Herunterladen', 'btn'); + laden.prepend(icon('download')); + laden.onclick = () => void herunterladen(datei, laden, meldung); + reihe.appendChild(laden); + } + + // **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit, + // weglegen — drei Dinge, die man oft tut und nie liest. + if ('files' in eintrag) { + reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false, + stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', + async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); })); + + reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false, + stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten', + async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); })); + + if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) { + reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen', + async () => { await weglegen(eintrag.id); await laden(); })); + } + } + + const huelle = document.createElement('div'); + huelle.append(reihe, meldung); + return huelle; + } + + function rundKnopf( + bild: keyof typeof icons, an: boolean, titel: string, tun: () => Promise, + ): HTMLButtonElement { + const b = document.createElement('button'); + b.className = an ? 'rund an' : 'rund'; + b.innerHTML = icons[bild]; + b.title = titel; + b.setAttribute('aria-label', titel); + b.onclick = async () => { b.disabled = true; try { await tun(); } catch { b.disabled = false; } }; + return b; + } + + /* ---------- Medienangaben ---------- */ + + /** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */ + function medienangaben(datei: Datei): HTMLElement { + const liste = document.createElement('dl'); + liste.className = 'medientabelle'; + + const bild = datei.streams.find(s => s.kind === 'Video'); + const ton = datei.streams.filter(s => s.kind === 'Audio'); + const text = datei.streams.filter(s => s.kind === 'Subtitle'); + + zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes), + dauer(datei.durationSeconds)].filter(Boolean).join(' · ')); + if (bild) zeile('Video', `${bild.height ? `${bild.height}p` : '—'} (${(bild.codec ?? '?').toUpperCase()})`); + if (ton.length) zeile('Ton', ton.map(spurname).join(' · ')); + if (text.length) zeile('Untertitel', text.map(spurname).join(' · ')); + + function zeile(name: string, wert: string): void { + const dt = document.createElement('dt'); + dt.textContent = name; + const dd = document.createElement('dd'); + dd.textContent = wert; + liste.append(dt, dd); + } + + return liste; + } + + function spurname(s: Datei['streams'][number]): string { + const teile = [s.title, s.language?.toUpperCase(), s.codec?.toUpperCase()] + .filter(Boolean); + const kanaele = s.channels ? `${s.channels}.0` : null; + return [teile.join(' '), kanaele].filter(Boolean).join(' '); + } + + /* ---------- Besetzung ---------- */ + + function besetzung(mitwirkende: Mitwirkender[] | null): HTMLElement { const bereich = document.createElement('section'); - bereich.className = 'settings'; + if (!mitwirkende?.length) return bereich; - if (dateien.length === 0) { bereich.appendChild(leer('Keine Datei hinterlegt.')); return bereich; } + const h = document.createElement('h2'); + h.textContent = 'Besetzung & Stab'; - dateien.forEach(datei => { - const block = document.createElement('div'); - block.className = 'setting'; + const reihe = document.createElement('div'); + reihe.className = 'besetzung'; - const zeile = document.createElement('div'); - zeile.className = 'rowline'; - zeile.innerHTML = - `${esc(datei.fileName)}` + - `${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes), - dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}`; - block.appendChild(zeile); + mitwirkende.forEach(m => { + const k = document.createElement('div'); + k.className = 'kopf-kachel'; - const spuren = document.createElement('div'); - spuren.className = 'hint'; - spuren.innerHTML = datei.streams.map(s => { - const was = s.kind === 'Video' ? `Bild ${s.width}×${s.height}` - : s.kind === 'Audio' ? `Ton${s.channels ? ` ${s.channels}.0` : ''}` - : 'Text'; - const name = [s.language?.toUpperCase(), s.title].filter(Boolean).join(' · '); - return `
${esc(was)} — ${esc(s.codec ?? '?')}${name ? ` — ${esc(name)}` : ''}
`; - }).join(''); - block.appendChild(spuren); + const portrait = document.createElement('div'); + portrait.className = 'portrait'; + const adresse = bildAdresse(m.image); + if (adresse) { + const bild = document.createElement('img'); + bild.src = adresse; + bild.alt = ''; + bild.loading = 'lazy'; + portrait.appendChild(bild); + } else { + // Kein Bild: die Anfangsbuchstaben. Ein leerer Kreis sieht aus wie + // ein Ladefehler. + portrait.textContent = m.name.split(' ').map(t => t[0]).slice(0, 2).join(''); + } - block.appendChild(aktionen(datei, titel, stand)); - bereich.appendChild(block); + const rolle = m.role === 'Director' ? 'Regie' + : m.role === 'Writer' ? 'Drehbuch' + : m.character ?? ''; + + k.innerHTML += ''; + k.append(portrait); + const name = document.createElement('strong'); + name.textContent = m.name; + const unten = document.createElement('span'); + unten.textContent = rolle; + k.append(name, unten); + reihe.appendChild(k); }); + + bereich.append(h, reihe); return bereich; } - function aktionen(datei: Datei, titel: string, stand: Stand | null): HTMLElement { - const reihe = document.createElement('div'); - reihe.className = 'btn-row'; - const zustandszeile = document.createElement('div'); - zustandszeile.className = 'hint'; + /* ---------- Dateien ---------- */ - // **Gibt es eine Stelle, stehen beide Knöpfe da** — und zwar auch bei - // einem gesehenen Film. Der Server wirft die Stelle nicht weg, wenn das - // Häkchen kommt; sie hier trotzdem zu verschweigen hiesse, den - // Unterschied wegzuwerfen, um dessentwillen er sie behält. - // - // Ein „Abspielen", das stillschweigend in der Mitte anfängt, wäre die - // andere falsche Lösung: eine Überraschung, keine Hilfe. - const stelle = stand?.positionSeconds ?? 0; - const abspielen = knopf( - stelle > 0 ? `Weiterschauen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary'); - const vonVorn = stelle > 0 ? knopf('Von vorn', 'btn') : null; - const herunterladen = knopf('Herunterladen', 'btn'); + function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement { + const bereich = document.createElement('section'); + if (dateien.length <= 1) return bereich; - const haken = knopf(stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', 'btn'); - haken.onclick = async () => { - haken.disabled = true; - try { await gesehen(titel, !stand?.watched); await laden(); } - catch (fehler) { zustandszeile.textContent = (fehler as Error).message; haken.disabled = false; } - }; + // Nur bei mehreren Fassungen: bei einer stehen die Angaben schon oben. + const h = document.createElement('h2'); + h.textContent = 'Fassungen'; + bereich.appendChild(h); - /** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */ - async function bereitstellen(): Promise { - let auskunft: Lieferung; - try { auskunft = await lieferung(datei.id); } - catch (fehler) { zustandszeile.textContent = (fehler as Error).message; return false; } - - if (auskunft.method === 'directFile') return true; - if (auskunft.packageState === 'ready') return true; - - // **Hier steht, was passieren wird, bevor es passiert.** Welche Spuren - // neu gerechnet werden, welche Untertitel nicht mitkönnen. - const gruende: string[] = []; - if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`); - if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`); - zustandszeile.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`; - - // Der erste Abruf stösst an, danach wird nachgefragt. - for (let i = 0; i < 600; i++) { - const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' }); - if (antwort.ok) { zustandszeile.textContent = ''; return true; } - if (antwort.status !== 202) { zustandszeile.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; } - - const wie = await lieferung(datei.id); - if (wie.error) { zustandszeile.textContent = wie.error; return false; } - zustandszeile.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`; - await new Promise(r => setTimeout(r, 1000)); - } - zustandszeile.textContent = 'Das Umpacken dauert ungewöhnlich lange.'; - return false; - } - - async function starten(ab: number, knopfDavon: HTMLButtonElement): Promise { - knopfDavon.disabled = true; - if (await bereitstellen()) spieler(downloadAdresse(datei.id), titel, ab, laden); - knopfDavon.disabled = false; - } - - abspielen.onclick = () => void starten(stelle, abspielen); - if (vonVorn) vonVorn.onclick = () => void starten(0, vonVorn); - - herunterladen.onclick = async () => { - herunterladen.disabled = true; - if (await bereitstellen()) { - // Ein echter Link, kein Blob: der Browser lädt im Hintergrund und - // kann fortsetzen, statt den ganzen Film in den Speicher zu nehmen. - const a = document.createElement('a'); - a.href = downloadAdresse(datei.id); - a.download = ''; - a.click(); - } - herunterladen.disabled = false; - }; - - reihe.append(abspielen); - if (vonVorn) reihe.appendChild(vonVorn); - reihe.append(herunterladen, haken); - const huelle = document.createElement('div'); - huelle.append(reihe, zustandszeile); - return huelle; + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + dateien.forEach(datei => { + const block = document.createElement('div'); + block.className = 'einstellung'; + block.innerHTML = `
${esc(datei.fileName)}` + + `${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes), + dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}
`; + block.appendChild(aktionsreihe({ ...({} as Film), id: titel, files: dateien } as Film, stand, datei)); + liste.appendChild(block); + }); + bereich.appendChild(liste); + return bereich; } /* ---------- Staffeln und Folgen ---------- */ @@ -205,12 +352,12 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem const alle = await folgen(serie.id); serie.seasons.forEach(nummer => { - const titel = document.createElement('h2'); - titel.textContent = `Staffel ${nummer}`; - bereich.appendChild(titel); + const h = document.createElement('h2'); + h.textContent = `Staffel ${nummer}`; + bereich.appendChild(h); const liste = document.createElement('div'); - liste.className = 'settings'; + liste.className = 'einstellungen'; alle.items.filter(f => f.season === nummer).forEach(folge => { liste.appendChild(folgenblock(folge)); }); @@ -221,26 +368,85 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem function folgenblock(folge: Folge): HTMLElement { const block = document.createElement('div'); - block.className = 'setting'; + block.className = 'einstellung'; + const zeile = document.createElement('div'); - zeile.className = 'rowline'; + zeile.className = 'zeile'; const nebenbei = folge.state?.watched ? 'Gesehen' - : folge.state && folge.state.positionSeconds > 0 ? verbleibend(folge.state) : ''; + : folge.state && folge.state.positionSeconds > 0 + ? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min` + : ''; zeile.innerHTML = `${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}` - + (nebenbei ? `${esc(nebenbei)}` : ''); + + (nebenbei ? `${esc(nebenbei)}` : ''); block.appendChild(zeile); if (folge.overview) { const text = document.createElement('div'); - text.className = 'hint'; + text.className = 'hinweis'; text.textContent = folge.overview; block.appendChild(text); } - folge.files.forEach(datei => block.appendChild(aktionen(datei, folge.id, folge.state))); + + folge.files.forEach(datei => block.appendChild( + aktionsreihe({ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei))); return block; } + /* ---------- Abspielen und Herunterladen ---------- */ + + /** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */ + async function bereitstellen(datei: Datei, meldung: HTMLElement): Promise { + let auskunft: Lieferung; + try { auskunft = await lieferung(datei.id); } + catch (fehler) { meldung.textContent = (fehler as Error).message; return false; } + + if (auskunft.method === 'directFile') return true; + if (auskunft.packageState === 'ready') return true; + + // **Hier steht, was passieren wird, bevor es passiert.** + const gruende: string[] = []; + if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`); + if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`); + meldung.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`; + + for (let i = 0; i < 600; i++) { + const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' }); + if (antwort.ok) { meldung.textContent = ''; return true; } + if (antwort.status !== 202) { meldung.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; } + + const wie = await lieferung(datei.id); + if (wie.error) { meldung.textContent = wie.error; return false; } + meldung.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`; + await new Promise(r => setTimeout(r, 1000)); + } + meldung.textContent = 'Das Umpacken dauert ungewöhnlich lange.'; + return false; + } + + async function starten( + datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, meldung: HTMLElement, + ): Promise { + knopfDavon.disabled = true; + if (await bereitstellen(datei, meldung)) spieler(downloadAdresse(datei.id), titel, ab, laden); + knopfDavon.disabled = false; + } + + async function herunterladen( + datei: Datei, knopfDavon: HTMLButtonElement, meldung: HTMLElement, + ): Promise { + knopfDavon.disabled = true; + if (await bereitstellen(datei, meldung)) { + // Ein echter Link, kein Blob: der Browser lädt im Hintergrund und kann + // fortsetzen, statt den ganzen Film in den Speicher zu nehmen. + const a = document.createElement('a'); + a.href = downloadAdresse(datei.id); + a.download = ''; + a.click(); + } + knopfDavon.disabled = false; + } + /* ---------- Zuordnung korrigieren ---------- */ function zuordnungsbereich(titel: string, jahr: number | null): HTMLElement { @@ -248,6 +454,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem if (!istAdmin()) return bereich; const oeffner = knopf('Zuordnung korrigieren', 'btn'); + oeffner.prepend(icon('stift')); bereich.appendChild(oeffner); oeffner.onclick = () => { @@ -259,7 +466,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem function zuordnungsmaske(titel: string, jahr: number | null): HTMLElement { const maske = document.createElement('div'); - maske.className = 'settings'; + maske.className = 'einstellungen'; const suche = feld('Anderer Suchbegriff'); suche.value = titel; @@ -268,7 +475,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem const suchen = knopf('Suchen', 'btn'); const reihe = document.createElement('div'); - reihe.className = 'btn-row'; + reihe.className = 'knopfreihe'; reihe.append(suche, jahrfeld, suchen); const treffer = document.createElement('div'); @@ -285,10 +492,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem liste.forEach(v => { const zeile = document.createElement('button'); - zeile.className = 'setting pick'; + zeile.className = 'einstellung waehlbar'; zeile.innerHTML = - `${esc(v.title)} ${esc(v.year ?? '')} · TMDB ${v.tmdbId}` + - `
${esc((v.overview ?? '').slice(0, 160))}
`; + `
${esc(v.title)}` + + `${esc(v.year ?? '')} · TMDB ${v.tmdbId}
` + + `
${esc((v.overview ?? '').slice(0, 160))}
`; zeile.onclick = async () => { zeile.disabled = true; try { @@ -312,7 +520,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem return wurzel; } -/* ---------- Abspielen ---------- */ +/* ---------- Der Spieler ---------- */ /** Wie oft der Stand zum Server geht. */ const MELDEABSTAND = 10_000; @@ -353,8 +561,6 @@ function spieler(adresse: string, titel: string, ab: number, danach: () => void) video.pause(); melden(true); schicht.remove(); - // Damit Knopfbeschriftung, Balken und Häkchen stimmen, wenn man - // dahinter wieder auftaucht. danach(); }; diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index 1d65259..a4353c7 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -1,18 +1,18 @@ import { - inhalt, weiterschauen, naechsteFolgen, - type Bibliothek, type Film, type Serie, type Angefangen, + inhalt, weiterschauen, naechsteFolgen, empfehlungen, + type Bibliothek, } from '../api'; -import { esc, plakat, leer, fehlerzeile, anteil, verbleibend } from '../util'; +import { leer, fehlerzeile } from '../util'; +import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card'; type Art = 'filme' | 'serien'; -/** Die Startseite: waagerechte Reihen, wie bei Plex. +/** Die Startseite — waagerechte Reihen, wie bei Plex. * - * Oben, was angefangen ist — darum geht man schliesslich hin. Darunter **je - * Mediathek eine Reihe** mit dem Zuletzt-Hinzugefügten. Nicht eine Reihe für - * alle Filme: wer „Filme" und „Dokumentationen" getrennt angelegt hat, hat - * sie getrennt gemeint, und eine Startseite, die sie wieder zusammenwirft, - * nimmt die Entscheidung zurück. + * Oben, was angefangen ist. Darunter **je Mediathek eine Reihe** mit dem + * zuletzt Hinzugefügten: wer „Filme" und „Dokumentationen" getrennt angelegt + * hat, hat sie getrennt gemeint, und eine Startseite, die sie wieder + * zusammenwirft, nimmt die Entscheidung zurück. * * Eine Reihe ohne Inhalt wird gar nicht erst gezeigt. */ export function startseite( @@ -25,13 +25,13 @@ export function startseite( void (async () => { try { - // **Alles auf einmal anfragen, nicht eine Reihe nach der anderen.** Bei - // fünf Mediatheken wären das sonst sieben Anfragen nacheinander, und - // die Startseite baut sich vor den Augen des Benutzers Zeile für Zeile - // auf. - const [weiter, naechste, ...bestaende] = await Promise.all([ + // **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst + // acht Anfragen nacheinander, und die Startseite baut sich vor den + // Augen des Benutzers Zeile für Zeile auf. + const [weiter, naechste, vorschlag, ...bestaende] = await Promise.all([ weiterschauen(), naechsteFolgen(), + empfehlungen(24).catch(() => ({ items: [], total: 0 })), ...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })), ]); @@ -43,17 +43,37 @@ export function startseite( return; } - if (weiter.items.length) wurzel.appendChild(angefangenReihe('Weiterschauen', weiter.items)); - if (naechste.items.length) wurzel.appendChild(angefangenReihe('Nächste Folge', naechste.items)); + if (weiter.items.length) { + wurzel.appendChild(reihe('Fortsetzen', + weiter.items.map(e => kachel(angefangenAlsKachel(e), oeffnen)))); + } + if (naechste.items.length) { + wurzel.appendChild(reihe('Nächste Folge', + naechste.items.map(e => kachel(angefangenAlsKachel(e), oeffnen)))); + } let etwasDa = false; bibliotheken.forEach((b, i) => { const seite = bestaende[i]; if (!seite?.items.length) return; etwasDa = true; - wurzel.appendChild(reihe(b, seite.items, seite.total)); + wurzel.appendChild(reihe( + // Plex schreibt den Namen der Mediathek in die Überschrift — + // „Zuletzt in Filme hinzugefügt". Bei mehreren Mediatheken ist das + // der Unterschied zwischen einer Reihe und sieben gleich + // aussehenden. + `Zuletzt in ${b.name} hinzugefügt`, + seite.items.map(e => kachel(alsKachel(e), oeffnen)), + seite.total > seite.items.length + ? { text: 'Alle zeigen', tun: () => alleZeigen(b) } + : undefined)); }); + if (vorschlag.items.length) { + wurzel.appendChild(reihe('Weil du Ähnliches gesehen hast', + vorschlag.items.map(e => kachel(alsKachel(e), oeffnen)))); + } + if (!etwasDa && !weiter.items.length && !naechste.items.length) { wurzel.appendChild(leer('Noch nichts da. Starte unter Verwaltung einen Scan.')); } @@ -63,99 +83,5 @@ export function startseite( } })(); - /** Eine Reihe aus Einträgen mit Stand — Filme und Folgen gemischt. */ - function angefangenReihe(titel: string, eintraege: Angefangen[]): HTMLElement { - const bereich = kopfzeile(titel); - const streifen = document.createElement('div'); - streifen.className = 'strip'; - - eintraege.forEach(eintrag => { - const k = document.createElement('button'); - k.className = 'card'; - k.appendChild(plakat(eintrag.posterImage, eintrag.title, anteil(eintrag.state))); - - const unten = document.createElement('div'); - unten.className = 'rowline'; - // Bei einer Folge oben die Serie, darunter wo man ist — eine Folge - // ohne ihre Serie zu nennen hilft niemandem beim Wiedererkennen. - const zweite = eintrag.kind === 'episode' - ? `S${String(eintrag.season).padStart(2, '0')}E${String(eintrag.episode).padStart(2, '0')}` - + (eintrag.subTitle ? ` · ${eintrag.subTitle}` : '') - : verbleibend(eintrag.state); - unten.innerHTML = `${esc(eintrag.title)}` - + `${esc(zweite)}`; - k.appendChild(unten); - - // Angesprungen wird der Titel, nicht die Folge: von dort kommt man zur - // Folge, umgekehrt fehlte der Zusammenhang. - k.onclick = () => oeffnen(eintrag.kind === 'movie' ? 'filme' : 'serien', eintrag.id); - streifen.appendChild(k); - }); - - bereich.appendChild(streifen); - return bereich; - } - - function reihe(b: Bibliothek, eintraege: (Film | Serie)[], gesamt: number): HTMLElement { - const art: Art = b.kind === 'Shows' ? 'serien' : 'filme'; - // Steht mehr drin, als die Reihe zeigt, führt ein Weg dorthin. Ohne ihn - // wäre die Reihe eine Sackgasse mit vierundzwanzig Kacheln. - const bereich = kopfzeile( - b.name, gesamt > eintraege.length ? () => alleZeigen(b) : undefined, gesamt); - - const streifen = document.createElement('div'); - streifen.className = 'strip'; - - eintraege.forEach(eintrag => { - const k = document.createElement('button'); - k.className = 'card'; - - const bild = plakat( - eintrag.posterImage, eintrag.title, - 'state' in eintrag ? anteil(eintrag.state) : null); - if ('unwatchedCount' in eintrag && eintrag.unwatchedCount > 0) { - const plakette = document.createElement('span'); - plakette.className = 'offen'; - plakette.textContent = String(eintrag.unwatchedCount); - bild.appendChild(plakette); - } - k.appendChild(bild); - - const unten = document.createElement('div'); - unten.className = 'rowline'; - unten.innerHTML = `${esc(eintrag.title)}` - + `${esc(eintrag.year ?? '')}`; - k.appendChild(unten); - k.onclick = () => oeffnen(art, eintrag.id); - streifen.appendChild(k); - }); - - bereich.appendChild(streifen); - return bereich; - } - - function kopfzeile(titel: string, mehr?: () => void, gesamt?: number): HTMLElement { - const bereich = document.createElement('section'); - bereich.className = 'reihe'; - - const zeile = document.createElement('div'); - zeile.className = 'reihe-kopf'; - - const h = document.createElement('h2'); - h.textContent = gesamt != null ? `${titel} · ${gesamt}` : titel; - zeile.appendChild(h); - - if (mehr) { - const knopf = document.createElement('button'); - knopf.className = 'mehr'; - knopf.textContent = 'Alle zeigen'; - knopf.onclick = mehr; - zeile.appendChild(knopf); - } - - bereich.appendChild(zeile); - return bereich; - } - return wurzel; } diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 118fe55..298ea91 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -1,124 +1,238 @@ -import { inhalt, type Bibliothek, type Film, type Serie, type Ordnung } from '../api'; -import { esc, plakat, leer, fehlerzeile, anteil } from '../util'; +import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api'; +import { leer, fehlerzeile } from '../util'; +import { kachel, alsKachel, reihe } from '../card'; type Art = 'filme' | 'serien'; +type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien'; -/** Eine Mediathek als Raster. +/** Eine Mediathek — mit Reitern, wie bei Plex. * - * **Eine Ansicht je Mediathek, nicht eine je Art.** Welche gezeigt wird, - * entscheidet die Seitenleiste — wie bei Plex, wo jede Mediathek ihr eigener - * Punkt in der Navigation ist. Wer „Filme" und „Dokumentationen" getrennt - * angelegt hat, hat sie getrennt gemeint. - * - * Gesucht und sortiert wird am Server: bei zehntausend Titeln wäre alles zu - * laden und dann zu sieben der falsche Weg. */ + * **Drei Sichten auf denselben Bestand.** „Empfehlungen" ist das, womit man + * ankommt; „Mediathek" ist das vollständige Raster für alle, die etwas + * Bestimmtes suchen; „Kategorien" ist der Weg hinein, wenn man noch nicht + * weiss, wonach. Alles in eine Ansicht zu legen hiesse, zwei davon unter ein + * Ausklappmenü zu schieben. */ export function mediathek( bibliothek: Bibliothek, + servername: string, + reiter: string, oeffnen: (art: Art, id: string) => void, ): HTMLElement { const wurzel = document.createElement('div'); - const art: Art = bibliothek.kind === 'Shows' ? 'serien' : 'filme'; - let suche = ''; - let ordnung: Ordnung = 'title'; - let tippt: number | undefined; + const welcher: Reiter = + reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek'; - const kopf = document.createElement('div'); - kopf.className = 'row'; + wurzel.appendChild(kopf()); + const buehne = document.createElement('div'); + wurzel.appendChild(buehne); - const titel = document.createElement('h1'); - titel.className = 'seitentitel'; - titel.textContent = bibliothek.name; + if (welcher === 'empfehlungen') empfehlungen(buehne); + else if (welcher === 'kategorien') kategorien(buehne); + else raster(buehne); - const sortierung = document.createElement('select'); - sortierung.className = 'field schmal'; - ([ - ['title', 'A–Z'], - ['added', 'Zuletzt hinzugefügt'], - ['year', 'Jahr'], - ] as [Ordnung, string][]).forEach(([wert, text]) => { - const o = document.createElement('option'); - o.value = wert; - o.textContent = text; - sortierung.appendChild(o); - }); - sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); }; + /* ---------- Kopf mit Reitern ---------- */ - const suchfeld = document.createElement('input'); - suchfeld.className = 'field'; - suchfeld.type = 'search'; - suchfeld.placeholder = `In ${bibliothek.name} suchen`; - suchfeld.oninput = () => { - // Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun - // Anfragen, von denen acht niemanden interessieren. - window.clearTimeout(tippt); - tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250); - }; + function kopf(): HTMLElement { + const k = document.createElement('div'); + k.className = 'seitenkopf'; - kopf.append(titel, sortierung, suchfeld); + const wer = document.createElement('div'); + wer.className = 'wer'; + const h = document.createElement('h1'); + h.textContent = bibliothek.name; + const quelle = document.createElement('div'); + quelle.className = 'quelle'; + quelle.textContent = servername; + wer.append(h, quelle); - const raster = document.createElement('div'); - raster.className = 'grid'; + const reiterleiste = document.createElement('nav'); + reiterleiste.className = 'reiter'; + ([ + ['empfehlungen', 'Empfehlungen'], + ['mediathek', 'Mediathek'], + ['kategorien', 'Kategorien'], + ] as [Reiter, string][]).forEach(([wert, text]) => { + const b = document.createElement('button'); + b.textContent = text; + b.classList.toggle('check', wert === welcher); + b.onclick = () => { location.hash = `#/m/${bibliothek.id}/${wert}`; }; + reiterleiste.appendChild(b); + }); - wurzel.append(kopf, raster); - - async function laden(): Promise { - raster.innerHTML = ''; - raster.appendChild(ladeanzeige()); - try { - const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, take: 100 }); - - raster.innerHTML = ''; - if (seite.items.length === 0) { - raster.appendChild(leer(suche - ? `Nichts gefunden für „${suche}“.` - : `„${bibliothek.name}“ ist leer. Starte unter Verwaltung einen Scan.`)); - return; - } - seite.items.forEach(eintrag => raster.appendChild(karte(eintrag))); - } catch (fehler) { - raster.innerHTML = ''; - raster.appendChild(fehlerzeile((fehler as Error).message)); - } - } - - function karte(eintrag: Film | Serie): HTMLElement { - const k = document.createElement('button'); - k.className = 'card'; - - const bild = plakat( - eintrag.posterImage, eintrag.title, - 'state' in eintrag ? anteil(eintrag.state) : null); - if ('unwatchedCount' in eintrag && eintrag.unwatchedCount > 0) { - const plakette = document.createElement('span'); - plakette.className = 'offen'; - plakette.textContent = String(eintrag.unwatchedCount); - bild.appendChild(plakette); - } - k.appendChild(bild); - - const unten = document.createElement('div'); - unten.className = 'rowline'; - // Zusammengesetzt aus dem, was da ist — ein fest verdrahtetes „ · " davor - // hinterlässt bei fehlendem Jahr einen Punkt ohne Angabe. - const teile = [ - eintrag.year ? String(eintrag.year) : null, - 'episodeCount' in eintrag ? `${eintrag.episodeCount} Folgen` : null, - 'state' in eintrag && eintrag.state?.watched ? 'Gesehen' : null, - ].filter(Boolean); - unten.innerHTML = `${esc(eintrag.title)}` - + `${esc(teile.join(' · '))}`; - k.appendChild(unten); - - k.onclick = () => oeffnen(art, eintrag.id); + k.append(wer, reiterleiste); return k; } - void laden(); + /* ---------- Reiter „Mediathek" ---------- */ + + function raster(ziel: HTMLElement): void { + let suche = ''; + let ordnung: Ordnung = 'title'; + let genre = ''; + let tippt: number | undefined; + + const zeile = document.createElement('div'); + zeile.className = 'filterzeile'; + + const anzahl = document.createElement('span'); + anzahl.className = 'anzahl'; + anzahl.textContent = '…'; + + const genreWahl = document.createElement('select'); + genreWahl.innerHTML = ''; + genreWahl.onchange = () => { genre = genreWahl.value; void laden(); }; + + const sortierung = document.createElement('select'); + ([ + ['title', 'Nach Titel'], + ['added', 'Zuletzt hinzugefügt'], + ['year', 'Nach Jahr'], + ] as [Ordnung, string][]).forEach(([wert, text]) => { + const o = document.createElement('option'); + o.value = wert; o.textContent = text; + sortierung.appendChild(o); + }); + sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); }; + + const suchfeld = document.createElement('input'); + suchfeld.className = 'suche'; + suchfeld.type = 'search'; + suchfeld.placeholder = `In ${bibliothek.name} suchen`; + suchfeld.oninput = () => { + // Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun + // Anfragen, von denen acht niemanden interessieren. + window.clearTimeout(tippt); + tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250); + }; + + zeile.append(genreWahl, sortierung, anzahl, suchfeld); + + const gitter = document.createElement('div'); + gitter.className = 'raster'; + ziel.append(zeile, gitter); + + // Die Genres der Mediathek nachladen — die Liste steht erst, wenn der + // Server sie genannt hat, und solange bleibt „Alle Genres" allein. + void genresVon(bibliothek.id).then(namen => { + namen.forEach(n => { + const o = document.createElement('option'); + o.value = n; o.textContent = n; + genreWahl.appendChild(o); + }); + }).catch(() => {}); + + void laden(); + + async function laden(): Promise { + gitter.innerHTML = '
'; + try { + const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 }); + anzahl.textContent = String(seite.total); + + gitter.innerHTML = ''; + if (seite.items.length === 0) { + gitter.appendChild(leer(suche || genre + ? 'Nichts gefunden.' + : `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`)); + return; + } + seite.items.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); + } catch (fehler) { + gitter.innerHTML = ''; + gitter.appendChild(fehlerzeile((fehler as Error).message)); + } + } + } + + /* ---------- Reiter „Empfehlungen" ---------- */ + + function empfehlungen(ziel: HTMLElement): void { + ziel.innerHTML = '
'; + + void (async () => { + try { + const [neu, beliebt, vorschlag] = await Promise.all([ + inhalt(bibliothek, { sort: 'added', take: 24 }), + inhalt(bibliothek, { sort: 'year', take: 24 }), + holeEmpfehlungen(24).catch(() => ({ items: [], total: 0 })), + ]); + + ziel.innerHTML = ''; + if (neu.items.length === 0) { + ziel.appendChild(leer(`„${bibliothek.name}" ist leer.`)); + return; + } + + ziel.appendChild(reihe( + `Zuletzt in ${bibliothek.name} hinzugefügt`, + neu.items.map(e => kachel(alsKachel(e), oeffnen)))); + + if (vorschlag.items.length) { + ziel.appendChild(reihe( + 'Weil du Ähnliches gesehen hast', + vorschlag.items.map(e => kachel(alsKachel(e), oeffnen)))); + } + + ziel.appendChild(reihe( + 'Nach Jahr', + beliebt.items.map(e => kachel(alsKachel(e), oeffnen)))); + } catch (fehler) { + ziel.innerHTML = ''; + ziel.appendChild(fehlerzeile((fehler as Error).message)); + } + })(); + } + + /* ---------- Reiter „Kategorien" ---------- */ + + function kategorien(ziel: HTMLElement): void { + ziel.innerHTML = '
'; + + void (async () => { + try { + const namen = await genresVon(bibliothek.id); + ziel.innerHTML = ''; + if (namen.length === 0) { + ziel.appendChild(leer('Für diese Mediathek sind noch keine Genres bekannt. ' + + 'Frisch unter Verwaltung die Metadaten auf.')); + return; + } + + const gitter = document.createElement('div'); + gitter.className = 'kategorien'; + namen.forEach(name => { + const k = document.createElement('button'); + k.className = 'kategorie'; + k.textContent = name; + // **Die Farbe aus dem Namen, nicht aus einer Liste.** Eine feste + // Zuordnung Genre→Farbe müsste jedes Genre kennen, das TMDB je + // erfindet. So bekommt jedes eine eigene und immer dieselbe. + k.style.background = farbeAus(name); + k.onclick = () => { + location.hash = `#/m/${bibliothek.id}/mediathek`; + // Der Filter wird über die Adresse gesetzt, sobald das Raster + // steht — sonst schriebe man in ein Element, das es nicht gibt. + window.setTimeout(() => { + const wahl = document.querySelector('.filterzeile select'); + if (wahl) { wahl.value = name; wahl.dispatchEvent(new Event('change')); } + }, 0); + }; + gitter.appendChild(k); + }); + ziel.appendChild(gitter); + } catch (fehler) { + ziel.innerHTML = ''; + ziel.appendChild(fehlerzeile((fehler as Error).message)); + } + })(); + } + return wurzel; } -function ladeanzeige(): HTMLElement { - const d = document.createElement('div'); - d.className = 'spinner'; - return d; +/** Eine immer gleiche Farbe aus einem Namen. */ +function farbeAus(name: string): string { + let summe = 0; + for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360; + return `linear-gradient(135deg, hsl(${summe} 42% 28%), hsl(${(summe + 40) % 360} 38% 20%))`; } diff --git a/web/app/src/views/search.ts b/web/app/src/views/search.ts new file mode 100644 index 0000000..f5126bd --- /dev/null +++ b/web/app/src/views/search.ts @@ -0,0 +1,59 @@ +import { filme, serien } from '../api'; +import { esc, leer, fehlerzeile } from '../util'; +import { kachel, alsKachel } from '../card'; + +/** Suchergebnisse quer durch alle Mediatheken. + * + * **Nicht je Mediathek.** Wer einen Titel sucht, weiss oft gar nicht, in + * welcher er liegt — genau deshalb steht die Suche oben in der Kopfleiste + * und nicht in jeder Mediathek einzeln. */ +export function suchseite( + text: string, + oeffnen: (art: 'filme' | 'serien', id: string) => void, +): HTMLElement { + const wurzel = document.createElement('div'); + + const kopf = document.createElement('div'); + kopf.className = 'seitenkopf'; + kopf.innerHTML = `

${esc(text)}

` + + '
Suchergebnisse
'; + wurzel.appendChild(kopf); + + const buehne = document.createElement('div'); + buehne.innerHTML = '
'; + wurzel.appendChild(buehne); + + void (async () => { + try { + const [f, s] = await Promise.all([ + filme({ search: text, take: 100 }), + serien({ search: text, take: 100 }), + ]); + + buehne.innerHTML = ''; + if (f.total === 0 && s.total === 0) { + buehne.appendChild(leer(`Nichts gefunden für „${text}".`)); + return; + } + + if (f.total > 0) buehne.appendChild(abschnitt(`Filme · ${f.total}`, f.items)); + if (s.total > 0) buehne.appendChild(abschnitt(`Serien · ${s.total}`, s.items)); + } catch (fehler) { + buehne.innerHTML = ''; + buehne.appendChild(fehlerzeile((fehler as Error).message)); + } + })(); + + function abschnitt(titel: string, eintraege: Parameters[0][]): HTMLElement { + const bereich = document.createElement('section'); + const h = document.createElement('h2'); + h.textContent = titel; + const gitter = document.createElement('div'); + gitter.className = 'raster'; + eintraege.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); + bereich.append(h, gitter); + return bereich; + } + + return wurzel; +} diff --git a/web/app/src/views/setup.ts b/web/app/src/views/setup.ts index 9682def..0be2111 100644 --- a/web/app/src/views/setup.ts +++ b/web/app/src/views/setup.ts @@ -12,7 +12,7 @@ export function einrichtung(fertig: () => void): HTMLElement { const titel = document.createElement('h1'); titel.textContent = 'Flaro einrichten'; const text = document.createElement('p'); - text.className = 'hint'; + text.className = 'hinweis'; text.textContent = 'Lege das erste Konto an. Es wird Administrator und kann ' + 'später Bibliotheken und weitere Benutzer verwalten.';