Task 8: der Bereich „Logo unten rechts“ in der Optionsspalte des Editors (Spec § 3 und § 5). - logos/logo-picker.ts (neu): createLogoPicker baut Kopf (Überschrift, Plakette „Community“, „+ Hochladen“, Satz zur Community), den Umschalter „Beliebt“/„Neu“ (Admins zusätzlich „Gemeldet“/„Ausgeblendet“, je eine Ansicht mit sort und filter) und das Raster: erste Kachel „Kein Logo“, je Logo eine Kachel mit Bild, Name, „von <Anzeigename>“ bzw. „von Anonym“ und einem eigenen Herz-Knopf neben dem Auswahlknopf. „Mehr laden“ hängt die nächste Seite an (Duplikate nur einmal), Leer-Text und Ladefehler mit „Nochmal versuchen“ (wiederholt genau den gescheiterten Schritt); nur die jüngste Antwort einer Ansicht zählt. Die Auswahl gehört dem Editor: ein Klick meldet onSelect, markiert wird über select(); ein Logo außerhalb der Liste wird nach dem Laden der ersten Seite per getLogo geholt und als zweite Kachel angeheftet (steht es in der Liste, gibt es keine Anfrage). selected() liefert die Zeile für die Infozeile in Task 9. Herzen: optimistisch mit Rücksprung und Fehlertext (role="alert"); solange eine Anfrage für ein Logo läuft, bleiben weitere Klicks auf sein Herz wirkungslos; Gäste öffnen erst den Login (abgebrochen: nichts passiert, kein Fehlertext); die Herzen einer Seite kommen mit genau deren IDs, beim Anmelden für alle gezeigten Logos, beim Abmelden aus; eine späte Antwort überschreibt kein inzwischen selbst geschaltetes Herz. „Hochladen“ meldet Gäste zuerst an, öffnet den Dialog (mit Signal, das beim Verlassen der Seite schließt), setzt das neue Logo als erste Kachel, ruft rememberLogo und onSelect(id). - logos/logo-image.ts (neu): das PNG einer Kachel laden, auf die Pixelgrenze prüfen (MAX_LOGO_PIXELS, so wie checkGalleryImage die Galerie-Bilder prüft; darüber, nicht lesbar oder Ladefehler: neutraler Platzhalter statt Absturz) und zeigen: mehrfarbig als <img>, einfarbig als weiße Maske, immer auf dem dunklen Kartengrund. Die Object-URL gibt die zurückgegebene Funktion frei; eine Datei, die erst danach eintrifft, wird nicht mehr angezeigt. - logos/logo-asset.ts: MAX_LOGO_PIXELS wird exportiert (eine Quelle für Renderer und Kacheln). - logos/logos.css: Kopf, Umschalter, Raster (ab 1024 px zwei Spalten, sonst auto-fill ab 140 px), Kacheln mit Bildfläche auf #1B2738, Herz (gefüllt, wenn gedrückt), Leer- und Fehlerzeilen; Bild und Maske teilen sich die Regeln mit der Dialog-Vorschau. - editor/editor-page.ts: der Picker sitzt in options.logoSlot, die Wahl setzt config.logo, sync ruft picker.select(config.logo), das Verlassen der Seite ruft dispose. Übernommen aus dem Review von Task 5: failedKey blockiert ein Logo nicht mehr für immer, eine andere Wahl (Logo, Details, Marke) hebt die Sperre auf; derselbe Schlüssel wird weiter nicht im Kreis neu versucht. - editor/dom.ts: Symbole heart und plus. i18n/en.ts: englische Texte zu allen neuen Beschriftungen. - Tests: logo-picker.test.ts (Aufbau, Sortierung, Mehr laden, Auswahl und angeheftetes Logo, Herzen mit Rennen und Login, Leer und Ladefehler, Hochladen, Kachelbilder, dispose, Englisch), logo-image.test.ts, editor-page.test.ts (Verdrahtung, Regressionstest zu failedKey). Vitest 51 Dateien, 1666 Tests grün, tsc und Build sauber. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
71 lines
3.1 KiB
TypeScript
71 lines
3.1 KiB
TypeScript
// Das PNG eines Community-Logos als Bild einer Kachel: laden, auf die Pixelgrenze prüfen und auf der Bildfläche zeigen. Die Object-URL
|
|
// gehört dem Aufrufer, der sie über die zurückgegebene Funktion freigibt. Fremde Dateien sind ungeprüft: Bilder über der Grenze oder
|
|
// nicht lesbare zeigt es nicht (neutraler Platzhalter), es stürzt nie ab.
|
|
import { TONE_COLOR } from '../card/config';
|
|
import { el } from '../editor/dom';
|
|
import { MAX_LOGO_PIXELS } from './logo-asset';
|
|
import type { CommunityLogo } from './logos';
|
|
|
|
/**
|
|
* Lädt die Object-URL in ein `Image` und lehnt ab, was sich nicht lesen lässt oder mehr als `MAX_LOGO_PIXELS` Pixel hat (so wie
|
|
* `checkGalleryImage` die Galerie-Bilder prüft): ein winziges, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten
|
|
* und den Tab einfrieren. Die Maße sind nach dem Laden bekannt, das Bild ist aber noch nicht dekodiert.
|
|
*/
|
|
function checkImage(url: string): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const probe = new Image();
|
|
probe.onload = () => {
|
|
const { naturalWidth: w, naturalHeight: h } = probe;
|
|
if (w > 0 && h > 0 && w * h <= MAX_LOGO_PIXELS) resolve();
|
|
else reject(new Error('Das Logo ist zu groß.'));
|
|
};
|
|
probe.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
|
|
probe.src = url;
|
|
});
|
|
}
|
|
|
|
/** Einfarbiges Logo: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL), gefüllt in Weiß wie auf der dunklen Karte. */
|
|
function maskOf(url: string): HTMLElement {
|
|
const mask = el('span', { class: 'logo-tile-mask', 'aria-hidden': 'true' });
|
|
mask.style.setProperty('--logo-url', `url("${url}")`);
|
|
mask.style.setProperty('background-color', TONE_COLOR.light);
|
|
return mask;
|
|
}
|
|
|
|
/**
|
|
* Zeigt das Logo auf `art` (dekorativ: mehrfarbig als Bild, einfarbig als weiße Maske) und setzt dort `data-state`: `loading`, dann
|
|
* `ready` oder `unusable` (Ladefehler, nicht lesbar, zu groß: die Fläche bleibt leer, der Platzhalter kommt aus dem Stylesheet).
|
|
* `load` holt die Datei. Die zurückgegebene Funktion gibt die Object-URL frei; eine Datei, die erst danach eintrifft, wird nicht mehr
|
|
* angezeigt und ihre URL sofort wieder freigegeben.
|
|
*/
|
|
export function showLogoImage(art: HTMLElement, logo: CommunityLogo, load: (logo: CommunityLogo) => Promise<Blob>): () => void {
|
|
let shown: string | null = null;
|
|
let released = false;
|
|
art.setAttribute('data-state', 'loading');
|
|
|
|
void (async () => {
|
|
let url: string | null = null;
|
|
try {
|
|
const blob = await load(logo);
|
|
if (released) return;
|
|
url = URL.createObjectURL(blob);
|
|
await checkImage(url);
|
|
if (!released) {
|
|
shown = url;
|
|
art.replaceChildren(logo.mono ? maskOf(url) : el('img', { class: 'logo-tile-img', src: url, alt: '', draggable: 'false' }));
|
|
art.setAttribute('data-state', 'ready');
|
|
return;
|
|
}
|
|
} catch {
|
|
if (!released) art.setAttribute('data-state', 'unusable');
|
|
}
|
|
if (url) URL.revokeObjectURL(url); // nicht angezeigt (abgelehnt oder die Kachel ist inzwischen weg)
|
|
})();
|
|
|
|
return () => {
|
|
released = true;
|
|
if (shown) URL.revokeObjectURL(shown);
|
|
shown = null;
|
|
};
|
|
}
|