Die Oberfläche nach Plex' Vorbild — Hintergrund, Schriftzug, Besetzung
Nach den Bildern deiner Plex-Installation umgebaut. Übernommen ist der Aufbau, nicht die Farbe: Plex ist bernsteingelb, Flaro bleibt Orchidee. **Kopfleiste oben** mit Marke, Suche und Konto. Die Suche gehört dorthin und nicht in jede Mediathek: wer einen Titel sucht, weiss oft gar nicht, in welcher er liegt. Sie geht quer durch alles. **Seitenleiste mit Icons** — echte SVG, keine Zeichen aus einer Schriftart. Ein ▶ aus Unicode sieht auf jedem System anders aus, lässt sich nicht einfärben und springt in der Grundlinie. Die aktive Zeile bekommt einen schmalen Balken links statt eines leuchtenden Blocks; das liest sich in einer langen Liste ruhiger. **Die Kachel steht jetzt an einer Stelle** statt an fünfen. Sie trägt den Haken für Gesehenes in der Ecke — wie bei Plex, ein Blick über das Raster genügt —, die Zahl offener Folgen an derselben Stelle, den Fortschrittsbalken unten und beim Überfahren einen Abspielknopf. Folgen sind dreizeilig: Serie, Folgentitel, „S14 · F22". **Reihen haben Pfeile.** Zwanzig Kacheln passen nicht in den Bildschirm, und quer zu scrollen geht mit dem Rad nur mit Umschalttaste. Die Überschrift nennt die Mediathek beim Namen: „Zuletzt in Dokumentationen hinzugefügt". **Mediatheken haben drei Reiter** — Empfehlungen, Mediathek, Kategorien. Darunter eine Filterzeile mit Genre, Sortierung und der Anzahl. Die Kategorien sind farbige Kacheln; die Farbe kommt aus dem Namen, nicht aus einer festen Liste, die jedes Genre kennen müsste, das TMDB je erfindet. **Die Detailseite** hat jetzt den unscharfen Hintergrund über die ganze Seite, den **Schriftzug des Films** statt der Systemschrift, eine Zeile mit Jahr, Genres, Laufzeit, Altersfreigabe und Bewertung, runde Knöpfe für gesehen/Favorit/weglegen, die Medienangaben als Tabelle — Datei, Video, Ton, Untertitel — und zuletzt Besetzung & Stab mit runden Porträts. **Dazu am Server:** ein Name. Ohne ihn zeigten die Clients den Hostnamen, und die App schrieb „localhost" über die Anmeldung. Vorgabe ist der Rechnername des Containers. --- **Der Fehler, den das erste Bild zeigte:** die Lupe in der Suchleiste war dreihundert Bildpunkte gross und lag über der halben Seite. Ein rohes `<svg>` ohne Breite füllt seinen Container; die Regel galt nur für `.icon`. Jetzt für jedes. 368 Tests grün. Am Container nachgesehen: „Der Untergang" mit eigenem Schriftzug, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm, beide Tonspuren in der Tabelle, 22 Porträts darunter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ede4069cd4
commit
8c395ac051
@@ -5,7 +5,10 @@ namespace Flaro.Api.Endpoints;
|
||||
// und bricht Clients, die niemand mehr aktualisiert.
|
||||
|
||||
public sealed record SystemInfo(
|
||||
string Version, bool NeedsSetup, IReadOnlyList<AttributionResponse> Attributions);
|
||||
string Version, string ServerName, bool NeedsSetup,
|
||||
IReadOnlyList<AttributionResponse> Attributions);
|
||||
|
||||
public sealed record ServerNameRequest(string Name);
|
||||
|
||||
public sealed record SetupRequest(string UserName, string Password);
|
||||
|
||||
|
||||
@@ -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
|
||||
/// </remarks>
|
||||
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
|
||||
|
||||
/// <summary>Unter welchem Schlüssel der Servername liegt.</summary>
|
||||
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<string, string[]>
|
||||
{
|
||||
["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)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<section id="login"></section>
|
||||
<header id="topbar" class="hidden"></header>
|
||||
<div id="app" class="hidden">
|
||||
<nav id="chrome"></nav>
|
||||
<main id="content"></main>
|
||||
|
||||
+41
-2
@@ -21,15 +21,36 @@ export type Stand = {
|
||||
favourite: boolean; rating: number | null; dismissed: boolean;
|
||||
lastPlayedAt: string | null;
|
||||
};
|
||||
export type Mitwirkender = {
|
||||
id: string; name: string;
|
||||
role: 'Actor' | 'Director' | 'Writer';
|
||||
character: string | null; image: string | null;
|
||||
};
|
||||
|
||||
export type Film = {
|
||||
id: string; title: string; year: number | null; overview: string | null;
|
||||
posterImage: string | null; backdropImage: string | null; files: Datei[];
|
||||
state: Stand | null;
|
||||
logoImage: string | null;
|
||||
runtimeMinutes: number | null;
|
||||
rating: number | null;
|
||||
tagline: string | null;
|
||||
contentRating: string | null;
|
||||
/** Leer in Listen, gefüllt beim Einzelabruf. */
|
||||
genres: string[] | null;
|
||||
credits: Mitwirkender[] | null;
|
||||
};
|
||||
export type Serie = {
|
||||
id: string; title: string; year: number | null; overview: string | null;
|
||||
posterImage: string | null; backdropImage: string | null;
|
||||
episodeCount: number; seasons: number[]; unwatchedCount: number;
|
||||
logoImage: string | null;
|
||||
runtimeMinutes: number | null;
|
||||
rating: number | null;
|
||||
tagline: string | null;
|
||||
contentRating: string | null;
|
||||
genres: string[] | null;
|
||||
credits: Mitwirkender[] | null;
|
||||
};
|
||||
export type Folge = {
|
||||
id: string; season: number; episode: number;
|
||||
@@ -71,6 +92,10 @@ export type Vorschlag = {
|
||||
overview: string | null; posterUrl: string | null;
|
||||
};
|
||||
export type Hinweis = { text: string; logoUrl: string; url: string };
|
||||
export type Systemauskunft = {
|
||||
version: string; serverName: string | null;
|
||||
needsSetup: boolean; attributions: Hinweis[];
|
||||
};
|
||||
|
||||
/* ---------- Sitzung ---------- */
|
||||
|
||||
@@ -139,7 +164,7 @@ export const bildAdresse = (schluessel: string | null): string | null =>
|
||||
/* ---------- System und Anmeldung ---------- */
|
||||
|
||||
export const systemauskunft = () =>
|
||||
anfrage<{ version: string; needsSetup: boolean; attributions: Hinweis[] }>('/system/info');
|
||||
anfrage<Systemauskunft>('/system/info');
|
||||
|
||||
export const einrichten = (benutzer: string, passwort: string) =>
|
||||
anfrage<Rolle>('/system/setup', {
|
||||
@@ -188,7 +213,8 @@ function geraetekennung(): string {
|
||||
export type Ordnung = 'title' | 'added' | 'year';
|
||||
|
||||
type Suchfrage = {
|
||||
library?: string; search?: string; sort?: Ordnung; skip?: number; take?: number;
|
||||
library?: string; search?: string; sort?: Ordnung; genre?: string;
|
||||
skip?: number; take?: number;
|
||||
};
|
||||
|
||||
export const filme = (q: Suchfrage = {}) => anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
|
||||
@@ -247,6 +273,19 @@ export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/downlo
|
||||
|
||||
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
|
||||
|
||||
/** Nur die Genres, die in dieser Mediathek auch vorkommen. */
|
||||
export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/genres`);
|
||||
|
||||
/** „Weil du Ähnliches gesehen hast" — über die Genres des Gesehenen. */
|
||||
export const empfehlungen = (take = 20) =>
|
||||
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
|
||||
|
||||
export const metadatenAuffrischen = (id: string) =>
|
||||
anfrage<void>(`/libraries/${id}/refresh`, { method: 'POST' });
|
||||
|
||||
export const servernameSetzen = (name: string) =>
|
||||
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
|
||||
|
||||
export const bibliothekUmbenennen = (id: string, name: string) =>
|
||||
anfrage<Bibliothek>(`/libraries/${id}`, {
|
||||
method: 'PUT', body: JSON.stringify({ name }),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api';
|
||||
import { esc, anteil } from './util';
|
||||
import { icons } from './icons';
|
||||
|
||||
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
|
||||
export type Kachel = {
|
||||
id: string;
|
||||
art: 'filme' | 'serien';
|
||||
/** Die obere, fette Zeile. Bei einer Folge die Serie. */
|
||||
titel: string;
|
||||
/** Die zweite Zeile. Bei einer Folge der Folgentitel, sonst das Jahr. */
|
||||
zweite?: string | null;
|
||||
/** Die dritte Zeile — „S14 · F22". Nur bei Folgen. */
|
||||
dritte?: string | null;
|
||||
bild: string | null;
|
||||
stand?: Stand | null;
|
||||
/** Die Zahl offener Folgen, als Plakette oben rechts. */
|
||||
offen?: number | null;
|
||||
};
|
||||
|
||||
/* ---------- Umwandlungen ---------- */
|
||||
|
||||
export const alsKachel = (e: Film | Serie): Kachel => ({
|
||||
id: e.id,
|
||||
art: 'episodeCount' in e ? 'serien' : 'filme',
|
||||
titel: e.title,
|
||||
zweite: e.year ? String(e.year) : null,
|
||||
bild: e.posterImage,
|
||||
stand: 'state' in e ? e.state : null,
|
||||
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
|
||||
});
|
||||
|
||||
/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */
|
||||
export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode'
|
||||
? {
|
||||
id: e.id, art: 'serien',
|
||||
titel: e.title,
|
||||
zweite: e.subTitle,
|
||||
dritte: `S${e.season} · F${e.episode}`,
|
||||
bild: e.posterImage, stand: e.state,
|
||||
}
|
||||
: {
|
||||
id: e.id, art: 'filme',
|
||||
titel: e.title,
|
||||
zweite: e.year ? String(e.year) : null,
|
||||
bild: e.posterImage, stand: e.state,
|
||||
};
|
||||
|
||||
/* ---------- Die Kachel selbst ---------- */
|
||||
|
||||
/** Baut eine Plakatkachel.
|
||||
*
|
||||
* **Alles auf einer Kachel steht an einer Stelle**, weil sie an fünf Stellen
|
||||
* vorkommt — Startseite, Mediathek, Vorschläge, Suche, Staffeln. Jede davon
|
||||
* eigen zu zeichnen hiesse: der Haken für Gesehenes erscheint an vier
|
||||
* Stellen und an der fünften nicht, und niemand weiss, warum. */
|
||||
export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement {
|
||||
const knopf = document.createElement('button');
|
||||
knopf.className = 'kachel';
|
||||
knopf.onclick = () => oeffnen(k.art, k.id);
|
||||
|
||||
const rahmen = document.createElement('div');
|
||||
rahmen.className = 'plakat';
|
||||
rahmen.setAttribute('role', 'img');
|
||||
rahmen.setAttribute('aria-label', k.titel);
|
||||
|
||||
const adresse = bildAdresse(k.bild);
|
||||
if (adresse) {
|
||||
const bild = document.createElement('img');
|
||||
bild.src = adresse;
|
||||
bild.alt = '';
|
||||
bild.loading = 'lazy';
|
||||
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
|
||||
bild.onload = () => rahmen.classList.add('geladen');
|
||||
rahmen.appendChild(bild);
|
||||
}
|
||||
|
||||
// **Der Haken sitzt in der Ecke, nicht in der Beschriftung.** So wie bei
|
||||
// Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine
|
||||
// einzige Zeile zu lesen.
|
||||
if (k.stand?.watched) {
|
||||
const haken = document.createElement('span');
|
||||
haken.className = 'ecke haken';
|
||||
haken.innerHTML = icons.haken;
|
||||
haken.title = 'Gesehen';
|
||||
rahmen.appendChild(haken);
|
||||
} else if (k.offen != null && k.offen > 0) {
|
||||
const plakette = document.createElement('span');
|
||||
plakette.className = 'ecke zahl';
|
||||
plakette.textContent = String(k.offen);
|
||||
plakette.title = `${k.offen} Folgen offen`;
|
||||
rahmen.appendChild(plakette);
|
||||
}
|
||||
|
||||
// Der Balken unten — ab der ersten Sekunde, mit Mindestbreite, sonst wären
|
||||
// zwei Minuten eines Zweistünders anderthalb Bildpunkte.
|
||||
const wie = anteil(k.stand);
|
||||
if (wie != null && wie > 0 && !k.stand?.watched) {
|
||||
const balken = document.createElement('div');
|
||||
balken.className = 'fortschritt';
|
||||
const gefuellt = document.createElement('div');
|
||||
gefuellt.style.width = `${Math.min(100, wie * 100)}%`;
|
||||
balken.appendChild(gefuellt);
|
||||
rahmen.appendChild(balken);
|
||||
}
|
||||
|
||||
// Beim Überfahren ein Abspielknopf in der Mitte — auch das von Plex.
|
||||
const schweben = document.createElement('span');
|
||||
schweben.className = 'schweber';
|
||||
schweben.innerHTML = icons.abspielen;
|
||||
rahmen.appendChild(schweben);
|
||||
|
||||
knopf.appendChild(rahmen);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'kacheltext';
|
||||
text.innerHTML =
|
||||
`<strong>${esc(k.titel)}</strong>`
|
||||
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '')
|
||||
+ (k.dritte ? `<span class="dritte">${esc(k.dritte)}</span>` : '');
|
||||
knopf.appendChild(text);
|
||||
|
||||
return knopf;
|
||||
}
|
||||
|
||||
/** Eine waagerechte Reihe mit Überschrift und Pfeilen. */
|
||||
export function reihe(
|
||||
titel: string,
|
||||
kacheln: HTMLElement[],
|
||||
mehr?: { text: string; tun: () => void },
|
||||
): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.className = 'reihe';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'reihe-kopf';
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = titel;
|
||||
kopf.appendChild(h);
|
||||
|
||||
const streifen = document.createElement('div');
|
||||
streifen.className = 'strip';
|
||||
kacheln.forEach(k => streifen.appendChild(k));
|
||||
|
||||
// **Pfeile, weil eine Reihe mit zwanzig Kacheln nicht in den Bildschirm
|
||||
// passt.** Scrollen mit dem Rad geht quer nur mit Umschalttaste, und ein
|
||||
// Trackpad hat nicht jeder.
|
||||
const steuerung = document.createElement('div');
|
||||
steuerung.className = 'reihe-pfeile';
|
||||
(['links', 'rechts'] as const).forEach(richtung => {
|
||||
const p = document.createElement('button');
|
||||
p.className = 'pfeil';
|
||||
p.innerHTML = icons[richtung];
|
||||
p.setAttribute('aria-label', richtung === 'links' ? 'Zurück' : 'Weiter');
|
||||
p.onclick = () => streifen.scrollBy({
|
||||
left: (richtung === 'links' ? -1 : 1) * Math.max(240, streifen.clientWidth * 0.8),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
steuerung.appendChild(p);
|
||||
});
|
||||
|
||||
if (mehr) {
|
||||
const m = document.createElement('button');
|
||||
m.className = 'mehr';
|
||||
m.textContent = mehr.text;
|
||||
m.onclick = mehr.tun;
|
||||
kopf.appendChild(m);
|
||||
}
|
||||
kopf.appendChild(steuerung);
|
||||
|
||||
bereich.append(kopf, streifen);
|
||||
return bereich;
|
||||
}
|
||||
+44
-47
@@ -1,51 +1,48 @@
|
||||
/* Die Symbole der Oberfläche.
|
||||
----------------------------------------------------------------------------
|
||||
Als Vektor, nicht als Schriftzeichen. Ein Wiedergabedreieck oder ein
|
||||
Lautsprecher-Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde
|
||||
Farben, sitzt schief auf der Grundlinie und lässt sich weder in der
|
||||
Strichstärke noch in der Farbe anpassen — es sieht nach Platzhalter aus,
|
||||
weil es einer ist.
|
||||
/* Icons als SVG, nicht als Zeichen aus einer Schriftart.
|
||||
*
|
||||
* **Keine Emojis.** Ein ▶ oder ✓ aus Unicode sieht auf jedem System anders
|
||||
* aus, lässt sich nicht einfärben, nicht in der Strichstärke anpassen und
|
||||
* springt in der Grundlinie. Diese hier sind auf 24×24 gezeichnet, erben die
|
||||
* Textfarbe und skalieren mit der Schriftgrösse.
|
||||
*
|
||||
* Der Strichstil ist durchgehend derselbe: 1.8 Einheiten, runde Enden. */
|
||||
|
||||
`currentColor` erbt Farbe und Zustand vom Knopf darum. */
|
||||
const rahmen = (inhalt: string, gefuellt = false): string =>
|
||||
`<svg viewBox="0 0 24 24" aria-hidden="true" fill="${gefuellt ? 'currentColor' : 'none'}"`
|
||||
+ ` stroke="${gefuellt ? 'none' : 'currentColor'}" stroke-width="1.8"`
|
||||
+ ` stroke-linecap="round" stroke-linejoin="round">${inhalt}</svg>`;
|
||||
|
||||
type Zeichner = string | ((n: number) => string);
|
||||
export const icons = {
|
||||
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
|
||||
/** Filmstreifen — für eine Filmmediathek. */
|
||||
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
||||
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
||||
/** Bildschirm — für eine Serienmediathek. */
|
||||
serien: rahmen('<rect x="2.5" y="4.5" width="19" height="12" rx="2"/><path d="M8 20h8"/>'),
|
||||
verwaltung: rahmen('<circle cx="12" cy="12" r="3.2"/>'
|
||||
+ '<path d="M19.4 14.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
|
||||
suche: rahmen('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
|
||||
abspielen: rahmen('<path d="M8 5.5v13l11-6.5z"/>', true),
|
||||
haken: rahmen('<path d="m5 12.5 4.5 4.5L19 7"/>'),
|
||||
hakenKreis: rahmen('<circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.7L16 9.6"/>'),
|
||||
stift: rahmen('<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>'),
|
||||
download: rahmen('<path d="M12 3v12"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M4 20h16"/>'),
|
||||
links: rahmen('<path d="M15 5 8 12l7 7"/>'),
|
||||
rechts: rahmen('<path d="m9 5 7 7-7 7"/>'),
|
||||
mehr: rahmen('<circle cx="12" cy="5.5" r="1.3" fill="currentColor" stroke="none"/>'
|
||||
+ '<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>'
|
||||
+ '<circle cx="12" cy="18.5" r="1.3" fill="currentColor" stroke="none"/>'),
|
||||
abmelden: rahmen('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/>'
|
||||
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
|
||||
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
|
||||
} as const;
|
||||
|
||||
const ICONS: Record<string, Zeichner> = {
|
||||
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
|
||||
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
|
||||
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
|
||||
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
|
||||
// `gobackward.10` und `goforward.30` auf dem iPad.
|
||||
goBack: (n) => `<path d="M11.5 4.5L7 8l4.5 3.5" /><path d="M7 8h4.5a7.5 7.5 0 11-7.2 9.6"/>`
|
||||
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
|
||||
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
|
||||
goFwd: (n) => `<path d="M12.5 4.5L17 8l-4.5 3.5"/><path d="M17 8h-4.5a7.5 7.5 0 107.2 9.6"/>`
|
||||
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
|
||||
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
|
||||
captions: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3"/>',
|
||||
captionsOn: '<rect x="3" y="5.5" width="18" height="13" rx="3" fill="currentColor" stroke="none"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3" stroke="#0B0B0D"/>',
|
||||
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
||||
// Rechteck mit Balken oben bzw. Spalte links — die Leiste zieht dorthin.
|
||||
barTop: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M2 9h20"/>'
|
||||
+ '<rect x="2" y="3.5" width="20" height="5.5" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
|
||||
barSide: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M8.5 3.5v17"/>'
|
||||
+ '<rect x="2" y="3.5" width="6.5" height="17" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
|
||||
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
|
||||
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
|
||||
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
|
||||
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
|
||||
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
||||
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
|
||||
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
|
||||
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
|
||||
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
|
||||
}
|
||||
|
||||
/** Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht. */
|
||||
export function icon(name: keyof typeof ICONS, size?: number, arg?: number): string {
|
||||
const eintrag = ICONS[name];
|
||||
const inhalt = typeof eintrag === 'function' ? eintrag(arg ?? 0) : eintrag;
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
||||
${size ? `style="width:${size}px;height:${size}px"` : ''}>${inhalt}</svg>`;
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
/** Ein Icon als Element — für alles, was nicht aus einer Zeichenkette gebaut wird. */
|
||||
export function icon(name: IconName, klasse = 'icon'): HTMLElement {
|
||||
const h = document.createElement('span');
|
||||
h.className = klasse;
|
||||
h.innerHTML = icons[name];
|
||||
return h;
|
||||
}
|
||||
|
||||
+70
-29
@@ -3,22 +3,25 @@ import {
|
||||
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
|
||||
type Hinweis, type Bibliothek,
|
||||
} from './api';
|
||||
import { icons, icon } from './icons';
|
||||
import { einrichtung } from './views/setup';
|
||||
import { anmeldung } from './views/login';
|
||||
import { startseite } from './views/home';
|
||||
import { mediathek } from './views/library';
|
||||
import { detailseite } from './views/detail';
|
||||
import { verwaltung } from './views/admin';
|
||||
import { suchseite } from './views/search';
|
||||
import { esc } from './util';
|
||||
|
||||
const anmeldeflaeche = document.getElementById('login')!;
|
||||
const kopfleiste = document.getElementById('topbar')!;
|
||||
const app = document.getElementById('app')!;
|
||||
const leiste = document.getElementById('chrome')!;
|
||||
const inhalt = document.getElementById('content')!;
|
||||
|
||||
let hinweise: Hinweis[] = [];
|
||||
/** Die Mediatheken, einmal geholt — die Seitenleiste steht auf ihnen. */
|
||||
let mediatheken: Bibliothek[] = [];
|
||||
let serverName = 'Flaro';
|
||||
|
||||
/* ---------- Start ---------- */
|
||||
|
||||
@@ -28,18 +31,21 @@ async function hochfahren(): Promise<void> {
|
||||
try {
|
||||
const auskunft = await systemauskunft();
|
||||
hinweise = auskunft.attributions;
|
||||
serverName = auskunft.serverName ?? 'Flaro';
|
||||
|
||||
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
|
||||
if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; }
|
||||
|
||||
anmeldeflaeche.classList.add('hidden');
|
||||
kopfleiste.classList.remove('hidden');
|
||||
app.classList.remove('hidden');
|
||||
|
||||
// **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek
|
||||
// einzeln auf; ohne die Liste gäbe es nichts zu zeichnen und der Router
|
||||
// könnte `#/m/{id}` nicht auflösen.
|
||||
// einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht
|
||||
// auflösen.
|
||||
mediatheken = await bibliotheken();
|
||||
|
||||
kopfleisteBauen();
|
||||
leisteBauen();
|
||||
window.addEventListener('hashchange', zeichnen);
|
||||
zeichnen();
|
||||
@@ -48,9 +54,10 @@ async function hochfahren(): Promise<void> {
|
||||
// Lüge — sie würde bei jedem Versuch scheitern.
|
||||
anmeldeflaeche.innerHTML =
|
||||
`<div class="login-card"><h1>Flaro</h1>`
|
||||
+ `<div class="error">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
|
||||
+ `<div class="hint">Läuft er? Stimmt die Adresse?</div></div>`;
|
||||
+ `<div class="fehler">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
|
||||
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
|
||||
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<void> {
|
||||
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 = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
|
||||
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 = `<span>${esc(text)}</span>`
|
||||
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
|
||||
+ `<span class="name">${esc(text)}</span>`
|
||||
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
|
||||
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 {
|
||||
|
||||
+363
-182
@@ -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
|
||||
`<svg>` 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; }
|
||||
}
|
||||
|
||||
+4
-40
@@ -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;
|
||||
}
|
||||
|
||||
+19
-19
@@ -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 = `<strong>${esc(b.name)}</strong>`
|
||||
+ `<span class="tiny">${esc(b.path)}${esc(anzahl)}</span>`;
|
||||
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
|
||||
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 = `<div class="rowline"><strong>${esc(b.userName)}</strong>`
|
||||
+ `<span class="tiny">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
|
||||
d.className = 'einstellung';
|
||||
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
|
||||
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
|
||||
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 () => {
|
||||
|
||||
+370
-164
@@ -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<void> {
|
||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||
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
|
||||
? `<span class="tiny">${eintrag.unwatchedCount} von ${eintrag.episodeCount} Folgen offen</span>`
|
||||
: '';
|
||||
text.innerHTML =
|
||||
`<h1>${esc(eintrag.title)}${esc(jahr)}</h1>` +
|
||||
(stand?.watched ? '<span class="tiny">Gesehen</span>' : offen) +
|
||||
`<p>${esc(eintrag.overview ?? 'Keine Beschreibung.')}</p>`;
|
||||
|
||||
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 ---------- */
|
||||
|
||||
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.className = 'settings';
|
||||
|
||||
if (dateien.length === 0) { bereich.appendChild(leer('Keine Datei hinterlegt.')); return bereich; }
|
||||
|
||||
dateien.forEach(datei => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'setting';
|
||||
|
||||
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
|
||||
function eckdaten(eintrag: Film | Serie): HTMLElement {
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'rowline';
|
||||
zeile.innerHTML =
|
||||
`<strong>${esc(datei.fileName)}</strong>` +
|
||||
`<span class="tiny">${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes),
|
||||
dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}</span>`;
|
||||
block.appendChild(zeile);
|
||||
zeile.className = 'eckdaten';
|
||||
|
||||
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 `<div>${esc(was)} — ${esc(s.codec ?? '?')}${name ? ` — ${esc(name)}` : ''}</div>`;
|
||||
}).join('');
|
||||
block.appendChild(spuren);
|
||||
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);
|
||||
}
|
||||
|
||||
block.appendChild(aktionen(datei, titel, stand));
|
||||
bereich.appendChild(block);
|
||||
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<void>,
|
||||
): 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');
|
||||
if (!mitwirkende?.length) return bereich;
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Besetzung & Stab';
|
||||
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'besetzung';
|
||||
|
||||
mitwirkende.forEach(m => {
|
||||
const k = document.createElement('div');
|
||||
k.className = 'kopf-kachel';
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 = `<div class="zeile"><strong>${esc(datei.fileName)}</strong>`
|
||||
+ `<span class="winzig">${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes),
|
||||
dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}</span></div>`;
|
||||
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 =
|
||||
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
|
||||
+ (nebenbei ? `<span class="tiny">${esc(nebenbei)}</span>` : '');
|
||||
+ (nebenbei ? `<span class="winzig">${esc(nebenbei)}</span>` : '');
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 =
|
||||
`<strong>${esc(v.title)}</strong> <span class="tiny">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span>` +
|
||||
`<div class="hint">${esc((v.overview ?? '').slice(0, 160))}</div>`;
|
||||
`<div class="zeile"><strong>${esc(v.title)}</strong>`
|
||||
+ `<span class="winzig">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span></div>`
|
||||
+ `<div class="hinweis">${esc((v.overview ?? '').slice(0, 160))}</div>`;
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
+37
-111
@@ -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 = `<strong>${esc(eintrag.title)}</strong>`
|
||||
+ `<span class="tiny">${esc(zweite)}</span>`;
|
||||
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 = `<strong>${esc(eintrag.title)}</strong>`
|
||||
+ `<span class="tiny">${esc(eintrag.year ?? '')}</span>`;
|
||||
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;
|
||||
}
|
||||
|
||||
+196
-82
@@ -1,50 +1,101 @@
|
||||
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';
|
||||
const welcher: Reiter =
|
||||
reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek';
|
||||
|
||||
wurzel.appendChild(kopf());
|
||||
const buehne = document.createElement('div');
|
||||
wurzel.appendChild(buehne);
|
||||
|
||||
if (welcher === 'empfehlungen') empfehlungen(buehne);
|
||||
else if (welcher === 'kategorien') kategorien(buehne);
|
||||
else raster(buehne);
|
||||
|
||||
/* ---------- Kopf mit Reitern ---------- */
|
||||
|
||||
function kopf(): HTMLElement {
|
||||
const k = document.createElement('div');
|
||||
k.className = 'seitenkopf';
|
||||
|
||||
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 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);
|
||||
});
|
||||
|
||||
k.append(wer, reiterleiste);
|
||||
return k;
|
||||
}
|
||||
|
||||
/* ---------- Reiter „Mediathek" ---------- */
|
||||
|
||||
function raster(ziel: HTMLElement): void {
|
||||
let suche = '';
|
||||
let ordnung: Ordnung = 'title';
|
||||
let genre = '';
|
||||
let tippt: number | undefined;
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'row';
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'filterzeile';
|
||||
|
||||
const titel = document.createElement('h1');
|
||||
titel.className = 'seitentitel';
|
||||
titel.textContent = bibliothek.name;
|
||||
const anzahl = document.createElement('span');
|
||||
anzahl.className = 'anzahl';
|
||||
anzahl.textContent = '…';
|
||||
|
||||
const genreWahl = document.createElement('select');
|
||||
genreWahl.innerHTML = '<option value="">Alle Genres</option>';
|
||||
genreWahl.onchange = () => { genre = genreWahl.value; void laden(); };
|
||||
|
||||
const sortierung = document.createElement('select');
|
||||
sortierung.className = 'field schmal';
|
||||
([
|
||||
['title', 'A–Z'],
|
||||
['title', 'Nach Titel'],
|
||||
['added', 'Zuletzt hinzugefügt'],
|
||||
['year', 'Jahr'],
|
||||
['year', 'Nach Jahr'],
|
||||
] as [Ordnung, string][]).forEach(([wert, text]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = wert;
|
||||
o.textContent = text;
|
||||
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 = 'field';
|
||||
suchfeld.className = 'suche';
|
||||
suchfeld.type = 'search';
|
||||
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
|
||||
suchfeld.oninput = () => {
|
||||
@@ -54,71 +105,134 @@ export function mediathek(
|
||||
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
|
||||
};
|
||||
|
||||
kopf.append(titel, sortierung, suchfeld);
|
||||
zeile.append(genreWahl, sortierung, anzahl, suchfeld);
|
||||
|
||||
const raster = document.createElement('div');
|
||||
raster.className = 'grid';
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'raster';
|
||||
ziel.append(zeile, gitter);
|
||||
|
||||
wurzel.append(kopf, raster);
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
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 = `<strong>${esc(eintrag.title)}</strong>`
|
||||
+ `<span class="tiny">${esc(teile.join(' · '))}</span>`;
|
||||
k.appendChild(unten);
|
||||
|
||||
k.onclick = () => oeffnen(art, eintrag.id);
|
||||
return k;
|
||||
}
|
||||
// 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<void> {
|
||||
gitter.innerHTML = '<div class="spinner"></div>';
|
||||
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 = '<div class="spinner"></div>';
|
||||
|
||||
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 = '<div class="spinner"></div>';
|
||||
|
||||
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<HTMLSelectElement>('.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%))`;
|
||||
}
|
||||
|
||||
@@ -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 = `<div class="wer"><h1>${esc(text)}</h1>`
|
||||
+ '<div class="quelle">Suchergebnisse</div></div>';
|
||||
wurzel.appendChild(kopf);
|
||||
|
||||
const buehne = document.createElement('div');
|
||||
buehne.innerHTML = '<div class="spinner"></div>';
|
||||
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<typeof alsKachel>[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;
|
||||
}
|
||||
@@ -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.';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user