Hochladen konnte man Plakat, Hintergrund und Schriftzug schon lange — der Knopf stand da, der Endpunkt nahm an. Nur ankommen tat nichts: die Ablage baute einen Schlüssel, den sie selbst nicht wiederfand (heute früh repariert). Was blieb, waren drei Lücken, die man erst sieht, wenn der Weg überhaupt funktioniert. **Der Ausschnitt.** Ein Plakat steht hochkant im Verhältnis 2∶3, ein Hintergrund liegt quer. Was man aussucht, passt selten von selbst — und den Rest beschnitt bisher der Browser beim Anzeigen: mittig. Jetzt wählt man selbst, zieht und vergrössert, und hochgeladen wird genau dieser Ausschnitt: 600×900 beim Plakat, 1920×1080 beim Hintergrund. Gerechnet wird im Browser; das spart die Leitung und dem Server eine Bildbibliothek. **Der Schriftzug nicht.** Er ist ein PNG mit durchsichtigem Grund. Durch dieselbe Mühle geschickt würde aus der Durchsicht ein weisses Rechteck — ein Aufkleber über dem Bild. Er geht unverändert hinaus, Byte für Byte. **Die Kachel merkte nichts.** Nach dem Hochladen fehlte der Anstoss nach aussen; die Oberfläche zeigte weiter das alte Bild, bis man sie neu lud. **Und zurück ging es gar nicht.** „Von Hand gesetzt" heisst auch: beim Auffrischen bleibt es stehen. Ohne Rückweg wäre ein Fehlgriff ein Dauerzustand — jetzt steht „Wieder automatisch" daneben, sobald etwas festgesetzt ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
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';
|
|
|
|
/** 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.
|
|
*
|
|
* **Erreichbar aus der Übersicht, nicht erst von der Detailseite.** Bei
|
|
* zwanzig Titeln mit schlechtem Plakat waren das vierzig Seitenwechsel.
|
|
*
|
|
* **Die Kandidaten kosten nichts.** TMDB liefert sie beim Einzelabruf ohnehin
|
|
* mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
|
*/
|
|
/** Bilder eines Titels austauschen.
|
|
*
|
|
* **`fertig` wird gerufen, wenn sich wirklich etwas geändert hat** — und erst
|
|
* beim Schliessen, nicht nach jeder einzelnen Wahl. Wer drei Bilder tauscht,
|
|
* löst nicht dreimal einen Neuaufbau der Kachel aus. Wer nur hineinschaut und
|
|
* wieder zumacht, gar keinen.
|
|
*
|
|
* **Warum es den Rückruf überhaupt gibt.** Ohne ihn zeigte die Kachel das
|
|
* alte Plakat weiter, bis man die ganze Oberfläche neu lud — und verlor dabei
|
|
* Bildlauf, Filter und offene Reiter.
|
|
*/
|
|
export function bilddialog(
|
|
art: 'movies' | 'series', id: string, titel: string, fertig?: () => void,
|
|
): void {
|
|
let geaendert = false;
|
|
const d = document.createElement('dialog');
|
|
d.className = 'dlg dlg-breit';
|
|
|
|
const kopf = document.createElement('div');
|
|
kopf.className = 'dlg-kopf';
|
|
const h = document.createElement('h2');
|
|
h.textContent = `Bilder — ${titel}`;
|
|
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 koerper = document.createElement('div');
|
|
koerper.className = 'dlg-koerper';
|
|
|
|
d.append(kopf, koerper);
|
|
d.addEventListener('close', () => { d.remove(); if (geaendert) fertig?.(); });
|
|
d.addEventListener('click', e => { if (e.target === d) d.close(); });
|
|
document.body.appendChild(d);
|
|
d.showModal();
|
|
beimSeitenwechselSchliessen(d);
|
|
|
|
bilderflaeche(koerper, art, id, () => { geaendert = true; });
|
|
}
|
|
|
|
/** Dieselbe Bildauswahl, aber in eine beliebige Fläche gezeichnet.
|
|
*
|
|
* **Damit sie als Reiter in „Bearbeiten" stehen kann.**
|
|
*
|
|
* Vorher ging von dort ein zweiter Dialog über dem ersten auf — ein Fenster
|
|
* über einem Fenster, und man wusste nicht mehr, was man eigentlich zumacht.
|
|
* Der eigene Dialog bleibt für alle, die nur die Bilder wollen; er ist jetzt
|
|
* nur noch ein Rahmen um diese Fläche.
|
|
*/
|
|
export function bilderflaeche(
|
|
koerper: HTMLElement,
|
|
art: 'movies' | 'series',
|
|
id: string,
|
|
beiAenderung?: () => void,
|
|
): void {
|
|
koerper.innerHTML = '<div class="spinner"></div>';
|
|
|
|
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
|
|
let offen: Bildart = 'poster';
|
|
|
|
void laden();
|
|
|
|
async function laden(): Promise<void> {
|
|
let auswahl;
|
|
try {
|
|
auswahl = await bildauswahl(art, id);
|
|
} catch (fehler) {
|
|
koerper.innerHTML = '';
|
|
koerper.appendChild(fehlerzeile(meldung(fehler)));
|
|
return;
|
|
}
|
|
|
|
koerper.innerHTML = '';
|
|
|
|
if (auswahl.candidates.length === 0) {
|
|
const hinweis = document.createElement('p');
|
|
hinweis.className = 'hinweis';
|
|
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die Zuordnung das
|
|
// Problem, nicht das Bild, und das soll hier auch so dastehen.
|
|
hinweis.textContent = 'Keine Bilder gefunden. Ist der Titel richtig zugeordnet?';
|
|
koerper.appendChild(hinweis);
|
|
}
|
|
|
|
// **Ein Reiter je Bildtyp statt drei Blöcke untereinander.**
|
|
//
|
|
// Vorher scrollte der Dialogkörper, und jedes der drei Raster darin
|
|
// scrollte noch einmal. Zwei Ebenen übereinander heisst: das Rad trifft
|
|
// mal die eine, mal die andere, je nachdem wo der Zeiger steht — und man
|
|
// landet nie dort, wo man hinwollte. Jetzt ist genau ein Typ sichtbar und
|
|
// genau eine Fläche scrollt.
|
|
const reiter = document.createElement('div');
|
|
reiter.className = 'bildreiter';
|
|
reiter.setAttribute('role', 'tablist');
|
|
|
|
const buehne = document.createElement('div');
|
|
buehne.className = 'bildbuehne';
|
|
|
|
koerper.append(reiter, buehne);
|
|
|
|
const bloecke = new Map<Bildart, HTMLElement>();
|
|
|
|
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);
|
|
|
|
const knopfReiter = document.createElement('button');
|
|
knopfReiter.type = 'button';
|
|
knopfReiter.className = 'bildreiter-knopf' + (bildart === offen ? ' an' : '');
|
|
knopfReiter.setAttribute('role', 'tab');
|
|
knopfReiter.setAttribute('aria-selected', String(bildart === offen));
|
|
// Die Zahl dazu: so sieht man vor dem Klicken, ob es dort etwas gibt.
|
|
knopfReiter.innerHTML = `<span>${name}</span>`
|
|
+ `<span class="winzig">${passende.length}</span>`;
|
|
knopfReiter.onclick = () => { offen = bildart; void laden(); };
|
|
reiter.appendChild(knopfReiter);
|
|
|
|
if (bildart !== offen) return;
|
|
|
|
const abschnitt = document.createElement('section');
|
|
abschnitt.className = 'bildblock';
|
|
|
|
const kopfzeile = document.createElement('div');
|
|
kopfzeile.className = 'knopfreihe';
|
|
const u = document.createElement('h3');
|
|
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
|
|
// das native Dateifeld in jedem Browser anders aussieht.
|
|
const feld = document.createElement('input');
|
|
feld.type = 'file';
|
|
feld.accept = 'image/*';
|
|
feld.className = 'hidden';
|
|
const eigenes = knopf('Eigenes Bild…', 'btn');
|
|
eigenes.type = 'button';
|
|
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,
|
|
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) {
|
|
const leer = document.createElement('p');
|
|
leer.className = 'winzig';
|
|
leer.textContent = 'Nichts zur Auswahl.';
|
|
abschnitt.append(leer);
|
|
buehne.appendChild(abschnitt);
|
|
bloecke.set(bildart, abschnitt);
|
|
return;
|
|
}
|
|
|
|
const gitter = document.createElement('div');
|
|
gitter.className = 'bildgitter';
|
|
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
|
|
|
|
// Das gesetzte Bild zuerst, damit sichtbar ist, wovon man weggeht.
|
|
if (aktuell) gitter.appendChild(kachelFuer(null, bildAdresse(aktuell)!, true, bildart));
|
|
passende.forEach(k => gitter.appendChild(kachelFuer(k, k.thumbUrl, false, bildart)));
|
|
|
|
abschnitt.appendChild(gitter);
|
|
buehne.appendChild(abschnitt);
|
|
bloecke.set(bildart, abschnitt);
|
|
});
|
|
}
|
|
|
|
function kachelFuer(
|
|
kandidat: Bildkandidat | null, adresse: string, gesetzt: boolean, bildart: Bildart,
|
|
): HTMLElement {
|
|
const b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.className = 'bildwahl' + (gesetzt ? ' an' : '');
|
|
b.title = gesetzt ? 'Aktuell gesetzt'
|
|
: [kandidat?.language?.toUpperCase(), kandidat?.vote ? `★ ${kandidat.vote.toFixed(1)}` : null]
|
|
.filter(Boolean).join(' · ') || 'Auswählen';
|
|
|
|
const bild = document.createElement('img');
|
|
bild.src = adresse;
|
|
bild.alt = '';
|
|
bild.loading = 'lazy';
|
|
b.appendChild(bild);
|
|
|
|
if (gesetzt) {
|
|
const marke = document.createElement('span');
|
|
marke.className = 'bildwahl-marke';
|
|
marke.innerHTML = icons.haken;
|
|
b.appendChild(marke);
|
|
b.disabled = true;
|
|
return b;
|
|
}
|
|
|
|
b.onclick = async () => {
|
|
b.disabled = true;
|
|
try {
|
|
await bildSetzen(art, id, bildart, kandidat!.url);
|
|
beiAenderung?.();
|
|
await laden();
|
|
}
|
|
catch (fehler) {
|
|
b.after(fehlerzeile(meldung(fehler)));
|
|
b.disabled = false;
|
|
}
|
|
};
|
|
return b;
|
|
}
|
|
}
|