Files
AirCardIOS/web/src/logos/logo-asset.ts
T
ClaudeandClaude Opus 5.5 4cf9fe192d feat(editor): Community-Logos unten rechts mit Herzen und Sortierung
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>
2026-10-04 22:40:37 +00:00

144 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Community-Logos als Bild für den Kartenrenderer: Zeile und PNG laden, dekodieren, einfarbige Logos in der Details-Farbe füllen.
// Zeilen und fertige Bilder bleiben im Speicher (je Logo und Details-Farbe nur ein Ladevorgang). Ein Logo, das es nicht (mehr)
// gibt, ergibt null; ein echter Ladefehler (Netz, Server) wirft und bleibt nicht im Zwischenspeicher.
import { TONE_COLOR, type DetailsTone } from '../card/config';
// Nur der Typ: render.ts importiert dieses Modul (Standard-Lader), zur Laufzeit darf kein Kreis entstehen.
import type { LogoAsset } from '../card/render';
import { msg } from '../i18n/i18n';
import { getLogo, loadLogoBlob, type CommunityLogo } from './logos';
/** Hinweis im Editor, wenn die Karte ein Logo wählt, das gelöscht oder ausgeblendet wurde. */
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
/**
* Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles
* Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet
* (und auch nicht als Kachel im Logo-Raster, siehe logo-picker.ts).
*/
export const MAX_LOGO_PIXELS = 1_000_000;
type Decoded = LogoAsset;
// Zeilen (auch „gibt es nicht“ = null) und fertige Bilder; die Schlüssel der Bilder sind „<id>:<tone>“ (IDs enthalten kein „:“).
const rows = new Map<string, Promise<CommunityLogo | null>>();
const assets = new Map<string, Promise<LogoAsset | null>>();
/** Verwirft die fertigen Bilder eines Logos (beide Details-Farben). */
function dropImages(id: string): void {
for (const key of assets.keys()) if (key.startsWith(`${id}:`)) assets.delete(key);
}
/**
* Legt eine Zeile, die der Aufrufer schon hat (Logo-Liste, frisch hochgeladenes Logo), in den Zwischenspeicher: spart `getLogo`.
* Ist sie ausgeblendet, fallen schon geladene Bilder des Logos weg: ein gesperrtes Logo darf nicht mehr gezeichnet werden.
*/
export function rememberLogo(logo: CommunityLogo): void {
rows.set(logo.id, Promise.resolve(logo));
if (logo.hidden) dropImages(logo.id);
}
/** Vergisst Zeile und Bilder eines Logos (nach Löschen oder Ausblenden); der nächste Zugriff lädt neu. */
export function forgetLogo(id: string): void {
rows.delete(id);
dropImages(id);
}
/** Die Zeile eines Logos, einmal je ID; ein Fehler wird nicht festgehalten. */
function rowFor(id: string): Promise<CommunityLogo | null> {
const cached = rows.get(id);
if (cached) return cached;
const request = getLogo(id);
// Nur entfernen, was noch derselbe Eintrag ist: forgetLogo/rememberLogo können ihn inzwischen ersetzt haben.
request.catch(() => {
if (rows.get(id) === request) rows.delete(id);
});
rows.set(id, request);
return request;
}
/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */
function isFileMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 || type === 'storage_file_not_found';
}
/** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */
function decodeWithImage(blob: Blob): Promise<Decoded> {
const url = URL.createObjectURL(blob);
return new Promise<Decoded>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ source: img, w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
img.src = url;
}).finally(() => URL.revokeObjectURL(url));
}
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
async function decode(blob: Blob): Promise<Decoded | null> {
try {
let decoded: Decoded;
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(blob);
decoded = { source: bitmap, w: bitmap.width, h: bitmap.height };
} else {
decoded = await decodeWithImage(blob);
}
return decoded.w > 0 && decoded.h > 0 ? decoded : null;
} catch {
return null;
}
}
/** Ein Canvas in Bildgröße, in dem nur noch die Form des Bildes (sein Alphakanal) steht, gefüllt mit `color`. */
function fillShape({ source, w, h }: Decoded, color: string): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
ctx.drawImage(source, 0, 0);
// „source-in“: die Farbe bleibt nur dort, wo das Bild schon deckt.
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = color;
ctx.fillRect(0, 0, w, h);
return canvas;
}
async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
const logo = await rowFor(id);
// Ausgeblendete Logos können Hochlader und Admins noch lesen. Ein gesperrtes Logo darf aber nie in einer Karte stehen,
// die sich veröffentlichen lässt: es gilt als nicht verfügbar.
if (!logo || logo.hidden) return null;
let blob: Blob;
try {
blob = await loadLogoBlob(logo);
} catch (err) {
if (isFileMissing(err)) return null;
throw err;
}
const decoded = await decode(blob);
if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null;
if (!logo.mono) return decoded;
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
}
/**
* Das Community-Logo `id` als Bild für den Renderer. Einfarbige Logos (`mono`) haben die Farbe der Details (Hell/Dunkel),
* mehrfarbige bleiben unverändert. null: das Logo gibt es nicht (mehr), ist ausgeblendet, oder seine Datei ist unbrauchbar.
* Wirft bei einem echten Ladefehler; der bleibt nicht im Zwischenspeicher, ein späterer Aufruf versucht es neu.
*/
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
const key = `${id}:${tone}`;
const cached = assets.get(key);
if (cached) return cached;
const request = build(id, tone);
request.catch(() => {
if (assets.get(key) === request) assets.delete(key);
});
assets.set(key, request);
return request;
}