diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 70f4470..eb2b77f 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -747,6 +747,16 @@ export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, u anfrage(`/${art}/${id}/artwork`, { method: 'PUT', body: JSON.stringify({ kind, url }) }); +/** Nimmt ein von Hand gesetztes Bild wieder weg. + * + * **Damit ein Fehlgriff kein Dauerzustand ist.** Ein hochgeladenes Bild war + * sonst endgültig: es stand fest und kam beim Auffrischen auch nicht zurück, + * weil „von Hand gesetzt" genau das bedeutet. Ohne Adresse setzt der Server + * beides zurück — das Bild und die Festlegung. */ +export const bildFreigeben = (art: 'movies' | 'series', id: string, kind: string) => + anfrage(`/${art}/${id}/artwork`, + { method: 'PUT', body: JSON.stringify({ kind, url: '' }) }); + /** Ein eigenes Bild hochladen. Ohne `Content-Type` — den setzt der Browser * mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */ export const bildHochladen = async ( diff --git a/web/app/src/bilder.ts b/web/app/src/bilder.ts index 60fa7e9..a21009d 100644 --- a/web/app/src/bilder.ts +++ b/web/app/src/bilder.ts @@ -1,14 +1,28 @@ -import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api'; -import { knopf, fehlerzeile, meldung } from './util'; +import { + bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, type Bildkandidat, +} from './api'; +import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util'; import { icons } from './icons'; import { beimSeitenwechselSchliessen } from './dialog'; +import { zuschneiden } from './bildzuschnitt'; type Bildart = 'poster' | 'backdrop' | 'logo'; -const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [ - { art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3' }, - { art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9' }, - { art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9' }, +/** Was jede Bildart ist — und in welcher Form sie hochgeladen wird. + * + * **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein + * Schriftzug ist ein PNG mit durchsichtigem Grund; durch die Zuschneidemühle + * geschickt würde aus der Durchsicht ein weisses Rechteck, und er läge wie + * ein Aufkleber über dem Bild. Er geht unverändert hinaus. */ +const ARTEN: { + art: Bildart; titel: string; verhaeltnis: string; + zuschnitt: { verhaeltnis: number; breite: number } | null; +}[] = [ + { art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3', + zuschnitt: { verhaeltnis: 2 / 3, breite: 600 } }, + { art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9', + zuschnitt: { verhaeltnis: 16 / 9, breite: 1920 } }, + { art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9', zuschnitt: null }, ]; /** Plakat, Hintergrund und Schriftzug von Hand wählen. @@ -122,10 +136,13 @@ export function bilderflaeche( const bloecke = new Map(); - ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => { + ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis, zuschnitt }) => { const aktuell = bildart === 'poster' ? auswahl.current.poster : bildart === 'backdrop' ? auswahl.current.backdrop : auswahl.current.logo; + const festgesetzt = bildart === 'poster' ? auswahl.current.posterPinned + : bildart === 'backdrop' ? auswahl.current.backdropPinned + : auswahl.current.logoPinned; const passende = auswahl.candidates.filter(k => k.kind === bildart); @@ -148,7 +165,8 @@ export function bilderflaeche( const kopfzeile = document.createElement('div'); kopfzeile.className = 'knopfreihe'; const u = document.createElement('h3'); - u.textContent = aktuell ? `${name} — eines ist gesetzt` : name; + u.textContent = festgesetzt ? `${name} — von Hand gesetzt` + : aktuell ? `${name} — eines ist gesetzt` : name; kopfzeile.appendChild(u); // Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil @@ -162,15 +180,60 @@ export function bilderflaeche( eigenes.onclick = () => feld.click(); feld.onchange = async () => { const datei = feld.files?.[0]; + feld.value = ''; // Dieselbe Datei zweimal soll wieder auslösen. if (!datei) return; + + // **Erst zuschneiden, dann hochladen** — ein Plakat steht hochkant, + // ein Hintergrund liegt quer. Was nicht passt, beschnitt sonst der + // Browser beim Anzeigen: mittig, und das trifft selten. + let inhalt: Blob = datei; + if (zuschnitt) { + const ausschnitt = await zuschneiden(datei, { + ...zuschnitt, titel: `${name} zuschneiden`, + }); + if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert. + inhalt = ausschnitt; + } + eigenes.disabled = true; - try { await bildHochladen(art, id, bildart, datei); await laden(); } - catch (fehler) { + try { + await bildHochladen(art, id, bildart, + new File([inhalt], zuschnitt ? 'bild.jpg' : datei.name, + { type: zuschnitt ? 'image/jpeg' : datei.type })); + // **Auch das zählt als Änderung.** Hier stand nichts, und die + // Kachel zeigte danach das alte Bild weiter — bis man die ganze + // Oberfläche neu lud. + beiAenderung?.(); + hinweisZeigen(`${name} gesetzt.`); + await laden(); + } catch (fehler) { abschnitt.appendChild(fehlerzeile(meldung(fehler))); eigenes.disabled = false; } }; kopfzeile.append(eigenes, feld); + + // **Nur, wenn es etwas zurückzunehmen gibt.** „Von Hand gesetzt" heisst + // auch: beim Auffrischen bleibt es stehen. Ohne diesen Knopf wäre ein + // Fehlgriff ein Dauerzustand. + if (festgesetzt) { + const frei = knopf('Wieder automatisch', 'btn'); + frei.type = 'button'; + frei.onclick = async () => { + frei.disabled = true; + try { + await bildFreigeben(art, id, bildart); + beiAenderung?.(); + hinweisZeigen('Kommt beim nächsten Auffrischen wieder aus der Quelle.'); + await laden(); + } catch (fehler) { + abschnitt.appendChild(fehlerzeile(meldung(fehler))); + frei.disabled = false; + } + }; + kopfzeile.appendChild(frei); + } + abschnitt.appendChild(kopfzeile); if (passende.length === 0 && !aktuell) { diff --git a/web/app/src/bildzuschnitt.ts b/web/app/src/bildzuschnitt.ts index 480d155..c5829cd 100644 --- a/web/app/src/bildzuschnitt.ts +++ b/web/app/src/bildzuschnitt.ts @@ -2,19 +2,34 @@ import { knopf } from './util'; import { icons } from './icons'; import { beimSeitenwechselSchliessen } from './dialog'; -/** Schneidet ein Bild auf ein Quadrat zu, bevor es hochgeladen wird. +/** Was für ein Ausschnitt gebraucht wird. */ +export type Zuschnitt = { + /** Breite geteilt durch Höhe. 1 für ein Quadrat, 2/3 für ein Plakat. */ + verhaeltnis: number; + /** Wie breit das Ergebnis wird; die Höhe folgt aus dem Verhältnis. */ + breite: number; + /** Rund gezeigt — beim Profilbild, weil es rund dasteht. */ + rund?: boolean; + titel?: string; +}; + +/** Schneidet ein Bild 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. + * **Weil ein Bild eine Form hat.** Ein Profilbild ist rund, ein Plakat steht + * hochkant im Verhältnis 2∶3, ein Hintergrund liegt quer. Was hochgeladen + * wird, passt selten von selbst — und der Browser beschnitt es mit + * `object-fit: cover` mittig, was bei einem Porträt den Hals trifft. Wer ein + * 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. + * Ausschnitt. Das spart die Leitung, den Platz und dem Server eine + * Bildbibliothek, die er sonst nirgends braucht. + * + * **Nicht für Schriftzüge.** Die liegen als PNG mit durchsichtigem Grund vor; + * sie durch diese Mühle zu schicken hiesse, aus der Durchsicht ein weisses + * Rechteck zu machen. Sie gehen unverändert hinaus. */ -export function zuschneiden(datei: File): Promise { +export function zuschneiden(datei: File, o: Zuschnitt): Promise { return new Promise(fertig => { const bild = new Image(); const adresse = URL.createObjectURL(datei); @@ -31,7 +46,7 @@ export function zuschneiden(datei: File): Promise { const kopf = document.createElement('div'); kopf.className = 'dlg-kopf'; const h = document.createElement('h2'); - h.textContent = 'Ausschnitt wählen'; + h.textContent = o.titel ?? 'Ausschnitt wählen'; const zu = document.createElement('button'); zu.className = 'dlg-zu'; zu.innerHTML = icons.schliessen; @@ -42,12 +57,17 @@ export function zuschneiden(datei: File): Promise { 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'; + // **Die Vorschau passt in ein Quadrat von 320**, egal welches Verhältnis + // — ein Plakat wird hoch und schmal, ein Hintergrund breit und flach, + // und beides bleibt im Dialog. + const LANG = 320; + const feldB = o.verhaeltnis >= 1 ? LANG : Math.round(LANG * o.verhaeltnis); + const feldH = o.verhaeltnis >= 1 ? Math.round(LANG / o.verhaeltnis) : LANG; + leinwand.width = feldB; + leinwand.height = feldH; + leinwand.className = 'zuschnitt-feld' + (o.rund ? ' zuschnitt-rund' : ''); + leinwand.style.width = `${feldB}px`; + leinwand.style.height = `${feldH}px`; buehne.appendChild(leinwand); const regler = document.createElement('input'); @@ -82,7 +102,7 @@ export function zuschneiden(datei: File): Promise { // **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); + const grund = Math.max(feldB / bild.width, feldH / bild.height); let faktor = 1; let x = 0; let y = 0; @@ -91,15 +111,15 @@ export function zuschneiden(datei: File): Promise { 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)); + x = Math.min(0, Math.max(feldB - breite, x)); + y = Math.min(0, Math.max(feldH - hoehe, y)); } function zeichnen(): void { const stift = leinwand.getContext('2d'); if (!stift) return; begrenzen(); - stift.clearRect(0, 0, SEITE, SEITE); + stift.clearRect(0, 0, feldB, feldH); stift.drawImage(bild, x, y, bild.width * grund * faktor, bild.height * grund * faktor); } @@ -109,9 +129,10 @@ export function zuschneiden(datei: File): Promise { 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); + const mx = feldB / 2; + const my = feldH / 2; + x = mx - (mx - x) * (neu / faktor); + y = my - (my - y) * (neu / faktor); faktor = neu; zeichnen(); }; @@ -138,14 +159,13 @@ export function zuschneiden(datei: File): Promise { let ergebnis: Blob | null = null; nehmen.onclick = () => { - const ZIEL = 512; const aus = document.createElement('canvas'); - aus.width = ZIEL; - aus.height = ZIEL; + aus.width = o.breite; + aus.height = Math.round(o.breite / o.verhaeltnis); const stift = aus.getContext('2d'); if (!stift) { d.close(); return; } - const massstab = ZIEL / SEITE; + const massstab = o.breite / feldB; stift.drawImage( bild, x * massstab, y * massstab, diff --git a/web/app/src/style.css b/web/app/src/style.css index 2b741d8..01b16eb 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1409,11 +1409,13 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; } .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); + max-width: 100%; + border-radius: var(--r-karte); background: var(--placeholder); cursor: grab; touch-action: none; box-shadow: 0 0 0 1px var(--line); } +/* Rund nur, wo es rund dasteht — beim Profilbild. */ +.zuschnitt-feld.zuschnitt-rund { border-radius: 50%; } .zuschnitt-feld:active { cursor: grabbing; } /* --- Kontextmenü --------------------------------------------------------- diff --git a/web/app/src/views/einstellungen/konto.ts b/web/app/src/views/einstellungen/konto.ts index b19234d..e7a9632 100644 --- a/web/app/src/views/einstellungen/konto.ts +++ b/web/app/src/views/einstellungen/konto.ts @@ -104,7 +104,9 @@ export function kontoseite(): HTMLElement { // **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); + const ausschnitt = await zuschneiden(datei, { + verhaeltnis: 1, breite: 512, rund: true, titel: 'Profilbild zuschneiden', + }); if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert. try {