removed the old event admin components and pages

This commit is contained in:
Codex Agent
2025-12-12 13:38:06 +01:00
parent bbf8d4a0f4
commit 1719d96fed
85 changed files with 994 additions and 19981 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
export type BackgroundImageOption = {
id: string;
url: string;
label: string;
};
// Preload background assets from public/storage/layouts/backgrounds.
// Vite does not process the public directory, so we try a glob (for cases where assets are in src)
// and fall back to known public URLs.
const backgroundImports: Record<string, string> = {
...import.meta.glob('../../../../../public/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
eager: true,
as: 'url',
}),
...import.meta.glob('/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
eager: true,
as: 'url',
}),
};
const fallbackFiles = ['bg-blue-floral.png', 'bg-goldframe.png', 'gr-green-floral.png'];
const importedBackgrounds: BackgroundImageOption[] = Object.entries(backgroundImports).map(([path, url]) => {
const filename = path.split('/').pop() ?? path;
const id = filename.replace(/\.[^.]+$/, '');
return { id, url: url as string, label: filename };
});
const fallbackBackgrounds: BackgroundImageOption[] = fallbackFiles.map((filename) => ({
id: filename.replace(/\.[^.]+$/, ''),
url: `/storage/layouts/backgrounds/${filename}`,
label: filename,
}));
const merged = [...importedBackgrounds, ...fallbackBackgrounds];
export const preloadedBackgrounds: BackgroundImageOption[] = Array.from(
merged.reduce((map, item) => {
if (!map.has(item.id)) {
map.set(item.id, item);
}
return map;
}, new Map<string, BackgroundImageOption>()),
).map(([, value]) => value);

View File

@@ -0,0 +1,134 @@
import * as fabric from 'fabric';
import { PDFDocument } from 'pdf-lib';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './schema';
import { FabricRenderOptions, renderFabricLayout } from './DesignerCanvas';
const PDF_PAGE_SIZES: Record<string, { width: number; height: number }> = {
a4: { width: 595.28, height: 841.89 },
letter: { width: 612, height: 792 },
};
export async function withFabricCanvas<T>(
options: FabricRenderOptions,
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
): Promise<T> {
const canvasElement = document.createElement('canvas');
canvasElement.width = CANVAS_WIDTH;
canvasElement.height = CANVAS_HEIGHT;
const canvas = new fabric.Canvas(canvasElement, {
selection: false,
});
try {
await renderFabricLayout(canvas, {
...options,
readOnly: true,
});
return await handler(canvas, canvasElement);
} finally {
canvas.dispose();
canvasElement.remove();
}
}
export async function generatePngDataUrl(
options: FabricRenderOptions,
multiplier = 2,
): Promise<string> {
return withFabricCanvas(options, async (canvas) =>
canvas.toDataURL({ format: 'png', multiplier }),
);
}
export async function generatePdfBytes(
options: FabricRenderOptions,
paper: string,
orientation: string,
multiplier = 2,
): Promise<Uint8Array> {
const dataUrl = await generatePngDataUrl(options, multiplier);
return createPdfFromPng(dataUrl, paper, orientation);
}
export async function createPdfFromPng(
dataUrl: string,
paper: string,
orientation: string,
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.create();
const baseSize = PDF_PAGE_SIZES[paper.toLowerCase()] ?? PDF_PAGE_SIZES.a4;
const landscape = orientation === 'landscape';
const pageWidth = landscape ? baseSize.height : baseSize.width;
const pageHeight = landscape ? baseSize.width : baseSize.height;
const page = pdfDoc.addPage([pageWidth, pageHeight]);
const pngBytes = dataUrlToUint8Array(dataUrl);
const pngImage = await pdfDoc.embedPng(pngBytes);
const imageWidth = pngImage.width;
const imageHeight = pngImage.height;
const scale = Math.min(pageWidth / imageWidth, pageHeight / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
page.drawImage(pngImage, {
x: (pageWidth - drawWidth) / 2,
y: (pageHeight - drawHeight) / 2,
width: drawWidth,
height: drawHeight,
});
return pdfDoc.save();
}
export function triggerDownloadFromDataUrl(dataUrl: string, filename: string): Promise<void> {
return fetch(dataUrl)
.then((response) => response.blob())
.then((blob) => triggerDownloadFromBlob(blob, filename));
}
export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
const arrayBuffer = pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength) as ArrayBuffer;
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
if (!printWindow) {
URL.revokeObjectURL(blobUrl);
throw new Error('window-blocked');
}
printWindow.onload = () => {
try {
printWindow.focus();
printWindow.print();
} catch (error) {
console.error('[FabricExport] Browser print failed', error);
}
};
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function dataUrlToUint8Array(dataUrl: string): Uint8Array {
const [, base64] = dataUrl.split(',');
const decoded = atob(base64 ?? '');
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) {
bytes[index] = decoded.charCodeAt(index);
}
return bytes;
}

View File

@@ -0,0 +1,52 @@
export function sanitizeFilenameSegment(value: string | null | undefined, fallback = ''): string {
if (typeof value !== 'string') {
return fallback;
}
const normalized = value
.trim()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
const slug = normalized.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
return slug.length ? slug : fallback;
}
export function normalizeEventDateSegment(dateValue: string | null | undefined): string | null {
if (!dateValue) {
return null;
}
const trimmed = dateValue.trim();
if (!trimmed) {
return null;
}
const isoCandidate = trimmed.slice(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(isoCandidate)) {
return isoCandidate;
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString().slice(0, 10);
}
export function buildDownloadFilename(
parts: Array<string | null | undefined>,
extension: string,
fallback = 'download',
): string {
const sanitizedParts = parts
.map((part) => sanitizeFilenameSegment(part, ''))
.filter((segment) => segment.length > 0);
const base = sanitizedParts.length ? sanitizedParts.join('-') : fallback;
const cleanExtension = sanitizeFilenameSegment(extension, '').replace(/[^a-z0-9]/gi, '') || 'bin';
return `${base}.${cleanExtension.toLowerCase()}`;
}

View File

@@ -0,0 +1,615 @@
// @ts-nocheck
// import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig
type EventQrInviteLayout = {
id: string;
name?: string;
description?: string | null;
subtitle?: string | null;
preview?: {
background?: string | null;
background_gradient?: { angle?: number; stops?: string[] } | null;
accent?: string | null;
text?: string | null;
qr_size_px?: number | null;
} | null;
formats?: string[];
};
export const CANVAS_WIDTH = 1240;
export const CANVAS_HEIGHT = 1754;
export type LayoutElementType =
| 'qr'
| 'headline'
| 'subtitle'
| 'description'
| 'link'
| 'badge'
| 'logo'
| 'cta'
| 'text';
export type LayoutTextAlign = 'left' | 'center' | 'right';
export interface LayoutElement {
id: string;
type: LayoutElementType;
x: number;
y: number;
width: number;
height: number;
scaleX?: number;
scaleY?: number;
rotation?: number;
fontSize?: number;
align?: LayoutTextAlign;
content?: string | null;
fontFamily?: string | null;
letterSpacing?: number;
lineHeight?: number;
fill?: string | null;
locked?: boolean;
initial?: boolean;
}
type PresetValue = number | ((context: LayoutPresetContext) => number);
type LayoutPresetElement = {
id: string;
type: LayoutElementType;
x: PresetValue;
y: PresetValue;
width?: PresetValue;
height?: PresetValue;
fontSize?: number;
align?: LayoutTextAlign;
fontFamily?: string;
lineHeight?: number;
letterSpacing?: number;
rotation?: number;
locked?: boolean;
initial?: boolean;
};
type LayoutPreset = LayoutPresetElement[];
interface LayoutPresetContext {
qrSize: number;
canvasWidth: number;
canvasHeight: number;
}
export interface LayoutElementPayload {
id: string;
type: LayoutElementType;
x: number;
y: number;
width: number;
height: number;
scale_x?: number;
scale_y?: number;
rotation?: number;
font_size?: number;
align?: LayoutTextAlign;
content?: string | null;
font_family?: string | null;
letter_spacing?: number;
line_height?: number;
fill?: string | null;
locked?: boolean;
initial?: boolean;
}
export interface LayoutSerializationContext {
form: QrLayoutCustomization;
eventName: string;
inviteUrl: string;
instructions: string[];
qrSize: number;
badgeFallback: string;
logoUrl: string | null;
}
export type QrLayoutCustomization = {
layout_id?: string;
headline?: string;
subtitle?: string;
description?: string;
badge_label?: string;
instructions_heading?: string;
instructions?: string[];
link_heading?: string;
link_label?: string;
cta_label?: string;
accent_color?: string;
text_color?: string;
background_color?: string;
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
background_image?: string | null;
logo_data_url?: string | null;
logo_url?: string | null;
mode?: 'standard' | 'advanced';
elements?: LayoutElementPayload[];
};
export const MIN_QR_SIZE = 400;
export const MAX_QR_SIZE = 800;
export const MIN_TEXT_WIDTH = 250;
export const MIN_TEXT_HEIGHT = 120;
export function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
export function clampElement(element: LayoutElement): LayoutElement {
return {
...element,
x: clamp(element.x, 20, CANVAS_WIDTH - element.width - 20),
y: clamp(element.y, 20, CANVAS_HEIGHT - element.height - 20),
width: clamp(element.width, 40, CANVAS_WIDTH - 40),
height: clamp(element.height, 40, CANVAS_HEIGHT - 40),
scaleX: clamp(element.scaleX ?? 1, 0.1, 5),
scaleY: clamp(element.scaleY ?? 1, 0.1, 5),
};
}
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
headline: { width: 900, height: 200, fontSize: 90, align: 'left' },
subtitle: { width: 760, height: 160, fontSize: 44, align: 'left' },
description: { width: 920, height: 320, fontSize: 36, align: 'left' },
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
logo: { width: 320, height: 220, align: 'center' },
cta: { width: 520, height: 130, fontSize: 28, align: 'center' },
qr: { width: 500, height: 500 }, // Default QR significantly larger
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
};
const DEFAULT_PRESET: LayoutPreset = [
// Basierend auf dem zentrierten, modernen "confetti-bash"-Layout
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
{
id: 'headline',
type: 'headline',
x: (c) => (c.canvasWidth - 1000) / 2,
y: 350,
width: 1000,
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Playfair Display',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
];
const evergreenVowsPreset: LayoutPreset = [
// Elegant, linksbündig mit verbesserter Balance
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{
id: 'headline',
type: 'headline',
x: 120,
y: 280,
width: (context) => context.canvasWidth - 240,
height: 200,
fontSize: 95,
align: 'left',
fontFamily: 'Playfair Display',
lineHeight: 1.3,
},
{
id: 'subtitle',
type: 'subtitle',
x: 120,
y: 490,
width: 680,
height: 140,
fontSize: 40,
align: 'left',
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'description',
type: 'description',
x: 120,
y: 640,
width: 680,
height: 220,
fontSize: 32,
align: 'left',
fontFamily: 'Lora',
lineHeight: 1.5,
},
{
id: 'qr',
type: 'qr',
x: (c) => c.canvasWidth - 440 - 120,
y: 920,
width: (c) => Math.min(c.qrSize, 440),
height: (c) => Math.min(c.qrSize, 440),
},
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const midnightGalaPreset: LayoutPreset = [
// Zentriert, premium, mehr vertikaler Abstand
{
id: 'headline',
type: 'headline',
x: (c) => (c.canvasWidth - 1100) / 2,
y: 240,
width: 1100,
height: 220,
fontSize: 105,
align: 'center',
fontFamily: 'Playfair Display',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{
id: 'qr',
type: 'qr',
x: (c) => (c.canvasWidth - 480) / 2,
y: 880,
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const gardenBrunchPreset: LayoutPreset = [
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
{
id: 'qr',
type: 'qr',
x: 120,
y: 880,
width: (c) => Math.min(c.qrSize, 460),
height: (c) => Math.min(c.qrSize, 460),
},
{
id: 'description',
type: 'description',
x: (c) => c.canvasWidth - 600 - 120,
y: 620,
width: 600,
height: 400,
fontSize: 32,
align: 'left',
fontFamily: 'Lora',
lineHeight: 1.6,
},
{ id: 'link', type: 'link', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 160, width: 460, height: 80, align: 'center', fontSize: 24 },
];
const sparklerSoireePreset: LayoutPreset = [
// Festlich, zentriert, klar
{
id: 'headline',
type: 'headline',
x: (c) => (c.canvasWidth - 1000) / 2,
y: 240,
width: 1000,
height: 220,
fontSize: 100,
align: 'center',
fontFamily: 'Playfair Display',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat' },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{
id: 'qr',
type: 'qr',
x: (c) => (c.canvasWidth - 480) / 2,
y: 880,
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const confettiBashPreset: LayoutPreset = [
// Zentriertes, luftiges Layout mit klarer Hierarchie.
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
{
id: 'headline',
type: 'headline',
x: (c) => (c.canvasWidth - 1000) / 2,
y: 350,
width: 1000,
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Playfair Display',
lineHeight: 1.3,
},
{
id: 'subtitle',
type: 'subtitle',
x: (c) => (c.canvasWidth - 800) / 2,
y: 580,
width: 800,
height: 120,
fontSize: 42,
align: 'center',
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'description',
type: 'description',
x: (c) => (c.canvasWidth - 900) / 2,
y: 720,
width: 900,
height: 180,
fontSize: 34,
align: 'center',
fontFamily: 'Lora',
lineHeight: 1.5,
},
{
id: 'qr',
type: 'qr',
x: (c) => (c.canvasWidth - 500) / 2,
y: 940,
width: (c) => Math.min(c.qrSize, 500),
height: (c) => Math.min(c.qrSize, 500),
},
{
id: 'link',
type: 'link',
x: (c) => (c.canvasWidth - 700) / 2,
y: (c) => 940 + Math.min(c.qrSize, 500) + 160,
width: 700,
height: 80,
align: 'center',
fontSize: 26,
fontFamily: 'Montserrat',
lineHeight: 1.5,
},
];
const balancedModernPreset: LayoutPreset = [
// Wahrhaftig balanciert: Text links, QR rechts
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{
id: 'headline',
type: 'headline',
x: 120,
y: 380,
width: 620,
height: 380,
fontSize: 100,
align: 'left',
fontFamily: 'Playfair Display',
lineHeight: 1.3,
},
{
id: 'subtitle',
type: 'subtitle',
x: 120,
y: 770,
width: 620,
height: 140,
fontSize: 42,
align: 'left',
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'description',
type: 'description',
x: 120,
y: 920,
width: 620,
height: 300,
fontSize: 34,
align: 'left',
fontFamily: 'Lora',
lineHeight: 1.5,
},
{
id: 'qr',
type: 'qr',
x: (c) => c.canvasWidth - 480 - 120,
y: 380,
width: 480,
height: 480,
},
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
'default': DEFAULT_PRESET,
'evergreen-vows': evergreenVowsPreset,
'midnight-gala': midnightGalaPreset,
'garden-brunch': gardenBrunchPreset,
'sparkler-soiree': sparklerSoireePreset,
'confetti-bash': confettiBashPreset,
'balanced-modern': balancedModernPreset, // New preset: QR right, text left, logo top
};
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
if (typeof value === 'function') {
const resolved = value(context);
return typeof resolved === 'number' ? resolved : fallback;
}
if (typeof value === 'number') {
return value;
}
return fallback;
}
export function buildDefaultElements(
layout: EventQrInviteLayout,
form: QrLayoutCustomization,
eventName: string,
qrSize: number
): LayoutElement[] {
const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE);
const context: LayoutPresetContext = {
qrSize: size,
canvasWidth: CANVAS_WIDTH,
canvasHeight: CANVAS_HEIGHT,
};
const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET;
const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's";
const instructionsList = Array.isArray(form.instructions) && form.instructions.length
? form.instructions
: (layout.instructions ?? []);
const baseContent: Record<string, string | null> = {
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? layout.subtitle ?? '',
description: form.description ?? layout.description ?? '',
link: form.link_label ?? '',
instructions_heading: instructionsHeading,
instructions_text: instructionsList[0] ?? null,
};
const elements = preset.map((config) => {
const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 };
const widthFallback = config.type === 'qr' ? size : typeStyle.width;
const heightFallback = config.type === 'qr' ? size : typeStyle.height;
const element: LayoutElement = {
id: config.id,
type: config.type,
x: resolvePresetValue(config.x, context, 0),
y: resolvePresetValue(config.y, context, 0),
width: resolvePresetValue(config.width, context, widthFallback),
height: resolvePresetValue(config.height, context, heightFallback),
fontSize: config.fontSize ?? typeStyle.fontSize,
align: config.align ?? typeStyle.align ?? 'left',
fontFamily: config.fontFamily ?? 'Lora',
content: null,
locked: config.locked ?? typeStyle.locked ?? false,
initial: config.initial ?? true,
};
if (config.type === 'description') {
element.lineHeight = 1.5;
}
switch (config.id) {
case 'headline':
element.content = baseContent.headline;
break;
case 'subtitle':
element.content = baseContent.subtitle;
break;
case 'description':
element.content = baseContent.description;
break;
case 'link':
element.content = baseContent.link;
break;
case 'text-strip':
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
break;
case 'logo':
element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null;
break;
default:
if (config.type === 'text') {
element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.';
}
break;
}
if (config.type === 'qr') {
element.locked = false;
}
const clamped = clampElement(element);
return {
...clamped,
initial: element.initial ?? true,
};
});
return elements;
}
export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] {
if (!Array.isArray(payload)) {
return [];
}
return payload.map((entry) =>
clampElement({
id: entry.id,
type: entry.type,
x: Number(entry.x ?? 0),
y: Number(entry.y ?? 0),
width: Number(entry.width ?? 100),
height: Number(entry.height ?? 100),
scaleX: Number(entry.scale_x ?? 1),
scaleY: Number(entry.scale_y ?? 1),
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
align: entry.align ?? 'left',
content: entry.content ?? null,
fontFamily: entry.font_family ?? null,
letterSpacing: entry.letter_spacing ?? undefined,
lineHeight: entry.line_height ?? undefined,
fill: entry.fill ?? null,
locked: Boolean(entry.locked),
initial: Boolean(entry.initial),
})
);
}
export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] {
return elements.map((element) => ({
id: element.id,
type: element.type,
x: element.x,
y: element.y,
width: element.width,
height: element.height,
scale_x: element.scaleX ?? 1,
scale_y: element.scaleY ?? 1,
rotation: element.rotation ?? 0,
font_size: element.fontSize,
align: element.align,
content: element.content ?? null,
font_family: element.fontFamily ?? null,
letter_spacing: element.letterSpacing,
line_height: element.lineHeight,
fill: element.fill ?? null,
locked: element.locked ?? false,
initial: element.initial ?? false,
}));
}
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
const seen = new Set<string>();
return elements
.filter((element) => {
if (!element.id) {
return false;
}
if (seen.has(element.id)) {
return false;
}
seen.add(element.id);
return true;
})
.map(clampElement);
}