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
+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 () => {
|
||||
|
||||
+366
-160
@@ -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 ---------- */
|
||||
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
|
||||
function eckdaten(eintrag: Film | Serie): HTMLElement {
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'eckdaten';
|
||||
|
||||
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
|
||||
const teile: string[] = [];
|
||||
if (eintrag.year) teile.push(String(eintrag.year));
|
||||
if (eintrag.genres?.length) teile.push(eintrag.genres.join(', '));
|
||||
if (eintrag.runtimeMinutes) teile.push(`${eintrag.runtimeMinutes} Min`);
|
||||
if ('episodeCount' in eintrag) {
|
||||
teile.push(`${eintrag.episodeCount} Folgen`);
|
||||
if (eintrag.unwatchedCount > 0) teile.push(`${eintrag.unwatchedCount} offen`);
|
||||
}
|
||||
if (teile.length) {
|
||||
const t = document.createElement('span');
|
||||
t.textContent = teile.join(' · ');
|
||||
zeile.appendChild(t);
|
||||
}
|
||||
|
||||
if (eintrag.contentRating) {
|
||||
const f = document.createElement('span');
|
||||
f.className = 'freigabe';
|
||||
f.textContent = eintrag.contentRating;
|
||||
f.title = 'Altersfreigabe';
|
||||
zeile.appendChild(f);
|
||||
}
|
||||
|
||||
if (eintrag.rating) {
|
||||
const b = document.createElement('span');
|
||||
b.className = 'bewertung';
|
||||
b.innerHTML = icons.stern;
|
||||
b.append(document.createTextNode(eintrag.rating.toFixed(1)));
|
||||
b.title = 'Bewertung bei TMDB';
|
||||
zeile.appendChild(b);
|
||||
}
|
||||
|
||||
return zeile;
|
||||
}
|
||||
|
||||
/* ---------- Knöpfe ---------- */
|
||||
|
||||
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'aktionen';
|
||||
const meldung = document.createElement('div');
|
||||
meldung.className = 'hinweis';
|
||||
|
||||
if (datei) {
|
||||
const stelle = stand?.positionSeconds ?? 0;
|
||||
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
||||
los.prepend(icon('abspielen'));
|
||||
los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung);
|
||||
reihe.appendChild(los);
|
||||
|
||||
if (stelle > 0) {
|
||||
const vonVorn = knopf('Von vorn', 'btn');
|
||||
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung);
|
||||
reihe.appendChild(vonVorn);
|
||||
}
|
||||
|
||||
const laden = knopf('Herunterladen', 'btn');
|
||||
laden.prepend(icon('download'));
|
||||
laden.onclick = () => void herunterladen(datei, laden, meldung);
|
||||
reihe.appendChild(laden);
|
||||
}
|
||||
|
||||
// **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit,
|
||||
// weglegen — drei Dinge, die man oft tut und nie liest.
|
||||
if ('files' in eintrag) {
|
||||
reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false,
|
||||
stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren',
|
||||
async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); }));
|
||||
|
||||
reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false,
|
||||
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
|
||||
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
|
||||
|
||||
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
|
||||
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
|
||||
async () => { await weglegen(eintrag.id); await laden(); }));
|
||||
}
|
||||
}
|
||||
|
||||
const huelle = document.createElement('div');
|
||||
huelle.append(reihe, meldung);
|
||||
return huelle;
|
||||
}
|
||||
|
||||
function rundKnopf(
|
||||
bild: keyof typeof icons, an: boolean, titel: string, tun: () => Promise<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');
|
||||
bereich.className = 'settings';
|
||||
if (!mitwirkende?.length) return bereich;
|
||||
|
||||
if (dateien.length === 0) { bereich.appendChild(leer('Keine Datei hinterlegt.')); return bereich; }
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Besetzung & Stab';
|
||||
|
||||
dateien.forEach(datei => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'setting';
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'besetzung';
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'rowline';
|
||||
zeile.innerHTML =
|
||||
`<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);
|
||||
mitwirkende.forEach(m => {
|
||||
const k = document.createElement('div');
|
||||
k.className = 'kopf-kachel';
|
||||
|
||||
const spuren = document.createElement('div');
|
||||
spuren.className = 'hint';
|
||||
spuren.innerHTML = datei.streams.map(s => {
|
||||
const was = s.kind === 'Video' ? `Bild ${s.width}×${s.height}`
|
||||
: s.kind === 'Audio' ? `Ton${s.channels ? ` ${s.channels}.0` : ''}`
|
||||
: 'Text';
|
||||
const name = [s.language?.toUpperCase(), s.title].filter(Boolean).join(' · ');
|
||||
return `<div>${esc(was)} — ${esc(s.codec ?? '?')}${name ? ` — ${esc(name)}` : ''}</div>`;
|
||||
}).join('');
|
||||
block.appendChild(spuren);
|
||||
const portrait = document.createElement('div');
|
||||
portrait.className = 'portrait';
|
||||
const adresse = bildAdresse(m.image);
|
||||
if (adresse) {
|
||||
const bild = document.createElement('img');
|
||||
bild.src = adresse;
|
||||
bild.alt = '';
|
||||
bild.loading = 'lazy';
|
||||
portrait.appendChild(bild);
|
||||
} else {
|
||||
// Kein Bild: die Anfangsbuchstaben. Ein leerer Kreis sieht aus wie
|
||||
// ein Ladefehler.
|
||||
portrait.textContent = m.name.split(' ').map(t => t[0]).slice(0, 2).join('');
|
||||
}
|
||||
|
||||
block.appendChild(aktionen(datei, titel, stand));
|
||||
bereich.appendChild(block);
|
||||
const rolle = m.role === 'Director' ? 'Regie'
|
||||
: m.role === 'Writer' ? 'Drehbuch'
|
||||
: m.character ?? '';
|
||||
|
||||
k.innerHTML += '';
|
||||
k.append(portrait);
|
||||
const name = document.createElement('strong');
|
||||
name.textContent = m.name;
|
||||
const unten = document.createElement('span');
|
||||
unten.textContent = rolle;
|
||||
k.append(name, unten);
|
||||
reihe.appendChild(k);
|
||||
});
|
||||
|
||||
bereich.append(h, reihe);
|
||||
return bereich;
|
||||
}
|
||||
|
||||
function aktionen(datei: Datei, titel: string, stand: Stand | null): HTMLElement {
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'btn-row';
|
||||
const zustandszeile = document.createElement('div');
|
||||
zustandszeile.className = 'hint';
|
||||
/* ---------- Dateien ---------- */
|
||||
|
||||
// **Gibt es eine Stelle, stehen beide Knöpfe da** — und zwar auch bei
|
||||
// einem gesehenen Film. Der Server wirft die Stelle nicht weg, wenn das
|
||||
// Häkchen kommt; sie hier trotzdem zu verschweigen hiesse, den
|
||||
// Unterschied wegzuwerfen, um dessentwillen er sie behält.
|
||||
//
|
||||
// Ein „Abspielen", das stillschweigend in der Mitte anfängt, wäre die
|
||||
// andere falsche Lösung: eine Überraschung, keine Hilfe.
|
||||
const stelle = stand?.positionSeconds ?? 0;
|
||||
const abspielen = knopf(
|
||||
stelle > 0 ? `Weiterschauen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
||||
const vonVorn = stelle > 0 ? knopf('Von vorn', 'btn') : null;
|
||||
const herunterladen = knopf('Herunterladen', 'btn');
|
||||
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
if (dateien.length <= 1) return bereich;
|
||||
|
||||
const haken = knopf(stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', 'btn');
|
||||
haken.onclick = async () => {
|
||||
haken.disabled = true;
|
||||
try { await gesehen(titel, !stand?.watched); await laden(); }
|
||||
catch (fehler) { zustandszeile.textContent = (fehler as Error).message; haken.disabled = false; }
|
||||
};
|
||||
// Nur bei mehreren Fassungen: bei einer stehen die Angaben schon oben.
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Fassungen';
|
||||
bereich.appendChild(h);
|
||||
|
||||
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
|
||||
async function bereitstellen(): Promise<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;
|
||||
}
|
||||
|
||||
+215
-101
@@ -1,124 +1,238 @@
|
||||
import { inhalt, type Bibliothek, type Film, type Serie, type Ordnung } from '../api';
|
||||
import { esc, plakat, leer, fehlerzeile, anteil } from '../util';
|
||||
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
|
||||
import { leer, fehlerzeile } from '../util';
|
||||
import { kachel, alsKachel, reihe } from '../card';
|
||||
|
||||
type Art = 'filme' | 'serien';
|
||||
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
|
||||
|
||||
/** Eine Mediathek als Raster.
|
||||
/** Eine Mediathek — mit Reitern, wie bei Plex.
|
||||
*
|
||||
* **Eine Ansicht je Mediathek, nicht eine je Art.** Welche gezeigt wird,
|
||||
* entscheidet die Seitenleiste — wie bei Plex, wo jede Mediathek ihr eigener
|
||||
* Punkt in der Navigation ist. Wer „Filme" und „Dokumentationen" getrennt
|
||||
* angelegt hat, hat sie getrennt gemeint.
|
||||
*
|
||||
* Gesucht und sortiert wird am Server: bei zehntausend Titeln wäre alles zu
|
||||
* laden und dann zu sieben der falsche Weg. */
|
||||
* **Drei Sichten auf denselben Bestand.** „Empfehlungen" ist das, womit man
|
||||
* ankommt; „Mediathek" ist das vollständige Raster für alle, die etwas
|
||||
* Bestimmtes suchen; „Kategorien" ist der Weg hinein, wenn man noch nicht
|
||||
* weiss, wonach. Alles in eine Ansicht zu legen hiesse, zwei davon unter ein
|
||||
* Ausklappmenü zu schieben. */
|
||||
export function mediathek(
|
||||
bibliothek: Bibliothek,
|
||||
servername: string,
|
||||
reiter: string,
|
||||
oeffnen: (art: Art, id: string) => void,
|
||||
): HTMLElement {
|
||||
const wurzel = document.createElement('div');
|
||||
const art: Art = bibliothek.kind === 'Shows' ? 'serien' : 'filme';
|
||||
let suche = '';
|
||||
let ordnung: Ordnung = 'title';
|
||||
let tippt: number | undefined;
|
||||
const welcher: Reiter =
|
||||
reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'row';
|
||||
wurzel.appendChild(kopf());
|
||||
const buehne = document.createElement('div');
|
||||
wurzel.appendChild(buehne);
|
||||
|
||||
const titel = document.createElement('h1');
|
||||
titel.className = 'seitentitel';
|
||||
titel.textContent = bibliothek.name;
|
||||
if (welcher === 'empfehlungen') empfehlungen(buehne);
|
||||
else if (welcher === 'kategorien') kategorien(buehne);
|
||||
else raster(buehne);
|
||||
|
||||
const sortierung = document.createElement('select');
|
||||
sortierung.className = 'field schmal';
|
||||
([
|
||||
['title', 'A–Z'],
|
||||
['added', 'Zuletzt hinzugefügt'],
|
||||
['year', 'Jahr'],
|
||||
] as [Ordnung, string][]).forEach(([wert, text]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = wert;
|
||||
o.textContent = text;
|
||||
sortierung.appendChild(o);
|
||||
});
|
||||
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
|
||||
/* ---------- Kopf mit Reitern ---------- */
|
||||
|
||||
const suchfeld = document.createElement('input');
|
||||
suchfeld.className = 'field';
|
||||
suchfeld.type = 'search';
|
||||
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
|
||||
suchfeld.oninput = () => {
|
||||
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
|
||||
// Anfragen, von denen acht niemanden interessieren.
|
||||
window.clearTimeout(tippt);
|
||||
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
|
||||
};
|
||||
function kopf(): HTMLElement {
|
||||
const k = document.createElement('div');
|
||||
k.className = 'seitenkopf';
|
||||
|
||||
kopf.append(titel, sortierung, suchfeld);
|
||||
const wer = document.createElement('div');
|
||||
wer.className = 'wer';
|
||||
const h = document.createElement('h1');
|
||||
h.textContent = bibliothek.name;
|
||||
const quelle = document.createElement('div');
|
||||
quelle.className = 'quelle';
|
||||
quelle.textContent = servername;
|
||||
wer.append(h, quelle);
|
||||
|
||||
const raster = document.createElement('div');
|
||||
raster.className = 'grid';
|
||||
const reiterleiste = document.createElement('nav');
|
||||
reiterleiste.className = 'reiter';
|
||||
([
|
||||
['empfehlungen', 'Empfehlungen'],
|
||||
['mediathek', 'Mediathek'],
|
||||
['kategorien', 'Kategorien'],
|
||||
] as [Reiter, string][]).forEach(([wert, text]) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = text;
|
||||
b.classList.toggle('check', wert === welcher);
|
||||
b.onclick = () => { location.hash = `#/m/${bibliothek.id}/${wert}`; };
|
||||
reiterleiste.appendChild(b);
|
||||
});
|
||||
|
||||
wurzel.append(kopf, raster);
|
||||
|
||||
async function laden(): Promise<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);
|
||||
k.append(wer, reiterleiste);
|
||||
return k;
|
||||
}
|
||||
|
||||
void laden();
|
||||
/* ---------- Reiter „Mediathek" ---------- */
|
||||
|
||||
function raster(ziel: HTMLElement): void {
|
||||
let suche = '';
|
||||
let ordnung: Ordnung = 'title';
|
||||
let genre = '';
|
||||
let tippt: number | undefined;
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'filterzeile';
|
||||
|
||||
const anzahl = document.createElement('span');
|
||||
anzahl.className = 'anzahl';
|
||||
anzahl.textContent = '…';
|
||||
|
||||
const genreWahl = document.createElement('select');
|
||||
genreWahl.innerHTML = '<option value="">Alle Genres</option>';
|
||||
genreWahl.onchange = () => { genre = genreWahl.value; void laden(); };
|
||||
|
||||
const sortierung = document.createElement('select');
|
||||
([
|
||||
['title', 'Nach Titel'],
|
||||
['added', 'Zuletzt hinzugefügt'],
|
||||
['year', 'Nach Jahr'],
|
||||
] as [Ordnung, string][]).forEach(([wert, text]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = wert; o.textContent = text;
|
||||
sortierung.appendChild(o);
|
||||
});
|
||||
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
|
||||
|
||||
const suchfeld = document.createElement('input');
|
||||
suchfeld.className = 'suche';
|
||||
suchfeld.type = 'search';
|
||||
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
|
||||
suchfeld.oninput = () => {
|
||||
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
|
||||
// Anfragen, von denen acht niemanden interessieren.
|
||||
window.clearTimeout(tippt);
|
||||
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
|
||||
};
|
||||
|
||||
zeile.append(genreWahl, sortierung, anzahl, suchfeld);
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'raster';
|
||||
ziel.append(zeile, gitter);
|
||||
|
||||
// Die Genres der Mediathek nachladen — die Liste steht erst, wenn der
|
||||
// Server sie genannt hat, und solange bleibt „Alle Genres" allein.
|
||||
void genresVon(bibliothek.id).then(namen => {
|
||||
namen.forEach(n => {
|
||||
const o = document.createElement('option');
|
||||
o.value = n; o.textContent = n;
|
||||
genreWahl.appendChild(o);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<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