Files
AirCardIOS/web/src/card/config.ts
T
ClaudeandClaude Opus 5.5 0be5abd954 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>
2026-10-04 20:32:45 +00:00

122 lines
3.8 KiB
TypeScript

// Kartenkonfiguration: was der Nutzer im Editor einstellt und in der Tabelle `cards` speichert.
// Reine Daten, kein DOM, keine Appwrite-Aufrufe.
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
const TONES = ['light', 'dark'] as const;
const BRANDS = ['aircards-symbol', 'aircards-text', 'none'] as const;
export type TemplateId = (typeof TEMPLATES)[number];
export type DetailsTone = (typeof TONES)[number];
export type BrandId = (typeof BRANDS)[number];
/**
* Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. Steht hier, damit die reine
* Konfiguration die ID des Community-Logos prüfen kann, ohne Appwrite zu laden (account/cards.ts reicht sie weiter).
*/
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
export const MIN_ZOOM = 1;
export const MAX_ZOOM = 4;
/** x/y = Versatz der Bildmitte zur Kartenmitte in Karten-px, zoom >= 1 relativ zum Cover-Maßstab. */
export interface Crop {
x: number;
y: number;
zoom: number;
}
export interface CardConfig {
v: 1;
template: TemplateId;
/** ID des Community-Logos unten rechts; null = keins. */
logo: string | null;
details: DetailsTone;
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
brand: BrandId;
crop: Crop;
}
export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
export const DEFAULT_CONFIG: CardConfig = {
v: 1,
template: 'signature',
logo: null,
details: 'light',
brand: 'aircards-symbol',
crop: { ...DEFAULT_CROP },
};
export const TONE_COLOR: Record<DetailsTone, string> = {
light: '#FFFFFF',
dark: '#111111',
};
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
/** Die ID eines Community-Logos; alles, was keine Appwrite-ID sein kann, ergibt null (kein Logo). */
function parseLogo(value: unknown): string | null {
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseCrop(value: unknown): Crop {
const c = isRecord(value) ? value : {};
const zoom = finite(c.zoom, DEFAULT_CROP.zoom);
return {
x: finite(c.x, DEFAULT_CROP.x),
y: finite(c.y, DEFAULT_CROP.y),
zoom: Math.min(Math.max(zoom, MIN_ZOOM), MAX_ZOOM),
};
}
/**
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
* Ältere Karten kennen noch das Feld `network` (Kartennetzwerk) und das Apple-Logo: `network` wird ignoriert,
* `brand: 'apple'` fällt auf den Standard.
*/
export function parseConfig(json: string | null | undefined): CardConfig {
let raw: unknown = null;
if (json) {
try {
raw = JSON.parse(json);
} catch {
raw = null;
}
}
const r = isRecord(raw) ? raw : {};
return {
v: 1,
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
logo: parseLogo(r.logo),
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
crop: parseCrop(r.crop),
};
}
/** Schreibt nur die bekannten Felder (feste Reihenfolge), damit die Spalte klein bleibt. */
export function serializeConfig(c: CardConfig): string {
return JSON.stringify({
v: 1,
template: c.template,
logo: c.logo,
details: c.details,
brand: c.brand,
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
});
}