// 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 = { light: '#FFFFFF', dark: '#111111', }; function pick(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 { 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 }, }); }