- 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>
122 lines
3.8 KiB
TypeScript
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 },
|
|
});
|
|
}
|