feat(card): Konfiguration, Geometrie und Bildausschnitt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:29:20 +00:00
1 parent 05a735993b
commit 1a4ed0d595
6 files changed
+858

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
// 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', 'apple', 'none'] as const;
const NETWORKS = [
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
'up-color', 'up-silver', 'up-white-frame', 'up-english',
'amex-blue', 'amex-outline', 'amex-select',
'jcb', 'discover',
] as const;
export type TemplateId = (typeof TEMPLATES)[number];
export type DetailsTone = (typeof TONES)[number];
export type BrandId = (typeof BRANDS)[number];
export type NetworkId = (typeof NETWORKS)[number];
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
/** 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;
network: NetworkId;
details: DetailsTone;
brand: BrandId;
crop: Crop;
}
export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
export const DEFAULT_CONFIG: CardConfig = {
v: 1,
template: 'signature',
network: 'visa-auto',
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;
}
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.
*/
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),
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
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,
network: c.network,
details: c.details,
brand: c.brand,
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
});
}