diff --git a/web/app/src/bildzuschnitt.ts b/web/app/src/bildzuschnitt.ts new file mode 100644 index 0000000..480d155 --- /dev/null +++ b/web/app/src/bildzuschnitt.ts @@ -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 { + 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; + }); +} diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 618c569..aa7c4e0 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -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 = `${esc(name.slice(0, 1).toUpperCase())}`; } - 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(); diff --git a/web/app/src/style.css b/web/app/src/style.css index 53d513b..2b741d8 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -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 diff --git a/web/app/src/views/verwaltung/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts similarity index 100% rename from web/app/src/views/verwaltung/benutzer.ts rename to web/app/src/views/einstellungen/benutzer.ts diff --git a/web/app/src/views/einstellungen/geschaut.ts b/web/app/src/views/einstellungen/geschaut.ts new file mode 100644 index 0000000..e45723a --- /dev/null +++ b/web/app/src/views/einstellungen/geschaut.ts @@ -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 { + ziel.innerHTML = '
'; + 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 = `${esc(g.name)}` + + `` + + `${esc(stunden(g.seconds))}`; + 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 ? `` : '') + + `${esc(e.title)}` + + (e.subTitle ? `${esc(e.subTitle)}` : '') + + `` + + `${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}`; + 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 = `${esc(wert)}${esc(was)}` + + `${esc(hinweis)}`; + 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.`; +} diff --git a/web/app/src/views/einstellungen/index.ts b/web/app/src/views/einstellungen/index.ts new file mode 100644 index 0000000..1d18ea1 --- /dev/null +++ b/web/app/src/views/einstellungen/index.ts @@ -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()]; + } + } +} diff --git a/web/app/src/views/profil.ts b/web/app/src/views/einstellungen/konto.ts similarity index 50% rename from web/app/src/views/profil.ts rename to web/app/src/views/einstellungen/konto.ts index b95428a..b19234d 100644 --- a/web/app/src/views/profil.ts +++ b/web/app/src/views/einstellungen/konto.ts @@ -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 = '
'; @@ -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 { - ziel.innerHTML = '
'; - 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 = `${esc(g.name)}` - + `` - + `${esc(stunden(g.seconds))}`; - 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 ? `` : '') - + `${esc(e.title)}` - + (e.subTitle ? `${esc(e.subTitle)}` : '') - + `` - + `${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}`; - 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 = `${esc(wert)}${esc(was)}` - + `${esc(hinweis)}`; - 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 { @@ -366,4 +231,4 @@ function zeile( h.textContent = hinweis; d.appendChild(h); return d; -} +} \ No newline at end of file diff --git a/web/app/src/views/verwaltung/mediatheken.ts b/web/app/src/views/einstellungen/mediatheken.ts similarity index 100% rename from web/app/src/views/verwaltung/mediatheken.ts rename to web/app/src/views/einstellungen/mediatheken.ts diff --git a/web/app/src/views/verwaltung/metadaten.ts b/web/app/src/views/einstellungen/metadaten.ts similarity index 100% rename from web/app/src/views/verwaltung/metadaten.ts rename to web/app/src/views/einstellungen/metadaten.ts diff --git a/web/app/src/views/verwaltung/protokolle.ts b/web/app/src/views/einstellungen/protokolle.ts similarity index 100% rename from web/app/src/views/verwaltung/protokolle.ts rename to web/app/src/views/einstellungen/protokolle.ts diff --git a/web/app/src/views/verwaltung/pruefung.ts b/web/app/src/views/einstellungen/pruefung.ts similarity index 100% rename from web/app/src/views/verwaltung/pruefung.ts rename to web/app/src/views/einstellungen/pruefung.ts diff --git a/web/app/src/views/verwaltung/teile.ts b/web/app/src/views/einstellungen/teile.ts similarity index 100% rename from web/app/src/views/verwaltung/teile.ts rename to web/app/src/views/einstellungen/teile.ts diff --git a/web/app/src/views/verwaltung/uebersicht.ts b/web/app/src/views/einstellungen/uebersicht.ts similarity index 100% rename from web/app/src/views/verwaltung/uebersicht.ts rename to web/app/src/views/einstellungen/uebersicht.ts diff --git a/web/app/src/views/verwaltung/wiedergabe.ts b/web/app/src/views/einstellungen/wiedergabe.ts similarity index 100% rename from web/app/src/views/verwaltung/wiedergabe.ts rename to web/app/src/views/einstellungen/wiedergabe.ts diff --git a/web/app/src/views/verwaltung/zuordnung.ts b/web/app/src/views/einstellungen/zuordnung.ts similarity index 100% rename from web/app/src/views/verwaltung/zuordnung.ts rename to web/app/src/views/einstellungen/zuordnung.ts diff --git a/web/app/src/views/verwaltung/index.ts b/web/app/src/views/verwaltung/index.ts deleted file mode 100644 index 86a8240..0000000 --- a/web/app/src/views/verwaltung/index.ts +++ /dev/null @@ -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()]; - } - } -}