feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer

- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
  sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
  wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
  ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
  der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
  loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
  Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
  createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
  gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
  Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
  rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
  Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
  ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
  auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
  fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
  es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
  logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
  Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 20:32:45 +00:00
1 parent 3051b3b3f9
commit 0be5abd954
44 files changed
+1401 -366

No files matched your search

+406
View File
@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CommunityLogo } from './logos';
const mocks = vi.hoisted(() => ({ getLogo: vi.fn(), loadLogoBlob: vi.fn(), createImageBitmap: vi.fn() }));
// Zeile und Datei kommen über getLogo/loadLogoBlob; Appwrite, Bilddekodierung und Canvas sind hier ersetzt.
vi.mock('./logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
/** Eine frische Kopie des Moduls, damit der Zwischenspeicher je Test leer beginnt. */
async function fresh() {
vi.resetModules();
return import('./logo-asset');
}
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'l1',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'l1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap;
const file = () => new Blob(['png'], { type: 'image/png' });
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
interface CanvasCall {
name: string;
args: unknown[];
/** Stand von `globalCompositeOperation` und `fillStyle` im Moment des Aufrufs. */
globalCompositeOperation: unknown;
fillStyle: unknown;
}
interface FakeCanvas {
width: number;
height: number;
calls: CanvasCall[];
getContext: ReturnType<typeof vi.fn>;
}
const canvases: FakeCanvas[] = [];
/** `document.createElement('canvas')` liefert aufzeichnende Fakes (happy-dom hat keinen 2D-Kontext). */
function stubCanvas(options: { noContext?: boolean } = {}): void {
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
if (tag !== 'canvas') throw new Error(`unerwartetes Element: ${tag}`);
const ctx: Record<string, unknown> = { globalCompositeOperation: 'source-over', fillStyle: '#000000' };
const canvas: FakeCanvas = { width: 0, height: 0, calls: [], getContext: vi.fn(() => (options.noContext ? null : ctx)) };
const record =
(name: string) =>
(...args: unknown[]) =>
void canvas.calls.push({ name, args, globalCompositeOperation: ctx.globalCompositeOperation, fillStyle: ctx.fillStyle });
ctx.drawImage = record('drawImage');
ctx.fillRect = record('fillRect');
canvases.push(canvas);
return canvas as unknown as HTMLElement;
}) as typeof document.createElement);
}
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
mocks.getLogo.mockResolvedValue(logo());
mocks.loadLogoBlob.mockResolvedValue(file());
mocks.createImageBitmap.mockResolvedValue(bitmap(200, 100));
vi.stubGlobal('createImageBitmap', mocks.createImageBitmap);
canvases.length = 0;
stubCanvas();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('LOGO_MISSING', () => {
it('der Hinweis aus der Spec, auf Englisch übersetzt', async () => {
const { LOGO_MISSING } = await fresh();
// Nach resetModules ist auch i18n frisch (die Sprache des Setups gilt für die alte Kopie): die Sprache hier selbst setzen.
const { t, setLang } = await import('../i18n/i18n');
setLang('de', { persist: false });
expect(LOGO_MISSING).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(t(LOGO_MISSING)).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
setLang('en', { persist: false });
expect(t(LOGO_MISSING)).toBe('The selected logo is no longer available.');
});
});
describe('loadLogoAsset: Laden', () => {
it('mehrfarbiges Logo: das dekodierte Bild in Originalgröße, ohne Canvas', async () => {
const { loadLogoAsset } = await fresh();
const blob = file();
const decoded = bitmap(200, 100);
mocks.loadLogoBlob.mockResolvedValue(blob);
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBe(decoded);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.getLogo).toHaveBeenCalledWith('l1');
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'l1', fileId: 'l1' }));
expect(mocks.createImageBitmap).toHaveBeenCalledWith(blob);
expect(canvases).toHaveLength(0);
});
it('fehlende Zeile (gelöscht oder ausgeblendet für mich): null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('ausgeblendetes Logo (Hochlader und Admins können die Zeile lesen): trotzdem null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('Datei fehlt (404): null', async () => {
const { loadLogoAsset } = await fresh();
mocks.loadLogoBlob.mockRejectedValue(fileGone());
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404 }));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
const { loadLogoAsset } = await fresh();
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
mocks.loadLogoBlob.mockRejectedValue(failure);
await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure);
const offline = new TypeError('Failed to fetch');
mocks.getLogo.mockRejectedValue(offline);
await expect(loadLogoAsset('l2', 'light')).rejects.toBe(offline);
});
it('nicht lesbare Datei: null (das Logo ist nicht verfügbar)', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockRejectedValue(new DOMException('kaputt', 'InvalidStateError'));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(0, 0));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('mehr als 1 000 000 Pixel (nur eine veränderte Datei): null, genau 1 000 000 und die größte erlaubte Größe: ein Bild', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockResolvedValue(bitmap(1001, 1000));
await expect(loadLogoAsset('big', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(1000, 1000));
await expect(loadLogoAsset('limit', 'light')).resolves.toMatchObject({ w: 1000, h: 1000 });
mocks.createImageBitmap.mockResolvedValue(bitmap(720, 250));
await expect(loadLogoAsset('max', 'light')).resolves.toMatchObject({ w: 720, h: 250 });
});
it('zu großes einfarbiges Logo: null, es wird kein Canvas dafür angelegt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(bitmap(30000, 30000));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(canvases).toHaveLength(0);
});
});
describe('loadLogoAsset: einfarbige Logos', () => {
it.each([
['light', '#FFFFFF'],
['dark', '#111111'],
] as const)('Details %s: Canvas in Bildgröße, Bild zeichnen, dann source-in mit %s füllen', async (tone, color) => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', tone);
expect(canvases).toHaveLength(1);
const canvas = canvases[0];
expect(asset?.source).toBe(canvas);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect([canvas.width, canvas.height]).toEqual([200, 100]);
expect(canvas.getContext).toHaveBeenCalledWith('2d');
expect(canvas.calls.map((c) => c.name)).toEqual(['drawImage', 'fillRect']);
// erst das Bild (die Form kommt aus dem Alphakanal) …
expect(canvas.calls[0].args[0]).toBe(decoded);
expect(canvas.calls[0].globalCompositeOperation).toBe('source-over');
// … dann die Farbe nur dort, wo das Bild deckt
expect(canvas.calls[1]).toMatchObject({ args: [0, 0, 200, 100], globalCompositeOperation: 'source-in', fillStyle: color });
});
it('mehrfarbige Logos bleiben unverändert (kein Canvas, beide Tonarten dasselbe Bild)', async () => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.createImageBitmap.mockResolvedValue(decoded);
expect((await loadLogoAsset('l1', 'light'))?.source).toBe(decoded);
expect((await loadLogoAsset('l1', 'dark'))?.source).toBe(decoded);
expect(canvases).toHaveLength(0);
});
it('ohne 2D-Kontext wirft es (das ist kein fehlendes Logo)', async () => {
const { loadLogoAsset } = await fresh();
stubCanvas({ noContext: true });
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/);
});
});
describe('loadLogoAsset: Zwischenspeicher', () => {
it('gleiche (id, tone) lädt nur einmal, auch bei gleichzeitigen Aufrufen und später', async () => {
const { loadLogoAsset } = await fresh();
const a = loadLogoAsset('l1', 'light');
const b = loadLogoAsset('l1', 'light');
expect(b).toBe(a);
const first = await a;
expect(await loadLogoAsset('l1', 'light')).toBe(first);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(1);
expect(mocks.createImageBitmap).toHaveBeenCalledTimes(1);
});
it('eine andere Tonart ist ein eigenes Bild, die Zeile wird aber nur einmal geholt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const light = await loadLogoAsset('l1', 'light');
const dark = await loadLogoAsset('l1', 'dark');
expect(dark).not.toBe(light);
expect(canvases).toHaveLength(2);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlendes Logo (null) bleibt ebenfalls zwischengespeichert', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('forgetLogo: danach wird Zeile und Bild neu geladen', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
forgetLogo('l1');
mocks.getLogo.mockResolvedValue(null); // inzwischen gelöscht
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
expect(first).not.toBeNull();
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('forgetLogo vergisst nur dieses Logo', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
mocks.getLogo.mockImplementation(async (id: string) => logo({ id, fileId: id }));
const other = await loadLogoAsset('l2', 'light');
await loadLogoAsset('l1', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l2', 'light')).toBe(other);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
// l1 und l1.x sind verschiedene Logos: das Präfix darf nicht auch l1.x erwischen
mocks.getLogo.mockClear();
const dotted = await loadLogoAsset('l1.x', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l1.x', 'light')).toBe(dotted);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlgeschlagener Ladevorgang bleibt nicht im Cache (Zeile und Bild)', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow('Failed to fetch');
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
mocks.loadLogoBlob.mockRejectedValueOnce(Object.assign(new Error('Bad gateway'), { code: 502 }));
await expect(loadLogoAsset('l2', 'light')).rejects.toThrow('Bad gateway');
await expect(loadLogoAsset('l2', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
});
it('ein Fehler nach forgetLogo wirft den neuen Eintrag nicht aus dem Cache', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
let fail!: (e: Error) => void;
mocks.getLogo.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
const stale = loadLogoAsset('l1', 'light');
stale.catch(() => undefined);
forgetLogo('l1');
const current = await loadLogoAsset('l1', 'light');
fail(new Error('spät'));
await expect(stale).rejects.toThrow('spät');
expect(await loadLogoAsset('l1', 'light')).toBe(current);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('rememberLogo legt die Zeile in den Cache: getLogo wird nicht gebraucht', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ id: 'neu', fileId: 'neu' }));
await expect(loadLogoAsset('neu', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).not.toHaveBeenCalled();
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'neu', fileId: 'neu' }));
});
it('rememberLogo mit einem ausgeblendeten Logo: null', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('rememberLogo mit einem jetzt ausgeblendeten Logo verwirft schon geladene Bilder (ein gesperrtes Logo wird nicht mehr gezeichnet)', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
await expect(loadLogoAsset('l1', 'light')).resolves.not.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.not.toBeNull();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
});
it('rememberLogo mit einem sichtbaren Logo lässt geladene Bilder stehen', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
rememberLogo(logo({ likeCount: 7 }));
expect(await loadLogoAsset('l1', 'light')).toBe(first);
});
});
describe('loadLogoAsset: ohne createImageBitmap', () => {
class FakeImage {
static mode: 'load' | 'error' = 'load';
static size = { w: 200, h: 100 };
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
#src = '';
get src() {
return this.#src;
}
set src(value: string) {
this.#src = value;
this.naturalWidth = FakeImage.size.w;
this.naturalHeight = FakeImage.size.h;
queueMicrotask(() => (FakeImage.mode === 'load' ? this.onload?.() : this.onerror?.()));
}
}
const created: string[] = [];
const revoked: string[] = [];
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
beforeEach(() => {
FakeImage.mode = 'load';
FakeImage.size = { w: 200, h: 100 };
created.length = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => {
created.push(`blob:fake/${created.length + 1}`);
return created[created.length - 1];
});
URL.revokeObjectURL = vi.fn((url: string) => void revoked.push(url));
vi.stubGlobal('createImageBitmap', undefined);
vi.stubGlobal('Image', FakeImage);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
it('lädt das Logo über ein Image aus einer Object-URL und gibt die URL danach frei', async () => {
const { loadLogoAsset } = await fresh();
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBeInstanceOf(FakeImage);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.createImageBitmap).not.toHaveBeenCalled();
expect(created).toEqual(['blob:fake/1']);
expect(revoked).toEqual(['blob:fake/1']);
});
it('Ladefehler des Bildes: null, die URL ist trotzdem freigegeben', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.mode = 'error';
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(revoked).toEqual(['blob:fake/1']);
});
it('auch hier gilt die Pixelgrenze', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.size = { w: 2000, h: 1000 };
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
});
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const asset = await loadLogoAsset('l1', 'dark');
expect(asset?.source).toBe(canvases[0]);
expect(canvases[0].calls[0].args[0]).toBeInstanceOf(FakeImage);
expect(canvases[0].calls[1]).toMatchObject({ globalCompositeOperation: 'source-in', fillStyle: '#111111' });
});
});
+142
View File
@@ -0,0 +1,142 @@
// 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.
*/
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;
}