verbesserung von benachrichtungen und warnungen an nutzer abgeschlossen. layout editor nun auf gutem stand.

This commit is contained in:
Codex Agent
2025-11-02 11:11:13 +01:00
parent 8e6c66f0db
commit 792b5dfe8b
32 changed files with 1292 additions and 149 deletions

View File

@@ -16,6 +16,7 @@ import {
Package as PackageIcon,
Loader2,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -205,6 +206,23 @@ export default function DashboardPage() {
[primaryEventLimits, limitTranslate],
);
const shownToastsRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
limitWarnings.forEach((warning) => {
const toastKey = `${warning.id}-${warning.message}`;
if (shownToastsRef.current.has(toastKey)) {
return;
}
shownToastsRef.current.add(toastKey);
toast(warning.message, {
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
id: toastKey,
});
});
}, [limitWarnings]);
const limitScopeLabels = React.useMemo(
() => ({
photos: tc('limits.photosTitle'),

View File

@@ -18,6 +18,7 @@ import {
Sparkles,
Users,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -216,6 +217,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
[event?.limits, tCommon],
);
const shownWarningToasts = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
limitWarnings.forEach((warning) => {
const id = `${warning.id}-${warning.message}`;
if (shownWarningToasts.current.has(id)) {
return;
}
shownWarningToasts.current.add(id);
toast(warning.message, {
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
id,
});
});
}, [limitWarnings]);
return (
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
{error && (

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import toast from 'react-hot-toast';
import { useQuery } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -203,6 +204,23 @@ export default function EventFormPage() {
return buildLimitWarnings(loadedEvent?.limits, tLimits);
}, [isEdit, loadedEvent?.limits, tLimits]);
const shownToastRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
limitWarnings.forEach((warning) => {
const key = `${warning.id}-${warning.message}`;
if (shownToastRef.current.has(key)) {
return;
}
shownToastRef.current.add(key);
toast(warning.message, {
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
id: key,
});
});
}, [limitWarnings]);
const limitScopeLabels = React.useMemo(() => ({
photos: tLimits('photosTitle'),
guests: tLimits('guestsTitle'),

View File

@@ -622,6 +622,8 @@ export function InviteLayoutCustomizerPanel({
if (!invite || !activeLayout) {
setForm({});
setInstructions([]);
commitElements(() => [], { silent: true });
resetHistory([]);
return;
}
@@ -635,7 +637,7 @@ export function InviteLayoutCustomizerPanel({
setInstructions(baseInstructions);
setForm({
const newForm: QrLayoutCustomization = {
layout_id: activeLayout.id,
headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName,
subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
@@ -652,53 +654,35 @@ export function InviteLayoutCustomizerPanel({
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,
});
mode: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.mode : 'standard',
elements: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.elements : undefined,
};
setForm(newForm);
setError(null);
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
React.useEffect(() => {
if (!activeLayout) {
const cleared: LayoutElement[] = [];
commitElements(() => cleared, { silent: true });
resetHistory(cleared);
return;
}
const isCustomizedAdvanced = newForm.mode === 'advanced' && Array.isArray(newForm.elements) && newForm.elements.length > 0;
const layoutKey = activeLayout.id ?? '__default';
const inviteKey = invite?.id ?? null;
if (prevInviteRef.current !== inviteKey) {
initializedLayoutsRef.current = {};
prevInviteRef.current = inviteKey;
if (isCustomizedAdvanced) {
const initialElements = normalizeElements(payloadToElements(newForm.elements));
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, newForm, eventName, activeLayoutQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
}
if (!initializedLayoutsRef.current[layoutKey]) {
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) {
const initialElements = normalizeElements(payloadToElements(initialCustomization.elements));
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, formStateRef.current, eventName, activeLayoutQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
}
initializedLayoutsRef.current[layoutKey] = true;
return;
}
if (historyIndexRef.current === -1 && elements.length > 0) {
resetHistory(cloneElements(elements));
}
}, [
setActiveElementId(null);
}, [
activeLayout,
invite?.id,
initialCustomization,
defaultInstructions,
eventName,
inviteUrl,
t,
activeLayoutQrSize,
initialCustomization?.mode,
initialCustomization?.elements,
commitElements,
resetHistory,
elements,
cloneElements,
eventName,
]);
React.useEffect(() => {
@@ -756,6 +740,17 @@ export function InviteLayoutCustomizerPanel({
hasCustomization: Boolean(initialCustomization?.elements?.length),
});
// Erweiterter Log für Duplikate-Check
const idCounts = base.reduce((acc, e) => {
acc[e.id] = (acc[e.id] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const duplicates = Object.entries(idCounts).filter(([_, count]) => count > 1);
if (duplicates.length > 0) {
console.warn('[Invites][CanvasElements] Duplicates detected in base', { duplicates, baseIds: base.map(e => ({ id: e.id, type: e.type, y: e.y })) });
}
console.debug('[Invites][CanvasElements] Base IDs overview', base.map(e => ({ id: e.id, type: e.type, y: e.y })));
const boundContent: Record<string, string | null> = {
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? '',
@@ -783,7 +778,7 @@ export function InviteLayoutCustomizerPanel({
};
});
}, [
activeLayout,
activeLayout?.id,
elements,
form.headline,
form.subtitle,
@@ -794,7 +789,6 @@ export function InviteLayoutCustomizerPanel({
eventName,
inviteUrl,
t,
activeLayout,
activeLayoutQrSize,
]);
@@ -1254,21 +1248,6 @@ export function InviteLayoutCustomizerPanel({
function handleLayoutSelect(layout: EventQrInviteLayout) {
setSelectedLayoutId(layout.id);
updateForm('layout_id', layout.id);
setForm((prev) => ({
...prev,
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: '#1F2937',
badge_color: '#2563EB',
background_gradient: layout.preview?.background_gradient ?? null,
}));
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
const defaults = buildDefaultElements(layout, formStateRef.current, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
setActiveElementId(null);
}
function handleInstructionChange(index: number, value: string) {

View File

@@ -450,7 +450,7 @@ export async function renderFabricLayout(
} = options;
canvas.discardActiveObject();
canvas.getObjects().forEach((object) => canvas.remove(object));
canvas.clear();
applyBackground(canvas, backgroundColor, backgroundGradient);
@@ -461,6 +461,7 @@ export async function renderFabricLayout(
readOnly,
});
const abortController = new AbortController();
const objectPromises = elements.map((element) =>
createFabricObject({
element,
@@ -471,10 +472,11 @@ export async function renderFabricLayout(
qrCodeDataUrl,
logoDataUrl,
readOnly,
}),
}, abortController.signal),
);
const fabricObjects = await Promise.all(objectPromises);
abortController.abort(); // Abort any pending loads
console.debug('[Invites][Fabric] resolved objects', {
count: fabricObjects.length,
nulls: fabricObjects.filter((obj) => !obj).length,
@@ -765,8 +767,9 @@ export async function loadImageObject(
element: LayoutElement,
baseConfig: FabricObjectWithId,
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
abortSignal?: AbortSignal,
): Promise<fabric.Object | null> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
let resolved = false;
const resolveSafely = (value: fabric.Object | null) => {
if (resolved) {
@@ -779,7 +782,7 @@ export async function loadImageObject(
const isDataUrl = source.startsWith('data:');
const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => {
if (!img) {
if (!img || resolved) {
console.warn('[Invites][Fabric] image load returned empty', { source });
resolveSafely(null);
return;
@@ -819,14 +822,26 @@ export async function loadImageObject(
};
const onError = (error?: unknown) => {
if (resolved) return;
console.warn('[Invites][Fabric] failed to load image', source, error);
resolveSafely(null);
};
const abortHandler = () => {
if (resolved) return;
console.debug('[Invites][Fabric] Image load aborted', { source });
resolveSafely(null);
};
if (abortSignal) {
abortSignal.addEventListener('abort', abortHandler);
}
try {
if (isDataUrl) {
const imageElement = new Image();
imageElement.onload = () => {
if (resolved) return;
console.debug('[Invites][Fabric] image loaded (data-url)', {
source: source.slice(0, 48),
width: imageElement.naturalWidth,
@@ -840,6 +855,7 @@ export async function loadImageObject(
// Use direct Image constructor approach for better compatibility
const img = new Image();
img.onload = () => {
if (resolved) return;
console.debug('[Invites][Fabric] image loaded', {
source: source.slice(0, 48),
width: img.width,
@@ -854,7 +870,17 @@ export async function loadImageObject(
onError(error);
}
window.setTimeout(() => resolveSafely(null), 3000);
const timeoutId = window.setTimeout(() => {
if (resolved) return;
resolveSafely(null);
}, 3000);
return () => {
clearTimeout(timeoutId);
if (abortSignal) {
abortSignal.removeEventListener('abort', abortHandler);
}
};
});
}