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

+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;
}