Einstellungen hat jeder, Verwaltung nicht

Die Seite hiess „Verwaltung" und war für alle anderen gar nicht
erreichbar — „Meine Sprache" lag dadurch hinter einer Tür, die den meisten
verschlossen ist. Jetzt heisst sie **Einstellungen**, steht jedem offen und
hat zwei beschriftete Gruppen: oben, was einem selbst gehört (Mein Konto,
Geschaut), darunter — nur für Administratoren — **Verwaltung**. Die
Überschrift ist hier die halbe Aussage: sie sagt ohne weiteren Satz, wem
der untere Teil gehört.

In der Hauptleiste steht der Eintrag jetzt unten, ohne Gruppenüberschrift.
Eine Überschrift über einem einzigen Eintrag ist eine Überschrift über
nichts, und am Fuss sucht man Einstellungen ohnehin. `#/profil` und
`#/verwaltung/…` leiten weiter; ein Lesezeichen merkt davon nichts.

**Ein kurzes Menü am Bild.** Erst meldete der Klick ab — ohne Rückfrage.
Dann führte er auf die eigene Seite, und zum Abmelden musste man erst
dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist ehrlicher
als eine Tür, hinter der eine zweite liegt.

**Und das Profilbild lässt sich zuschneiden.** Ein Profilbild ist rund,
was hochgeladen wird selten quadratisch — den Ausschnitt dem Browser zu
überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals. Der
Dialog zeigt den runden Ausschnitt, man zieht und vergrössert, und
hochgeladen wird genau das: ein Quadrat von 512 Pixeln. Gemessen, es kommt
auch so an.

**Dazu die Spielerleiste.** Beide Knopfgruppen durften schrumpfen, ihre
Knöpfe aber nicht — wird es eng, steht der äusserste halb hinter dem
Fensterrand. Rechts sind es nur Knöpfe, da gibt es nichts nachzugeben;
links schon. Dazu seitlich `env(safe-area-inset-*)`, damit auf einem Gerät
mit runden Ecken nichts darunter liegt. Gemessen: vorher riss es ab 420
Pixeln, jetzt bis 320 nicht mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 18:24:44 +02:00
co-authored by Claude Opus 5
parent 9c2e2b5f47
commit 327e2e9195
16 changed files with 582 additions and 280 deletions
+169
View File
@@ -0,0 +1,169 @@
import { knopf } from './util';
import { icons } from './icons';
import { beimSeitenwechselSchliessen } from './dialog';
/** Schneidet ein Bild auf ein Quadrat zu, bevor es hochgeladen wird.
*
* **Weil ein Profilbild rund ist.** Was hochgeladen wird, ist selten
* quadratisch — ein Foto ist hochkant, ein Ausschnitt quer. Der Browser
* beschnitt es mit `object-fit: cover` von selbst: mittig, und das trifft bei
* einem Porträt den Hals. Wer sein Bild aussucht, soll auch aussuchen, welcher
* Teil davon zu sehen ist.
*
* **Im Browser gerechnet, nicht auf dem Server.** Hochgeladen wird genau der
* Ausschnitt — ein Quadrat von 512 Pixeln. Das spart die Leitung, den Platz
* und dem Server eine Bildbibliothek, die er sonst nirgends braucht.
*/
export function zuschneiden(datei: File): Promise<Blob | null> {
return new Promise(fertig => {
const bild = new Image();
const adresse = URL.createObjectURL(datei);
bild.onerror = () => {
URL.revokeObjectURL(adresse);
fertig(null);
};
bild.onload = () => {
const d = document.createElement('dialog');
d.className = 'dlg zuschnitt-dlg';
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = 'Ausschnitt wählen';
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const buehne = document.createElement('div');
buehne.className = 'zuschnitt-buehne';
const leinwand = document.createElement('canvas');
// Die Vorschau ist so gross, wie sie dasteht; gerechnet wird am Ende
// auf 512 — zweimal dieselbe Zahl wäre eine zum Verwechseln.
const SEITE = 320;
leinwand.width = SEITE;
leinwand.height = SEITE;
leinwand.className = 'zuschnitt-feld';
buehne.appendChild(leinwand);
const regler = document.createElement('input');
regler.type = 'range';
regler.className = 'feld';
regler.min = '100';
regler.max = '300';
regler.value = '100';
regler.setAttribute('aria-label', 'Vergrössern');
const hinweis = document.createElement('p');
hinweis.className = 'hinweis';
hinweis.textContent = 'Ziehen verschiebt den Ausschnitt, der Regler vergrössert.';
const fuss = document.createElement('div');
fuss.className = 'dlg-fuss';
const abbrechen = knopf('Abbrechen', 'btn');
const nehmen = knopf('Übernehmen', 'btn btn-primary');
fuss.append(abbrechen, nehmen);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
koerper.append(buehne, regler, hinweis);
d.append(kopf, koerper, fuss);
document.body.appendChild(d);
d.showModal();
beimSeitenwechselSchliessen(d);
/* ---------- Was gezeigt wird ---------- */
// **Der Nullpunkt ist „ganz drin".** Die kleinere Seite füllt das
// Quadrat; alles darüber ist Vergrösserung. Damit ist der Ausschnitt
// am Anfang immer gültig, egal wie das Bild liegt.
const grund = Math.max(SEITE / bild.width, SEITE / bild.height);
let faktor = 1;
let x = 0;
let y = 0;
function begrenzen(): void {
const breite = bild.width * grund * faktor;
const hoehe = bild.height * grund * faktor;
// Keine Lücke am Rand: der Ausschnitt bleibt im Bild.
x = Math.min(0, Math.max(SEITE - breite, x));
y = Math.min(0, Math.max(SEITE - hoehe, y));
}
function zeichnen(): void {
const stift = leinwand.getContext('2d');
if (!stift) return;
begrenzen();
stift.clearRect(0, 0, SEITE, SEITE);
stift.drawImage(bild, x, y, bild.width * grund * faktor, bild.height * grund * faktor);
}
zeichnen();
regler.oninput = () => {
const neu = Number(regler.value) / 100;
// Um die Mitte vergrössern, nicht um die linke obere Ecke — sonst
// wandert das Motiv beim Ziehen am Regler aus dem Bild.
const mitte = SEITE / 2;
x = mitte - (mitte - x) * (neu / faktor);
y = mitte - (mitte - y) * (neu / faktor);
faktor = neu;
zeichnen();
};
/* ---------- Ziehen ---------- */
let zieht: { x: number; y: number } | null = null;
leinwand.addEventListener('pointerdown', e => {
zieht = { x: e.clientX - x, y: e.clientY - y };
leinwand.setPointerCapture(e.pointerId);
});
leinwand.addEventListener('pointermove', e => {
if (!zieht) return;
x = e.clientX - zieht.x;
y = e.clientY - zieht.y;
zeichnen();
});
const loslassen = (): void => { zieht = null; };
leinwand.addEventListener('pointerup', loslassen);
leinwand.addEventListener('pointercancel', loslassen);
/* ---------- Ergebnis ---------- */
let ergebnis: Blob | null = null;
nehmen.onclick = () => {
const ZIEL = 512;
const aus = document.createElement('canvas');
aus.width = ZIEL;
aus.height = ZIEL;
const stift = aus.getContext('2d');
if (!stift) { d.close(); return; }
const massstab = ZIEL / SEITE;
stift.drawImage(
bild,
x * massstab, y * massstab,
bild.width * grund * faktor * massstab,
bild.height * grund * faktor * massstab);
aus.toBlob(b => { ergebnis = b; d.close(); }, 'image/jpeg', 0.9);
};
abbrechen.onclick = () => d.close();
d.addEventListener('close', () => {
d.remove();
URL.revokeObjectURL(adresse);
fertig(ergebnis);
});
d.addEventListener('click', e => { if (e.target === d) d.close(); });
};
bild.src = adresse;
});
}
+53 -14
View File
@@ -1,6 +1,6 @@
import './style.css';
import {
systemauskunft, angemeldet, istAdmin, sitzung, bibliotheken,
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
reihenfolgeSetzen, bildAdresse, kontoAbgleichen,
type Hinweis, type Bibliothek,
} from './api';
@@ -11,10 +11,11 @@ import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
import { detailseite } from './views/detail';
import { verwaltung, alsBereich } from './views/verwaltung';
import { einstellungenseite, alsBereich } from './views/einstellungen';
import { suchseite } from './views/search';
import { profilseite } from './views/profil';
import { esc, meldung } from './util';
import { menueAnZiel } from './menue';
import { nachfragen } from './dialog';
const anmeldeflaeche = document.getElementById('login')!;
const kopfleiste = document.getElementById('topbar')!;
@@ -171,9 +172,32 @@ function kopfleisteBauen(): void {
} else {
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
}
konto.title = `${name} — mein Profil`;
konto.setAttribute('aria-label', `${name} — mein Profil`);
konto.onclick = () => { location.hash = '#/profil'; };
konto.title = name;
konto.setAttribute('aria-label', `${name} — Konto`);
// **Ein kurzes Menü, kein Sprung.** Erst meldete der Klick ab — ohne
// Rückfrage. Dann führte er auf die eigene Seite, und zum Abmelden musste
// man erst dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist
// die ehrlichere Form als eine Tür, hinter der eine zweite liegt.
konto.onclick = () => menueAnZiel(konto, [
{
text: 'Einstellungen',
icon: 'verwaltung',
tun: () => { location.hash = '#/einstellungen/konto'; },
},
{
text: 'Abmelden',
icon: 'abmelden',
gefaehrlich: true,
tun: () => nachfragen({
titel: 'Abmelden',
text: `Du meldest dich als „${name}“ von diesem Gerät ab. `
+ 'Sehstände und Favoriten bleiben auf dem Server.',
bestaetigen: 'Abmelden',
tun: async () => { abmelden(); location.reload(); },
}),
},
]);
rechts.appendChild(konto);
kopfleiste.append(marke, suche, rechts);
@@ -206,7 +230,18 @@ function leisteBauen(): void {
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i, liste));
}
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
// **Für jeden, nicht nur für Administratoren.** Hier stand „Verwaltung",
// und die sah nur, wer Rechte hat — mitsamt „Meine Sprache", die jedem
// gehört. Was dem Server gehört, steht jetzt als eigene Gruppe *in* den
// Einstellungen und bleibt Administratoren vorbehalten.
//
// **Unten, ohne Überschrift.** Eine Gruppenüberschrift über einem einzigen
// Eintrag ist eine Überschrift über nichts; am Fuss der Leiste steht er
// dort, wo man Einstellungen sucht.
const fuss = document.createElement('div');
fuss.className = 'nav-fuss';
leiste.appendChild(fuss);
eintrag('Einstellungen', '#/einstellungen', 'verwaltung', undefined, undefined, fuss);
/** Eine Gruppenüberschrift — bei den Mediatheken mit dem Stift daneben.
*
@@ -423,13 +458,17 @@ function zeichnen(): void {
} else if (teile[0] === 'suche' && kennung) {
einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen),
{ merken: true });
} else if (teile[0] === 'profil') {
// Nicht gemerkt: das Bild kann sich ändern, und ein Profil ist billig.
einhaengen(weg, inhalt, () => profilseite());
} else if (teile[0] === 'verwaltung' && istAdmin()) {
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
// Signal und hört auf, sobald man weggeht.
einhaengen(weg, inhalt, signal => verwaltung(alsBereich(teile[1]), bekannt => {
} else if (teile[0] === 'profil' || teile[0] === 'verwaltung') {
// **Die alten Adressen bleiben gültig.** Beides ist in „Einstellungen"
// aufgegangen; ein Lesezeichen soll davon nichts merken.
location.hash = teile[0] === 'profil'
? '#/einstellungen/konto'
: `#/einstellungen/${teile[1] ?? 'uebersicht'}`;
return;
} else if (teile[0] === 'einstellungen') {
// **Nicht gemerkt** — sie zeigt Laufendes. Ihr Takt hängt am Signal und
// hört auf, sobald man weggeht.
einhaengen(weg, inhalt, signal => einstellungenseite(alsBereich(teile[1], istAdmin()), bekannt => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen();
+44 -2
View File
@@ -739,7 +739,10 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.pl-kopf, .pl-leiste {
position: absolute; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: var(--s-regular);
padding: 14px 18px;
/* **Seitlich mindestens 18 Pixel, auf einem Gerät mit Ecken mehr.** Ohne
`env()` liegt der äusserste Knopf auf einem iPhone quer unter der
abgerundeten Ecke. */
padding: 14px max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
transition: opacity .2s ease-out, transform .2s ease-out;
}
.pl-kopf {
@@ -765,6 +768,15 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.pl-unten { display: flex; align-items: center; justify-content: space-between; gap: var(--s-regular); }
.pl-gruppe { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* **Die rechte Gruppe gibt nicht nach.**
*
* Beide Gruppen durften schrumpfen, und ihre Knöpfe sind `flex: 0 0 auto` —
* wird es eng, schrumpft also die Gruppe und ihre Knöpfe stehen heraus. Dann
* liegt der äusserste halb hinter dem Fensterrand. Links gibt es etwas
* nachzugeben (die Lautstärke, die Zeitangabe), rechts sind es nur Knöpfe. */
.pl-unten > .pl-gruppe:last-child { flex: 0 0 auto; }
.pl-unten > .pl-gruppe:first-child { flex: 0 1 auto; overflow: hidden; }
/* **44 Bildpunkte, nicht 32.** Darunter trifft ein Finger daneben, und ein
Fehlgriff auf der Zeitleiste wirft einen aus dem Film. */
.pl-rund {
@@ -887,9 +899,12 @@ body.pl-offen { overflow: hidden; }
.pl-kleinleiste { display: none; }
.pl.pl-klein .pl-kleinleiste {
display: flex; align-items: center; gap: var(--s-snug);
width: 100%; height: 100%; padding: 0 var(--s-regular) 0 140px;
width: 100%; height: 100%;
padding: 0 max(var(--s-regular), env(safe-area-inset-right)) 0 140px;
cursor: pointer;
}
/* Wie oben: der Text gibt nach, die Knöpfe nicht. */
.pl.pl-klein .pl-kleinleiste > .pl-rund { flex: 0 0 auto; }
.pl-kleintext { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pl-kleintitel {
@@ -1339,6 +1354,15 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
flex: none; width: 196px;
display: flex; flex-direction: column; gap: 2px;
}
/* Die Beschriftung ist hier die halbe Aussage: „Verwaltung" über den unteren
Einträgen sagt ohne weiteren Satz, dass diese dem Server gehören und nicht
einem selbst. */
.verw-gruppe {
font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
color: var(--text-faint); padding: var(--s-loose) var(--s-regular) var(--s-snug);
}
.verw-leiste > .verw-gruppe:first-child { padding-top: 0; }
.verw-eintrag {
display: flex; align-items: center; gap: var(--s-regular);
background: none; border: 0; text-align: left;
@@ -1372,8 +1396,26 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
}
.verw-eintrag.check { border-bottom-color: var(--accent); background: var(--accent-soft); }
.verw-buehne { width: 100%; }
/* Im Streifen sind die Überschriften Trennzeichen, keine Zeilen. */
.verw-gruppe { padding: 0 var(--s-snug); align-self: center; white-space: nowrap; }
}
/* --- Bildzuschnitt --------------------------------------------------------
*
* **Weil ein Profilbild rund ist.** Was hochgeladen wird, ist selten
* quadratisch. Den Ausschnitt dem Browser zu überlassen hiesse: mittig, und
* das trifft bei einem Porträt den Hals. */
.zuschnitt-dlg .dlg-koerper { display: flex; flex-direction: column; gap: var(--s-loose); }
.zuschnitt-buehne { display: flex; justify-content: center; }
.zuschnitt-feld {
width: 320px; height: 320px; max-width: 100%;
border-radius: 50%; background: var(--placeholder);
cursor: grab; touch-action: none;
box-shadow: 0 0 0 1px var(--line);
}
.zuschnitt-feld:active { cursor: grabbing; }
/* --- Kontextmenü ---------------------------------------------------------
*
* Gesehen, Favorit und Weglegen gab es nur auf der Detailseite. In einer
+149
View File
@@ -0,0 +1,149 @@
import { statistik, bildAdresse, type Statistik } from '../../api';
import { esc, fehlerzeile, meldung } from '../../util';
/** Was man geschaut hat — die eigenen Zahlen, und nur die eigenen.
*
* **Jeder sieht sich selbst, auch ein Administrator.** Das ist die Linie, die
* im Server schon steht: wer wann welchen Film anschaut, ist eine Auskunft
* über Menschen und nicht über den Server.
*/
export function geschautseite(): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'profil-zahlen';
void zahlenLaden(wurzel);
return wurzel;
}
async function zahlenLaden(ziel: HTMLElement): Promise<void> {
ziel.innerHTML = '<div class="spinner"></div>';
let stand: Statistik;
try {
stand = await statistik();
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile(meldung(fehler)));
return;
}
ziel.innerHTML = '';
const h = document.createElement('h2');
h.textContent = 'Geschaut';
ziel.appendChild(h);
// **Drei Zahlen, und jede sagt, woher sie kommt.**
//
// Die Gesamtzeit stammt aus dem Protokoll und fängt an dem Tag an, an dem
// es eingebaut wurde. Die Zahl gesehener Titel steht seit jeher fest. Das
// zu einer Zahl zu verrühren hiesse, die eine zu behaupten.
const kacheln = document.createElement('div');
kacheln.className = 'zahlenreihe';
kacheln.append(
zahl(stunden(stand.loggedSeconds), 'aufgezeichnet',
stand.since
? `seit ${new Date(stand.since).toLocaleDateString('de-CH', { dateStyle: 'medium' })}`
: 'noch nichts aufgezeichnet'),
zahl(String(stand.watchedTitles), stand.watchedTitles === 1 ? 'Titel gesehen' : 'Titel gesehen',
'mit Häkchen, auch von früher'),
zahl(stunden(stand.watchedRuntimeSeconds), 'Laufzeit davon',
'die Länge aller abgehakten Titel'),
);
ziel.appendChild(kacheln);
ziel.appendChild(wochenkurve(stand));
if (stand.genres.length > 0) ziel.appendChild(genreliste(stand));
if (stand.recent.length > 0) ziel.appendChild(zuletztliste(stand));
}
/** Die Wochenkurve als Balken — zwölf Werte brauchen keine Bibliothek. */
function wochenkurve(stand: Statistik): HTMLElement {
const block = document.createElement('div');
block.className = 'wochen';
const hoechstwert = Math.max(...stand.weeks.map(w => w.seconds), 1);
stand.weeks.forEach(w => {
const saeule = document.createElement('div');
saeule.className = 'woche';
const balken = document.createElement('div');
balken.className = 'woche-balken';
// **In Pixeln, nicht in Prozent.** Eine Prozenthöhe in einer Flexspalte
// löst sich nicht gegen das auf, was man erwartet — gemessen: aus „2 %"
// wurden 48 Pixel. Hundert Pixel sind der Platz, der da ist.
// Mindestens zwei, damit eine leere Woche als leer zu sehen ist und
// nicht als fehlend.
balken.style.height = `${Math.max(2, Math.round((w.seconds / hoechstwert) * 100))}px`;
// **`woche-leer`, nicht `leer`.** Den kurzen Namen gibt es schon: er
// gehört der Fläche „hier ist nichts" und bringt 24 Pixel Polster mit.
// Der Balken wurde dadurch 48 Pixel hoch, egal welche Höhe dastand.
if (w.seconds === 0) balken.classList.add('woche-leer');
const datum = new Date(w.start);
saeule.title = `Woche ab ${datum.toLocaleDateString('de-CH', { dateStyle: 'medium' })}: `
+ (w.seconds > 0 ? stunden(w.seconds) : 'nichts');
const marke = document.createElement('span');
marke.className = 'woche-marke';
marke.textContent = datum.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' });
saeule.append(balken, marke);
block.appendChild(saeule);
});
return block;
}
function genreliste(stand: Statistik): HTMLElement {
const block = document.createElement('div');
block.className = 'genres';
const groesste = Math.max(...stand.genres.map(g => g.seconds), 1);
stand.genres.forEach(g => {
const zeileG = document.createElement('div');
zeileG.className = 'genre';
zeileG.innerHTML = `<span class="genre-name">${esc(g.name)}</span>`
+ `<span class="genre-balken"><i style="width:${Math.round((g.seconds / groesste) * 100)}%"></i></span>`
+ `<span class="genre-wert">${esc(stunden(g.seconds))}</span>`;
block.appendChild(zeileG);
});
return block;
}
function zuletztliste(stand: Statistik): HTMLElement {
const block = document.createElement('div');
block.className = 'zuletzt';
const h = document.createElement('h3');
h.textContent = 'Zuletzt gesehen';
block.appendChild(h);
stand.recent.slice(0, 8).forEach(e => {
const k = document.createElement('button');
k.type = 'button';
k.className = 'zuletzt-zeile';
const adresse = bildAdresse(e.posterImage);
k.innerHTML = (adresse ? `<img src="${adresse}" alt="">` : '<span class="platzhalter"></span>')
+ `<span class="zuletzt-text"><strong>${esc(e.title)}</strong>`
+ (e.subTitle ? `<span class="winzig">${esc(e.subTitle)}</span>` : '')
+ `</span>`
+ `<span class="winzig">${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}</span>`;
k.onclick = () => { location.hash = `#/${e.kind === 'episode' ? 'serien' : 'filme'}/${e.id}`; };
block.appendChild(k);
});
return block;
}
function zahl(wert: string, was: string, hinweis: string): HTMLElement {
const d = document.createElement('div');
d.className = 'zahlkachel';
d.innerHTML = `<strong>${esc(wert)}</strong><span>${esc(was)}</span>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
return d;
}
/* ---------- Bild und Name ---------- */
/** „12 Std. 30 Min." — oder „0 Min.", solange nichts dasteht. */
function stunden(sekunden: number): string {
if (sekunden < 60) return '0 Min.';
const h = Math.floor(sekunden / 3600);
const m = Math.round((sekunden % 3600) / 60);
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
}
+144
View File
@@ -0,0 +1,144 @@
import { istAdmin, type Bibliothek } from '../../api';
import { icon, type IconName } from '../../icons';
import { kontoseite } from './konto';
import { geschautseite } from './geschaut';
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
import { pruefbereich } from './pruefung';
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
import { wiedergabebereich } from './wiedergabe';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
/** Welcher Teil der Einstellungen offen ist. */
export type Bereich =
| 'konto' | 'geschaut'
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
type Eintrag = { wert: Bereich; text: string; bild: IconName };
/** Was jedem gehört — das eigene Konto und die eigenen Zahlen. */
const MEINS: Eintrag[] = [
{ wert: 'konto', text: 'Mein Konto', bild: 'person' },
{ wert: 'geschaut', text: 'Geschaut', bild: 'hakenKreis' },
];
/** Was dem Server gehört. Nur Administratoren sehen diese Gruppe. */
const SERVER: Eintrag[] = [
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'haken' },
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'serien' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
];
/** Macht aus einem Adressteil einen Bereich.
*
* Unbekanntes und Verbotenes werden zum eigenen Konto: das hat jeder, und es
* ist die Seite, auf der man landen will, wenn man hier nichts zu suchen hat.
*/
export function alsBereich(wert: string | undefined, admin: boolean): Bereich {
if (MEINS.some(b => b.wert === wert)) return wert as Bereich;
if (admin && SERVER.some(b => b.wert === wert)) return wert as Bereich;
return 'konto';
}
/** Die Einstellungen.
*
* **Ein Ort, zwei Gruppen.** Oben steht, was einem selbst gehört — Bild,
* Sprache, die eigenen Zahlen. Darunter, nur für Administratoren, was dem
* Server gehört. Vorher hiess die ganze Seite „Verwaltung" und war für alle
* anderen gar nicht erreichbar; „Meine Sprache" lag dadurch hinter einer Tür,
* die den meisten verschlossen ist.
*
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block in
* der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg, das
* man einmal im Monat braucht — und sieht aus wie eine Eingabe, die noch
* aussteht.
*
* **Nur der offene Bereich wird gebaut.** Das ist nicht nur sparsamer — das
* Abspielfenster der Übersicht fragt im Takt von fünf Sekunden nach, und die
* Mediathekenliste beobachtet laufende Scans. Was nicht dasteht, fragt auch
* nicht.
*
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function einstellungenseite(
welcher: Bereich = 'konto',
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const admin = istAdmin();
const wurzel = document.createElement('div');
wurzel.className = 'verwaltung';
const leiste = document.createElement('nav');
leiste.className = 'verw-leiste';
leiste.setAttribute('aria-label', 'Bereiche der Einstellungen');
gruppe('Einstellungen', MEINS);
if (admin) gruppe('Verwaltung', SERVER);
const buehne = document.createElement('div');
buehne.className = 'verw-buehne';
inhalt().forEach(a => buehne.appendChild(a));
wurzel.append(leiste, buehne);
return wurzel;
/** Eine beschriftete Gruppe — die Beschriftung ist hier die halbe Aussage.
*
* „Verwaltung" über den unteren Einträgen sagt ohne weiteren Satz, dass
* diese dem Server gehören und nicht einem selbst. */
function gruppe(name: string, eintraege: Eintrag[]): void {
const kopf = document.createElement('div');
kopf.className = 'verw-gruppe';
kopf.textContent = name;
leiste.appendChild(kopf);
eintraege.forEach(b => {
const k = document.createElement('button');
k.type = 'button';
k.className = 'verw-eintrag' + (b.wert === welcher ? ' check' : '');
k.appendChild(icon(b.bild));
k.append(document.createTextNode(b.text));
if (b.wert === welcher) k.setAttribute('aria-current', 'page');
k.onclick = () => { location.hash = `#/einstellungen/${b.wert}`; };
leiste.appendChild(k);
});
}
function inhalt(): HTMLElement[] {
switch (welcher) {
case 'geschaut':
return [geschautseite()];
case 'mediatheken':
return [bibliotheksbereich(geaendert, signal), vermisstenbereich(geaendert)];
case 'pruefung': {
// **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
// ändert sich der Bestand — und die Liste darüber muss nachziehen.
const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
return [p.wurzel];
}
case 'zuordnung':
return [ohneZuordnungBereich(), unzugeordnetbereich()];
case 'wiedergabe':
return [wiedergabebereich()];
case 'metadaten':
return [sprachbereich()];
case 'benutzer':
return [benutzerbereich()];
case 'protokolle':
return [protokollbereich()];
case 'uebersicht':
return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
default:
return [kontoseite()];
}
}
}
@@ -1,25 +1,24 @@
import {
sitzung, meinKonto, profilbildSetzen, profilbildEntfernen, bildAdresse,
spracheSetzen, sprachen, abmelden, istAdmin, statistik,
type Rolle, type Statistik,
} from '../api';
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../util';
import { dialog, nachfragen } from '../dialog';
import { icon } from '../icons';
spracheSetzen, sprachen, abmelden, type Rolle,
} from '../../api';
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
import { dialog, nachfragen } from '../../dialog';
import { icon } from '../../icons';
import { zuschneiden } from '../../bildzuschnitt';
/** Die eigene Seite.
/** Das eigene Konto: Bild, Name, Sprache — und Abmelden.
*
* **Warum es sie gibt.** Ein Klick auf das eigene Bild meldete ab — ohne
* Rückfrage, ohne Menü, ohne Weg zurück. Das ist die unfreundlichste Deutung,
* die ein Bild mit dem eigenen Namen haben kann. Jetzt führt es hierher, und
* Abmelden steht als eigener Knopf am Ende, mit Rückfrage.
* **Warum es diese Seite gibt.** Ein Klick auf das eigene Bild meldete ab —
* ohne Rückfrage, ohne Menü, ohne Weg zurück. Das ist die unfreundlichste
* Deutung, die ein Bild mit dem eigenen Namen haben kann.
*
* **Und eine Einstellung wird dadurch überhaupt erst erreichbar.** „Meine
* Sprache" stand mitten in der Verwaltung; die zeigt der Client nur
* Administratoren, der Endpunkt erlaubte sie längst jedem. Wer kein Admin war,
* konnte seine Sprache nirgends ändern.
*/
export function profilseite(): HTMLElement {
export function kontoseite(): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'profil';
wurzel.innerHTML = '<div class="spinner"></div>';
@@ -41,139 +40,9 @@ export function profilseite(): HTMLElement {
if (sitzung.wer) Object.assign(sitzung.wer, konto);
wurzel.innerHTML = '';
const zahlen = document.createElement('section');
zahlen.className = 'profil-zahlen';
wurzel.append(kopf(konto), zahlen, einstellungen(konto), abmeldeteil());
void zahlenLaden(zahlen);
wurzel.append(kopf(konto), einstellungen(konto), abmeldeteil());
}
/* ---------- Was man geschaut hat ---------- */
async function zahlenLaden(ziel: HTMLElement): Promise<void> {
ziel.innerHTML = '<div class="spinner"></div>';
let stand: Statistik;
try {
stand = await statistik();
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile(meldung(fehler)));
return;
}
ziel.innerHTML = '';
const h = document.createElement('h2');
h.textContent = 'Geschaut';
ziel.appendChild(h);
// **Drei Zahlen, und jede sagt, woher sie kommt.**
//
// Die Gesamtzeit stammt aus dem Protokoll und fängt an dem Tag an, an dem
// es eingebaut wurde. Die Zahl gesehener Titel steht seit jeher fest. Das
// zu einer Zahl zu verrühren hiesse, die eine zu behaupten.
const kacheln = document.createElement('div');
kacheln.className = 'zahlenreihe';
kacheln.append(
zahl(stunden(stand.loggedSeconds), 'aufgezeichnet',
stand.since
? `seit ${new Date(stand.since).toLocaleDateString('de-CH', { dateStyle: 'medium' })}`
: 'noch nichts aufgezeichnet'),
zahl(String(stand.watchedTitles), stand.watchedTitles === 1 ? 'Titel gesehen' : 'Titel gesehen',
'mit Häkchen, auch von früher'),
zahl(stunden(stand.watchedRuntimeSeconds), 'Laufzeit davon',
'die Länge aller abgehakten Titel'),
);
ziel.appendChild(kacheln);
ziel.appendChild(wochenkurve(stand));
if (stand.genres.length > 0) ziel.appendChild(genreliste(stand));
if (stand.recent.length > 0) ziel.appendChild(zuletztliste(stand));
}
/** Die Wochenkurve als Balken — zwölf Werte brauchen keine Bibliothek. */
function wochenkurve(stand: Statistik): HTMLElement {
const block = document.createElement('div');
block.className = 'wochen';
const hoechstwert = Math.max(...stand.weeks.map(w => w.seconds), 1);
stand.weeks.forEach(w => {
const saeule = document.createElement('div');
saeule.className = 'woche';
const balken = document.createElement('div');
balken.className = 'woche-balken';
// **In Pixeln, nicht in Prozent.** Eine Prozenthöhe in einer Flexspalte
// löst sich nicht gegen das auf, was man erwartet — gemessen: aus „2 %"
// wurden 48 Pixel. Hundert Pixel sind der Platz, der da ist.
// Mindestens zwei, damit eine leere Woche als leer zu sehen ist und
// nicht als fehlend.
balken.style.height = `${Math.max(2, Math.round((w.seconds / hoechstwert) * 100))}px`;
// **`woche-leer`, nicht `leer`.** Den kurzen Namen gibt es schon: er
// gehört der Fläche „hier ist nichts" und bringt 24 Pixel Polster mit.
// Der Balken wurde dadurch 48 Pixel hoch, egal welche Höhe dastand.
if (w.seconds === 0) balken.classList.add('woche-leer');
const datum = new Date(w.start);
saeule.title = `Woche ab ${datum.toLocaleDateString('de-CH', { dateStyle: 'medium' })}: `
+ (w.seconds > 0 ? stunden(w.seconds) : 'nichts');
const marke = document.createElement('span');
marke.className = 'woche-marke';
marke.textContent = datum.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' });
saeule.append(balken, marke);
block.appendChild(saeule);
});
return block;
}
function genreliste(stand: Statistik): HTMLElement {
const block = document.createElement('div');
block.className = 'genres';
const groesste = Math.max(...stand.genres.map(g => g.seconds), 1);
stand.genres.forEach(g => {
const zeileG = document.createElement('div');
zeileG.className = 'genre';
zeileG.innerHTML = `<span class="genre-name">${esc(g.name)}</span>`
+ `<span class="genre-balken"><i style="width:${Math.round((g.seconds / groesste) * 100)}%"></i></span>`
+ `<span class="genre-wert">${esc(stunden(g.seconds))}</span>`;
block.appendChild(zeileG);
});
return block;
}
function zuletztliste(stand: Statistik): HTMLElement {
const block = document.createElement('div');
block.className = 'zuletzt';
const h = document.createElement('h3');
h.textContent = 'Zuletzt gesehen';
block.appendChild(h);
stand.recent.slice(0, 8).forEach(e => {
const k = document.createElement('button');
k.type = 'button';
k.className = 'zuletzt-zeile';
const adresse = bildAdresse(e.posterImage);
k.innerHTML = (adresse ? `<img src="${adresse}" alt="">` : '<span class="platzhalter"></span>')
+ `<span class="zuletzt-text"><strong>${esc(e.title)}</strong>`
+ (e.subTitle ? `<span class="winzig">${esc(e.subTitle)}</span>` : '')
+ `</span>`
+ `<span class="winzig">${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}</span>`;
k.onclick = () => { location.hash = `#/${e.kind === 'episode' ? 'serien' : 'filme'}/${e.id}`; };
block.appendChild(k);
});
return block;
}
function zahl(wert: string, was: string, hinweis: string): HTMLElement {
const d = document.createElement('div');
d.className = 'zahlkachel';
d.innerHTML = `<strong>${esc(wert)}</strong><span>${esc(was)}</span>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
return d;
}
/* ---------- Bild und Name ---------- */
function kopf(konto: Rolle): HTMLElement {
const reihe = document.createElement('section');
reihe.className = 'profil-kopf';
@@ -231,8 +100,15 @@ export function profilseite(): HTMLElement {
hinweisZeigen('Das Bild ist grösser als 5 MB. Nimm ein kleineres.');
return;
}
// **Erst zuschneiden, dann hochladen.** Ein Profilbild ist rund; was
// hochgeladen wird, ist selten quadratisch. Den Ausschnitt dem Browser zu
// überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals.
const ausschnitt = await zuschneiden(datei);
if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert.
try {
await profilbildSetzen(datei);
await profilbildSetzen(new File([ausschnitt], 'profil.jpg', { type: 'image/jpeg' }));
hinweisZeigen('Bild gesetzt.');
await laden();
kopfleisteAuffrischen();
@@ -287,13 +163,6 @@ export function profilseite(): HTMLElement {
},
})));
if (istAdmin()) {
liste.appendChild(zeile(
'Server einstellen',
'Mediatheken, Wiedergabe, Metadaten',
'Was für alle gilt, steht in der Verwaltung.',
() => { location.hash = '#/verwaltung'; }, 'Öffnen'));
}
bereich.appendChild(liste);
return bereich;
@@ -324,23 +193,19 @@ export function profilseite(): HTMLElement {
}
/** Die Kopfleiste zeigt das Bild — nach einem Wechsel muss sie es neu lesen. */
function kopfleisteAuffrischen(): void {
window.dispatchEvent(new CustomEvent('flaro:konto'));
}
/** „12 Std. 30 Min." — oder „noch nichts", solange nichts dasteht. */
function stunden(sekunden: number): string {
if (sekunden < 60) return '0 Min.';
const h = Math.floor(sekunden / 3600);
const m = Math.round((sekunden % 3600) / 60);
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
}
export function anfangsbuchstabe(name: string): string {
return (name.slice(0, 1) || '?').toUpperCase();
}
/** Dieselbe Zeile wie in der Verwaltung — hier mit frei wählbarer Beschriftung. */
function zeile(
name: string, wert: string, hinweis: string, tun: () => void, text = 'Ändern',
): HTMLElement {
-106
View File
@@ -1,106 +0,0 @@
import { type Bibliothek } from '../../api';
import { icon, type IconName } from '../../icons';
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
import { pruefbereich } from './pruefung';
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
import { wiedergabebereich } from './wiedergabe';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
/** Welcher Teil der Verwaltung offen ist. */
export type Bereich =
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
const BEREICHE: { wert: Bereich; text: string; bild: IconName }[] = [
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'hakenKreis' },
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'person' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
];
/** Macht aus einem Adressteil einen Bereich — Unbekanntes wird zur Übersicht. */
export function alsBereich(wert: string | undefined): Bereich {
return BEREICHE.some(b => b.wert === wert) ? wert as Bereich : 'uebersicht';
}
/** Die Verwaltung.
*
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block
* in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg,
* das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die
* noch aussteht.
*
* **Warum eine zweite Leiste.** Vorher hingen elf Abschnitte untereinander in
* einer Seite — 1786 Zeilen, und jede neue Einstellung machte sie länger. Wer
* das Protokoll sehen wollte, scrollte an den Mediatheken, der Prüfliste und
* dem Speicher vorbei. Jetzt hat jeder Bereich seine eigene Adresse: man kann
* ihn als Lesezeichen ablegen, und ein Neuladen bleibt dort stehen.
*
* **Nur der offene Bereich wird gebaut.** Das ist nicht nur sparsamer — das
* Abspielfenster der Übersicht fragt im Takt von fünf Sekunden nach, und die
* Mediathekenliste beobachtet laufende Scans. Was nicht dasteht, fragt auch
* nicht.
*
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function verwaltung(
welcher: Bereich = 'uebersicht',
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'verwaltung';
const leiste = document.createElement('nav');
leiste.className = 'verw-leiste';
leiste.setAttribute('aria-label', 'Bereiche der Verwaltung');
BEREICHE.forEach(b => {
const k = document.createElement('button');
k.type = 'button';
k.className = 'verw-eintrag' + (b.wert === welcher ? ' check' : '');
k.appendChild(icon(b.bild));
k.append(document.createTextNode(b.text));
if (b.wert === welcher) k.setAttribute('aria-current', 'page');
k.onclick = () => { location.hash = `#/verwaltung/${b.wert}`; };
leiste.appendChild(k);
});
const buehne = document.createElement('div');
buehne.className = 'verw-buehne';
inhalt().forEach(a => buehne.appendChild(a));
wurzel.append(leiste, buehne);
return wurzel;
function inhalt(): HTMLElement[] {
switch (welcher) {
case 'mediatheken':
return [bibliotheksbereich(geaendert, signal), vermisstenbereich(geaendert)];
case 'pruefung': {
// **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
// ändert sich der Bestand — und die Liste darüber muss nachziehen.
const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
return [p.wurzel];
}
case 'zuordnung':
return [ohneZuordnungBereich(), unzugeordnetbereich()];
case 'wiedergabe':
return [wiedergabebereich()];
case 'metadaten':
return [sprachbereich()];
case 'benutzer':
return [benutzerbereich()];
case 'protokolle':
return [protokollbereich()];
default:
return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
}
}
}