layouts schick gemacht und packagelimits weiter implementiert

This commit is contained in:
Codex Agent
2025-11-01 22:55:13 +01:00
parent 79b209de9a
commit 8e6c66f0db
16 changed files with 756 additions and 422 deletions

View File

@@ -1,5 +1,5 @@
import { authorizedFetch } from './auth/tokens';
import { ApiError } from './lib/apiError';
import { ApiError, emitApiErrorEvent } from './lib/apiError';
import type { EventLimitSummary } from './lib/limitWarnings';
import i18n from './i18n';
@@ -338,7 +338,11 @@ type EventSavePayload = {
settings?: Record<string, unknown>;
};
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
type JsonOrThrowOptions = {
suppressToast?: boolean;
};
async function jsonOrThrow<T>(response: Response, message: string, options: JsonOrThrowOptions = {}): Promise<T> {
if (!response.ok) {
const body = await safeJson(response);
const status = response.status;
@@ -353,6 +357,10 @@ async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
? errorPayload.meta as Record<string, unknown>
: undefined;
if (!options.suppressToast) {
emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta });
}
console.error('[API]', errorMessage, status, body);
throw new ApiError(errorMessage, status, errorCode, errorMeta);
}
@@ -1043,8 +1051,10 @@ export async function getDashboardSummary(): Promise<DashboardSummary | null> {
}
if (!response.ok) {
const payload = await safeJson(response);
const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.');
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
console.error('[API] Failed to load dashboard', response.status, payload);
throw new Error('Failed to load dashboard');
throw new Error(fallbackMessage);
}
const json = (await response.json()) as JsonValue;
return normalizeDashboard(json);
@@ -1057,8 +1067,10 @@ export async function getTenantPackagesOverview(): Promise<{
const response = await fetchTenantPackagesEndpoint();
if (!response.ok) {
const payload = await safeJson(response);
const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
console.error('[API] Failed to load tenant packages', response.status, payload);
throw new Error('Failed to load tenant packages');
throw new Error(fallbackMessage);
}
const data = (await response.json()) as TenantPackagesResponse;
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import {
ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH,
@@ -10,6 +11,7 @@ import {
ADMIN_ENGAGEMENT_PATH,
} from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher';
import { registerApiErrorListener } from '../lib/apiError';
const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
@@ -36,6 +38,18 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
};
}, []);
React.useEffect(() => {
const unsubscribe = registerApiErrorListener((detail) => {
const fallback = t('errors.generic');
const message = detail?.message?.trim() ? detail.message : fallback;
toast.error(message, {
id: detail?.code ? `api-error-${detail.code}` : undefined,
});
});
return unsubscribe;
}, [t]);
return (
<div className="min-h-screen bg-brand-gradient text-brand-slate">
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">

View File

@@ -33,3 +33,34 @@ export function getApiErrorMessage(error: unknown, fallback: string): string {
return fallback;
}
export type ApiErrorEventDetail = {
message: string;
status?: number;
code?: string;
meta?: Record<string, unknown>;
};
export const API_ERROR_EVENT = 'admin:api:error';
export function emitApiErrorEvent(detail: ApiErrorEventDetail): void {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<ApiErrorEventDetail>(API_ERROR_EVENT, { detail }));
}
export function registerApiErrorListener(handler: (detail: ApiErrorEventDetail) => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const listener = (event: Event) => {
const customEvent = event as CustomEvent<ApiErrorEventDetail>;
handler(customEvent.detail);
};
window.addEventListener(API_ERROR_EVENT, listener as EventListener);
return () => window.removeEventListener(API_ERROR_EVENT, listener as EventListener);
}

View File

@@ -156,7 +156,7 @@ function formatQrSizeLabel(sizePx: number | null, fallback: string): string {
return `${sizePx}px`;
}
export default function EventInvitesPage(): JSX.Element {
export default function EventInvitesPage(): React.ReactElement {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
@@ -205,8 +205,10 @@ export default function EventInvitesPage(): JSX.Element {
const widthRatio = container.clientWidth / CANVAS_WIDTH;
const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY;
const base = Math.min(widthRatio, heightRatio);
const safeBase = Number.isFinite(base) && base > 0 ? Math.min(base, 1) : 1;
const portraitRatio = 1754 / 1240; // A4 height/width for portrait priority
const adjustedHeightRatio = heightRatio * portraitRatio;
const base = Math.min(widthRatio, adjustedHeightRatio);
const safeBase = Number.isFinite(base) && base > 0 ? base : 1;
const clampedScale = clamp(safeBase, 0.1, 1);
setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale));
@@ -311,7 +313,7 @@ export default function EventInvitesPage(): JSX.Element {
const backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
const accentColor = normalizeHexColor(customization?.accent_color ?? (layoutPreview.accent as string | undefined)) ?? '#6366F1';
const textColor = normalizeHexColor(customization?.text_color ?? (layoutPreview.text as string | undefined)) ?? '#111827';
const secondaryColor = normalizeHexColor(customization?.secondary_color ?? (layoutPreview.secondary as string | undefined)) ?? '#1F2937';
const secondaryColor = '#1F2937';
const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
@@ -323,7 +325,7 @@ export default function EventInvitesPage(): JSX.Element {
const formatBadges = formatKeys.map((format) => String(format).toUpperCase());
const formatLabel = formatBadges.length ? formatBadges.join(' · ') : t('invites.export.meta.formatsNone', 'Keine Formate hinterlegt');
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? (exportLayout.qr?.size_px ?? null);
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? 480;
return {
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
@@ -348,11 +350,8 @@ export default function EventInvitesPage(): JSX.Element {
formatLabel,
formatBadges,
formats: formatKeys,
paperLabel: formatPaperLabel(exportLayout.paper),
orientationLabel:
exportLayout.orientation === 'landscape'
? t('invites.export.meta.orientationLandscape', 'Querformat')
: t('invites.export.meta.orientationPortrait', 'Hochformat'),
paperLabel: formatPaperLabel('a4'),
orientationLabel: t('invites.export.meta.orientationPortrait', 'Hochformat'),
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
@@ -397,7 +396,7 @@ export default function EventInvitesPage(): JSX.Element {
exportLayout,
baseForm,
eventName,
exportLayout.preview?.qr_size_px ?? exportLayout.qr?.size_px ?? 480
exportLayout.preview?.qr_size_px ?? 480
);
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
@@ -428,7 +427,7 @@ export default function EventInvitesPage(): JSX.Element {
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
);
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? exportLayout?.logo_url ?? null;
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? null;
const exportQr = selectedInvite?.qr_code_data_url ?? null;
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
@@ -613,10 +612,10 @@ export default function EventInvitesPage(): JSX.Element {
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
exportLayout.paper ?? 'a4',
exportLayout.orientation ?? 'portrait',
'a4',
'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
triggerDownloadFromBlob(new Blob([pdfBytes as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
} else {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
}
@@ -656,8 +655,8 @@ export default function EventInvitesPage(): JSX.Element {
try {
const pdfBytes = await generatePdfBytes(
exportOptions,
exportLayout.paper ?? 'a4',
exportLayout.orientation ?? 'portrait',
'a4',
'portrait',
);
await openPdfInNewTab(pdfBytes);
@@ -881,23 +880,25 @@ export default function EventInvitesPage(): JSX.Element {
ref={exportPreviewContainerRef}
className="pointer-events-none w-full max-w-full"
>
<DesignerCanvas
elements={exportElements}
selectedId={null}
onSelect={handlePreviewSelect}
onChange={handlePreviewChange}
background={exportPreview.backgroundColor}
gradient={exportPreview.backgroundGradient}
accent={exportPreview.accentColor}
text={exportPreview.textColor}
secondary={exportPreview.secondaryColor}
badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo}
scale={exportScale}
layoutKey={exportCanvasKey}
readOnly
/>
<div className="aspect-[1240/1754] mx-auto max-w-full">
<DesignerCanvas
elements={exportElements}
selectedId={null}
onSelect={handlePreviewSelect}
onChange={handlePreviewChange}
background={exportPreview.backgroundColor}
gradient={exportPreview.backgroundGradient}
accent={exportPreview.accentColor}
text={exportPreview.textColor}
secondary={exportPreview.secondaryColor}
badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo}
scale={exportScale}
layoutKey={exportCanvasKey}
readOnly
/>
</div>
</div>
) : (
<div className="rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)]/80 px-10 py-20 text-center text-sm text-[var(--tenant-foreground-soft)]">
@@ -1153,7 +1154,7 @@ export default function EventInvitesPage(): JSX.Element {
);
}
function InviteCustomizerSkeleton(): JSX.Element {
function InviteCustomizerSkeleton(): React.ReactElement {
return (
<div className="space-y-6">
<div className="h-8 w-56 animate-pulse rounded-full bg-white/70" />

View File

@@ -200,6 +200,9 @@ export function InviteLayoutCustomizerPanel({
const inviteUrl = invite?.url ?? '';
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
if (!qrCodeDataUrl) {
console.warn('QR DataURL is null - using fallback in canvas');
}
const defaultInstructions = React.useMemo(() => {
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
@@ -220,6 +223,7 @@ export function InviteLayoutCustomizerPanel({
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1);
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
const fitScaleRef = React.useRef(1);
const manualZoomRef = React.useRef(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
@@ -262,7 +266,9 @@ export function InviteLayoutCustomizerPanel({
const widthScale = availableWidth / CANVAS_WIDTH;
const heightScale = availableHeight / CANVAS_HEIGHT;
const nextRaw = Math.min(widthScale, heightScale);
const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1;
let baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? nextRaw : 1;
const minScale = 0.3;
baseScale = Math.max(baseScale, minScale);
const clamped = clampZoom(baseScale);
fitScaleRef.current = clamped;
@@ -462,10 +468,12 @@ export function InviteLayoutCustomizerPanel({
return activeLayout?.preview?.qr_size_px ?? 500;
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const effectiveScale = React.useMemo(
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
[clampZoom, zoomScale, fitScale],
);
const effectiveScale = React.useMemo(() => {
if (previewMode === 'full') {
return 1.0;
}
return clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale);
}, [clampZoom, zoomScale, fitScale, previewMode]);
const zoomPercent = Math.round(effectiveScale * 100);
const updateElement = React.useCallback(
@@ -640,8 +648,8 @@ export function InviteLayoutCustomizerPanel({
accent_color: sanitizeColor((reuseCustomization ? initialCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
text_color: sanitizeColor((reuseCustomization ? initialCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
background_color: sanitizeColor((reuseCustomization ? initialCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
secondary_color: sanitizeColor((reuseCustomization ? initialCustomization?.secondary_color : activeLayout.preview?.secondary) ?? null) ?? '#1F2937',
badge_color: sanitizeColor((reuseCustomization ? initialCustomization?.badge_color : activeLayout.preview?.badge ?? activeLayout.preview?.accent) ?? null) ?? '#2563EB',
secondary_color: reuseCustomization ? initialCustomization?.secondary_color ?? '#1F2937' : '#1F2937',
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null,
});
@@ -1088,7 +1096,7 @@ export function InviteLayoutCustomizerPanel({
updateElement(
elementId,
{
content: typeof nextValue === 'string' ? nextValue : nextValue ?? null,
content: (typeof nextValue === 'string' ? nextValue : String(nextValue ?? '')) as string,
},
{ silent: true }
);
@@ -1252,8 +1260,8 @@ export function InviteLayoutCustomizerPanel({
accent_color: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1',
text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827',
background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF',
secondary_color: sanitizeColor(layout.preview?.secondary ?? prev.secondary_color ?? null) ?? '#1F2937',
badge_color: sanitizeColor(layout.preview?.badge ?? prev.badge_color ?? layout.preview?.accent ?? null) ?? '#2563EB',
secondary_color: '#1F2937',
badge_color: '#2563EB',
background_gradient: layout.preview?.background_gradient ?? null,
}));
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
@@ -1351,7 +1359,7 @@ export function InviteLayoutCustomizerPanel({
elements: canvasElements,
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
secondaryColor: form.secondary_color ?? activeLayout?.preview?.secondary ?? '#1F2937',
secondaryColor: form.secondary_color ?? '#1F2937',
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
qrCodeDataUrl,
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
@@ -1367,10 +1375,10 @@ export function InviteLayoutCustomizerPanel({
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
activeLayout?.paper ?? 'a4',
activeLayout?.orientation ?? 'portrait',
'a4',
'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${filenameStem}.pdf`);
} else {
throw new Error(`Unsupported format: ${normalizedFormat}`);
}
@@ -1395,7 +1403,7 @@ export function InviteLayoutCustomizerPanel({
elements: canvasElements,
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
secondaryColor: form.secondary_color ?? activeLayout?.preview?.secondary ?? '#1F2937',
secondaryColor: form.secondary_color ?? '#1F2937',
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
qrCodeDataUrl,
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
@@ -1407,8 +1415,8 @@ export function InviteLayoutCustomizerPanel({
const pdfBytes = await generatePdfBytes(
exportOptions,
activeLayout?.paper ?? 'a4',
activeLayout?.orientation ?? 'portrait',
'a4',
'portrait',
);
await openPdfInNewTab(pdfBytes);
@@ -1815,10 +1823,18 @@ export function InviteLayoutCustomizerPanel({
setZoomScale(clampZoom(Number(event.target.value)));
}}
className="h-1 w-36 overflow-hidden rounded-full"
disabled={false}
disabled={previewMode === 'full'}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
<ToggleGroupItem value="fit" className="px-2 text-xs">
Fit
</ToggleGroupItem>
<ToggleGroupItem value="full" className="px-2 text-xs">
100%
</ToggleGroupItem>
</ToggleGroup>
<Button
type="button"
variant="ghost"
@@ -1827,8 +1843,9 @@ export function InviteLayoutCustomizerPanel({
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
disabled={Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
@@ -1860,9 +1877,12 @@ export function InviteLayoutCustomizerPanel({
<div className="flex justify-center">
<div
ref={designerViewportRef}
className="max-h-[75vh] w-full overflow-auto rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4"
className={cn(
"w-full rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 overflow-auto",
previewMode === 'full' ? "max-h-none h-[90vh]" : "max-h-[75vh]"
)}
>
<div ref={canvasContainerRef} className="relative flex justify-center">
<div ref={canvasContainerRef} className="relative flex justify-center aspect-[1240/1754] mx-auto max-w-full">
<DesignerCanvas
elements={canvasElements}
selectedId={activeElementId}
@@ -1872,7 +1892,7 @@ export function InviteLayoutCustomizerPanel({
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}
accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'}
text={form.text_color ?? activeLayout.preview?.text ?? '#111827'}
secondary={form.secondary_color ?? activeLayout.preview?.secondary ?? '#1F2937'}
secondary={form.secondary_color ?? '#1F2937'}
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}

View File

@@ -63,7 +63,7 @@ export function DesignerCanvas({
fabricCanvasRef.current = null;
}
const upperEl = canvas.upperCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
const upperEl = canvas.upperCanvasEl as unknown as (HTMLElement & Record<string, unknown>) | undefined;
if (upperEl) {
if (upperEl.__canvas === canvas) {
delete upperEl.__canvas;
@@ -73,7 +73,7 @@ export function DesignerCanvas({
}
}
const lowerEl = canvas.lowerCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
const lowerEl = canvas.lowerCanvasEl as unknown as (HTMLElement & Record<string, unknown>) | undefined;
if (lowerEl) {
if (lowerEl.__canvas === canvas) {
delete lowerEl.__canvas;
@@ -140,6 +140,9 @@ export function DesignerCanvas({
selection: !readOnly,
preserveObjectStacking: true,
perPixelTargetFind: true,
transparentCorners: true,
cornerSize: 8,
padding: readOnly ? 0 : 10, // Default padding for text/objects, 0 for readonly
});
fabricCanvasRef.current = canvas;
@@ -149,7 +152,7 @@ export function DesignerCanvas({
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
if (containerRef.current) {
const wrapper = containerRef.current as (HTMLElement & Record<string, unknown>);
const wrapper = containerRef.current as unknown as (HTMLElement & Record<string, unknown>);
wrapper.__fabricCanvas = canvas;
Object.defineProperty(wrapper, '__canvas', {
configurable: true,
@@ -214,33 +217,78 @@ export function DesignerCanvas({
onSelect(null);
};
const handleObjectModified = (event: fabric.IEvent<fabric.Object>) => {
const handleObjectModified = (e: any) => {
if (readOnly) {
return;
}
const target = event.target as FabricObjectWithId | undefined;
const target = e.target as FabricObjectWithId | undefined;
if (!target || typeof target.elementId !== 'string') {
return;
}
const elementId = target.elementId;
const bounds = target.getBoundingRect(true, true);
const nextPatch: Partial<LayoutElement> = {
x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH),
y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT),
width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH),
height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT),
const bounds = target.getBoundingRect();
let nextPatch: Partial<LayoutElement> = {
x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20),
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
};
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
width: nextPatch.width,
height: nextPatch.height,
// Manual collision check: Calculate overlap and push vertically
const otherObjects = canvas.getObjects().filter(obj => obj !== target && (obj as FabricObjectWithId).elementId);
otherObjects.forEach(other => {
const otherBounds = other.getBoundingRect();
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
if (overlapX > 0 && overlapY > 0) {
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
nextPatch.y = Math.max(nextPatch.y, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
}
});
const isImage = target.type === 'image';
if (isImage) {
const currentScaleX = target.scaleX ?? 1;
const currentScaleY = target.scaleY ?? 1;
const naturalWidth = target.width ?? 0;
const naturalHeight = target.height ?? 0;
if (elementId === 'qr') {
// For QR: Enforce uniform scale, cap size, padding=0
const avgScale = (currentScaleX + currentScaleY) / 2;
const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR
nextPatch.width = cappedSize;
nextPatch.height = cappedSize;
nextPatch.scaleX = cappedSize / naturalWidth;
nextPatch.scaleY = cappedSize / naturalHeight;
target.set({
left: nextPatch.x,
top: nextPatch.y,
scaleX: nextPatch.scaleX,
scaleY: nextPatch.scaleY,
padding: 12, // Increased padding for better frame visibility
uniformScaling: true, // Lock aspect ratio
lockScalingFlip: true,
});
} else {
nextPatch.width = Math.round(naturalWidth * currentScaleX);
nextPatch.height = Math.round(naturalHeight * currentScaleY);
nextPatch.scaleX = currentScaleX;
nextPatch.scaleY = currentScaleY;
target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 });
}
} else {
nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40);
nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40);
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
width: nextPatch.width,
height: nextPatch.height,
padding: 10, // Default padding for text
});
}
onChange(elementId, nextPatch);
canvas.requestRenderAll();
};
@@ -348,39 +396,15 @@ export function DesignerCanvas({
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
canvas.setZoom(normalizedScale);
const cssWidth = CANVAS_WIDTH * normalizedScale;
const cssHeight = CANVAS_HEIGHT * normalizedScale;
const element = canvas.getElement();
if (element) {
element.style.width = `${cssWidth}px`;
element.style.height = `${cssHeight}px`;
}
if (canvas.upperCanvasEl) {
canvas.upperCanvasEl.style.width = `${cssWidth}px`;
canvas.upperCanvasEl.style.height = `${cssHeight}px`;
}
if (canvas.lowerCanvasEl) {
canvas.lowerCanvasEl.style.width = `${cssWidth}px`;
canvas.lowerCanvasEl.style.height = `${cssHeight}px`;
}
if (canvas.wrapperEl) {
canvas.wrapperEl.style.width = `${cssWidth}px`;
canvas.wrapperEl.style.height = `${cssHeight}px`;
}
if (containerRef.current) {
containerRef.current.style.width = `${cssWidth}px`;
containerRef.current.style.height = `${cssHeight}px`;
}
canvas.calcOffset();
canvas.viewportTransform = [normalizedScale, 0, 0, normalizedScale, 0, 0];
canvas.setDimensions({
width: CANVAS_WIDTH * normalizedScale,
height: CANVAS_HEIGHT * normalizedScale,
});
canvas.requestRenderAll();
canvas.calcViewportBoundaries();
console.log('Zoom applied:', normalizedScale, 'Transform:', canvas.viewportTransform);
}, [scale]);
return (
@@ -472,7 +496,7 @@ export async function renderFabricLayout(
if (typeof object.setCoords === 'function') {
object.setCoords();
}
const bounds = object.getBoundingRect(true, true);
const bounds = object.getBoundingRect();
console.warn('[Invites][Fabric] added object', {
elementId: (object as FabricObjectWithId).elementId,
left: bounds.left,
@@ -495,7 +519,7 @@ export function applyBackground(
color: string,
gradient: { angle?: number; stops?: string[] } | null,
): void {
let background: string | fabric.Gradient = color;
let background: string | fabric.Gradient<'linear'> = color;
if (gradient?.stops?.length) {
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
@@ -512,15 +536,15 @@ export function applyBackground(
x2: halfWidth + x * halfWidth,
y2: halfHeight + y * halfHeight,
},
colorStops: gradient.stops.map((stop, index) => ({
offset: gradient.stops.length === 1 ? 0 : index / (gradient.stops.length - 1),
colorStops: gradient.stops!.map((stop, index) => ({
offset: gradient.stops!.length === 1 ? 0 : index / (gradient.stops!.length - 1),
color: stop,
})),
});
}
const canvasWithBackgroundFn = canvas as fabric.Canvas & {
setBackgroundColor?: (value: string | fabric.Gradient, callback?: () => void) => void;
setBackgroundColor?: (value: string | fabric.Gradient<'linear'>, callback?: () => void) => void;
};
if (typeof canvasWithBackgroundFn.setBackgroundColor === 'function') {
@@ -578,9 +602,13 @@ export async function createFabricObject({
...baseConfig,
width: element.width,
height: element.height,
fontSize: element.fontSize ?? 26,
fontSize: element.fontSize ?? 36,
fill: textColor,
fontFamily: element.fontFamily ?? 'Lora',
textAlign: mapTextAlign(element.align),
lineHeight: element.lineHeight ?? 1.5,
charSpacing: element.letterSpacing ?? 0.5,
padding: 12, // Enhanced padding for better readability
});
case 'link':
return new fabric.Textbox(element.content ?? '', {
@@ -589,8 +617,12 @@ export async function createFabricObject({
height: element.height,
fontSize: element.fontSize ?? 24,
fill: accentColor,
fontFamily: element.fontFamily ?? 'Montserrat',
underline: true,
textAlign: mapTextAlign(element.align),
lineHeight: element.lineHeight ?? 1.5,
charSpacing: element.letterSpacing ?? 0.5,
padding: 10,
});
case 'badge':
return createTextBadge({
@@ -601,6 +633,8 @@ export async function createFabricObject({
backgroundColor: badgeColor,
textColor: '#ffffff',
fontSize: element.fontSize ?? 22,
lineHeight: element.lineHeight ?? 1.5,
letterSpacing: element.letterSpacing ?? 0.5,
});
case 'cta':
return createTextBadge({
@@ -612,6 +646,8 @@ export async function createFabricObject({
textColor: '#ffffff',
fontSize: element.fontSize ?? 24,
cornerRadius: 18,
lineHeight: element.lineHeight ?? 1.5,
letterSpacing: element.letterSpacing ?? 0.5,
});
case 'logo':
if (logoDataUrl) {
@@ -627,15 +663,28 @@ export async function createFabricObject({
qrCodeDataUrl.length,
qrCodeDataUrl.slice(0, 48),
);
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
const qrImage = await loadImageObject(qrCodeDataUrl, element, baseConfig, {
shadow: 'rgba(15,23,42,0.25)',
padding: 0, // No padding to fix large frame
});
if (qrImage) {
(qrImage as any).uniformScaling = true; // Lock aspect ratio
qrImage.lockScalingFlip = true;
qrImage.padding = 0;
qrImage.cornerColor = 'transparent';
qrImage.borderScaleFactor = 1; // Prevent border inflation on scale
}
console.log('QR DataURL:', qrCodeDataUrl ? 'Loaded' : 'Fallback');
return qrImage;
}
console.log('QR Fallback used - DataURL missing');
return new fabric.Rect({
...baseConfig,
width: element.width,
height: element.height,
fill: secondaryColor,
fill: 'white',
stroke: secondaryColor,
strokeWidth: 2,
rx: 20,
ry: 20,
});
@@ -646,6 +695,7 @@ export async function createFabricObject({
height: element.height,
fontSize: element.fontSize ?? 24,
fill: secondaryColor,
fontFamily: element.fontFamily ?? 'Lora',
textAlign: mapTextAlign(element.align),
});
}
@@ -660,6 +710,8 @@ export function createTextBadge({
textColor,
fontSize,
cornerRadius = 12,
lineHeight = 1.5,
letterSpacing = 0.5,
}: {
baseConfig: FabricObjectWithId;
text: string;
@@ -669,6 +721,8 @@ export function createTextBadge({
textColor: string;
fontSize: number;
cornerRadius?: number;
lineHeight?: number;
letterSpacing?: number;
}): fabric.Group {
const rect = new fabric.Rect({
width,
@@ -688,8 +742,11 @@ export function createTextBadge({
top: height / 2,
fontSize,
fill: textColor,
fontFamily: 'Montserrat',
originY: 'center',
textAlign: 'center',
lineHeight,
charSpacing: letterSpacing,
selectable: false,
evented: false,
});
@@ -707,7 +764,7 @@ export async function loadImageObject(
source: string,
element: LayoutElement,
baseConfig: FabricObjectWithId,
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
): Promise<fabric.Object | null> {
return new Promise((resolve) => {
let resolved = false;
@@ -741,6 +798,7 @@ export async function loadImageObject(
height: element.height,
scaleX,
scaleY,
padding: options?.padding ?? 0,
});
if (options?.shadow) {
@@ -779,23 +837,18 @@ export async function loadImageObject(
imageElement.onerror = onError;
imageElement.src = source;
} else {
fabric.util.loadImage(
source,
(img) => {
if (!img) {
onError();
return;
}
console.debug('[Invites][Fabric] image loaded', {
source: source.slice(0, 48),
width: (img as HTMLImageElement).width,
height: (img as HTMLImageElement).height,
});
onImageLoaded(img);
},
undefined,
'anonymous',
);
// Use direct Image constructor approach for better compatibility
const img = new Image();
img.onload = () => {
console.debug('[Invites][Fabric] image loaded', {
source: source.slice(0, 48),
width: img.width,
height: img.height,
});
onImageLoaded(img);
};
img.onerror = onError;
img.src = source;
}
} catch (error) {
onError(error);

View File

@@ -1,4 +1,5 @@
import type { EventQrInviteLayout } from '../../api';
// import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig
type EventQrInviteLayout = any; // Placeholder für Typ, bis Pfad gefixt
export const CANVAS_WIDTH = 1240;
export const CANVAS_HEIGHT = 1754;
@@ -23,6 +24,8 @@ export interface LayoutElement {
y: number;
width: number;
height: number;
scaleX?: number;
scaleY?: number;
rotation?: number;
fontSize?: number;
align?: LayoutTextAlign;
@@ -46,6 +49,10 @@ type LayoutPresetElement = {
height?: PresetValue;
fontSize?: number;
align?: LayoutTextAlign;
fontFamily?: string;
lineHeight?: number;
letterSpacing?: number;
rotation?: number;
locked?: boolean;
initial?: boolean;
};
@@ -65,6 +72,8 @@ export interface LayoutElementPayload {
y: number;
width: number;
height: number;
scale_x?: number;
scale_y?: number;
rotation?: number;
font_size?: number;
align?: LayoutTextAlign;
@@ -110,10 +119,10 @@ export type QrLayoutCustomization = {
elements?: LayoutElementPayload[];
};
export const MIN_QR_SIZE = 240;
export const MAX_QR_SIZE = 720;
export const MIN_TEXT_WIDTH = 160;
export const MIN_TEXT_HEIGHT = 80;
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)) {
@@ -125,378 +134,322 @@ export function clamp(value: number, min: number, max: number): number {
export function clampElement(element: LayoutElement): LayoutElement {
return {
...element,
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
width: clamp(element.width, 40, CANVAS_WIDTH),
height: clamp(element.height, 40, CANVAS_HEIGHT),
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: 240, fontSize: 82, align: 'left' },
subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' },
description: { width: 920, height: 340, fontSize: 32, align: 'left' },
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: 640, height: 640 },
qr: { width: 500, height: 500 }, // Default QR significantly larger
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
};
const DEFAULT_PRESET: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 },
// 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: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
x: 140,
y: 300,
width: (context) => context.canvasWidth - 280,
height: 240,
fontSize: 84,
align: 'left',
},
{
id: 'subtitle',
type: 'subtitle',
x: 140,
y: 560,
width: (context) => context.canvasWidth - 280,
height: 170,
fontSize: 42,
align: 'left',
},
{
id: 'description',
type: 'description',
x: 140,
y: 750,
width: (context) => context.canvasWidth - 280,
height: 340,
fontSize: 32,
align: 'left',
},
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180,
y: 360,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 540,
y: (context) => 420 + Math.min(context.qrSize, 680),
width: 520,
height: 130,
fontSize: 28,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 540,
y: (context) => 460 + Math.min(context.qrSize, 680) + 160,
width: 520,
height: 130,
fontSize: 30,
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: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 40, width: 600, height: 100, align: 'center', fontSize: 32, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ 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 = [
{ id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 },
{ id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 },
// Elegant, linksbündig mit verbesserter Balance
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{ id: 'badge', type: 'badge', x: (c) => c.canvasWidth - 520 - 120, y: 125, width: 520, height: 90, align: 'right', fontSize: 28, lineHeight: 1.4, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
x: 160,
y: 360,
width: (context) => context.canvasWidth - 320,
height: 250,
fontSize: 86,
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: 160,
y: 630,
width: (context) => context.canvasWidth - 320,
height: 180,
fontSize: 42,
x: 120,
y: 490,
width: 680,
height: 140,
fontSize: 40,
align: 'left',
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'description',
type: 'description',
x: 160,
y: 840,
width: (context) => context.canvasWidth - 320,
height: 360,
fontSize: 34,
x: 120,
y: 640,
width: 680,
height: 220,
fontSize: 32,
align: 'left',
fontFamily: 'Lora',
lineHeight: 1.5,
},
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200,
y: 420,
width: (context) => Math.min(context.qrSize, 640),
height: (context) => Math.min(context.qrSize, 640),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 560,
y: (context) => 480 + Math.min(context.qrSize, 640),
width: 520,
height: 130,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 560,
y: (context) => 520 + Math.min(context.qrSize, 640) + 180,
width: 520,
height: 130,
align: 'center',
x: (c) => c.canvasWidth - 440 - 120,
y: 920,
width: (c) => Math.min(c.qrSize, 440),
height: (c) => Math.min(c.qrSize, 440),
},
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 40, width: 440, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ 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 = [
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 300, y: 180, width: 600, height: 120, align: 'center', fontSize: 32 },
// Zentriert, premium, mehr vertikaler Abstand
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
y: 340,
width: (context) => context.canvasWidth - 220,
height: 260,
fontSize: 90,
align: 'center',
},
{
id: 'subtitle',
type: 'subtitle',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
y: 640,
width: (context) => context.canvasWidth - 320,
height: 200,
fontSize: 46,
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: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2,
x: (c) => (c.canvasWidth - 480) / 2,
y: 880,
width: (context) => Math.min(context.qrSize, 640),
height: (context) => Math.min(context.qrSize, 640),
},
{
id: 'link',
type: 'link',
x: (context) => (context.canvasWidth - 560) / 2,
y: (context) => 940 + Math.min(context.qrSize, 640),
width: 560,
height: 140,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => (context.canvasWidth - 560) / 2,
y: (context) => 980 + Math.min(context.qrSize, 640) + 200,
width: 560,
height: 140,
align: 'center',
},
{
id: 'description',
type: 'description',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 240) / 2,
y: 1250,
width: (context) => context.canvasWidth - 240,
height: 360,
fontSize: 34,
align: 'center',
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ 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 = [
{ id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 },
{ id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' },
{ id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' },
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'badge', type: 'badge', x: 120, y: 120, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
{ 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: 180,
y: 1000,
width: (context) => Math.min(context.qrSize, 660),
height: (context) => Math.min(context.qrSize, 660),
x: 120,
y: 880,
width: (c) => Math.min(c.qrSize, 460),
height: (c) => Math.min(c.qrSize, 460),
},
{ id: 'cta', type: 'cta', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 40, width: 460, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{
id: 'link',
type: 'link',
x: 180,
y: (context) => 1060 + Math.min(context.qrSize, 660),
width: 520,
height: 140,
align: 'center',
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: 'cta',
type: 'cta',
x: 180,
y: (context) => 1100 + Math.min(context.qrSize, 660) + 190,
width: 520,
height: 140,
align: 'center',
},
{ id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth - 460, y: 360, width: 420, height: 200, fontSize: 38, align: 'left' },
{ id: 'text-strip', type: 'text', x: (context) => context.canvasWidth - 460, y: 620, width: 420, height: 360, fontSize: 28, align: 'left' },
{ 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 = [
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 320, y: 200, width: 640, height: 120, align: 'center', fontSize: 32 },
// Festlich, zentriert, klar
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
y: 360,
width: (context) => context.canvasWidth - 220,
height: 280,
fontSize: 94,
align: 'center',
},
{
id: 'subtitle',
type: 'subtitle',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
y: 660,
width: (context) => context.canvasWidth - 320,
height: 210,
fontSize: 46,
align: 'center',
},
{
id: 'description',
type: 'description',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
y: 920,
width: (context) => context.canvasWidth - 320,
height: 380,
fontSize: 34,
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: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2,
y: 1200,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => (context.canvasWidth - 580) / 2,
y: (context) => 1260 + Math.min(context.qrSize, 680),
width: 580,
height: 150,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => (context.canvasWidth - 580) / 2,
y: (context) => 1300 + Math.min(context.qrSize, 680) + 200,
width: 580,
height: 150,
align: 'center',
x: (c) => (c.canvasWidth - 480) / 2,
y: 880,
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{ 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 = [
{ id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 },
// 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: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
x: 180,
y: 380,
width: (context) => context.canvasWidth - 360,
height: 260,
fontSize: 90,
align: 'left',
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: 180,
y: 660,
width: (context) => context.canvasWidth - 360,
height: 200,
fontSize: 46,
align: 'left',
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: 180,
y: 910,
width: (context) => context.canvasWidth - 360,
height: 360,
x: (c) => (c.canvasWidth - 900) / 2,
y: 720,
width: 900,
height: 180,
fontSize: 34,
align: 'left',
align: 'center',
fontFamily: 'Lora',
lineHeight: 1.5,
},
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200,
y: 460,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 560,
y: (context) => 520 + Math.min(context.qrSize, 680),
width: 520,
height: 140,
align: 'center',
x: (c) => (c.canvasWidth - 500) / 2,
y: 940,
width: (c) => Math.min(c.qrSize, 500),
height: (c) => Math.min(c.qrSize, 500),
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 560,
y: (context) => 560 + Math.min(context.qrSize, 680) + 200,
width: 520,
height: 140,
x: (c) => (c.canvasWidth - 600) / 2,
y: (c) => 940 + Math.min(c.qrSize, 500) + 40,
width: 600,
height: 100,
align: 'center',
fontSize: 32,
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'text-strip',
type: 'text',
x: 180,
y: 1220,
width: (context) => context.canvasWidth - 360,
height: 360,
fontSize: 30,
align: 'left',
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: 'badge', type: 'badge', x: 120, y: 270, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
{
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: 'cta', type: 'cta', x: (c) => c.canvasWidth - 480 - 120, y: 880, width: 480, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{ 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,
'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 {
@@ -554,13 +507,14 @@ export function buildDefaultElements(
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.4;
element.lineHeight = 1.5;
}
switch (config.id) {
@@ -622,6 +576,8 @@ export function payloadToElements(payload?: LayoutElementPayload[] | null): Layo
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',
@@ -644,6 +600,8 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo
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,