login-seiten neu designt, homepage neu designt. "so funktioniert's" ergänzt und Demo-Seite hinzugefügt. Paketansicht in mobile verbessert.
This commit is contained in:
@@ -54,6 +54,7 @@ import {
|
||||
triggerDownloadFromBlob,
|
||||
triggerDownloadFromDataUrl,
|
||||
} from './invite-layout/export-utils';
|
||||
import { buildDownloadFilename, normalizeEventDateSegment } from './invite-layout/fileNames';
|
||||
|
||||
export type { QrLayoutCustomization } from './invite-layout/schema';
|
||||
|
||||
@@ -182,6 +183,7 @@ function serializeElements(elements: LayoutElement[], context: LayoutSerializati
|
||||
type InviteLayoutCustomizerPanelProps = {
|
||||
invite: EventQrInvite | null;
|
||||
eventName: string;
|
||||
eventDate: string | null;
|
||||
saving: boolean;
|
||||
resetting: boolean;
|
||||
onSave: (customization: QrLayoutCustomization) => Promise<void>;
|
||||
@@ -199,6 +201,7 @@ const ZOOM_STEP = 0.05;
|
||||
export function InviteLayoutCustomizerPanel({
|
||||
invite,
|
||||
eventName,
|
||||
eventDate,
|
||||
saving,
|
||||
resetting,
|
||||
onSave,
|
||||
@@ -1391,7 +1394,12 @@ export function InviteLayoutCustomizerPanel({
|
||||
}
|
||||
|
||||
const normalizedFormat = format.toLowerCase();
|
||||
const filenameStem = `${invite.token || 'invite'}-${normalizedFormat}`;
|
||||
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
||||
const filename = buildDownloadFilename(
|
||||
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment],
|
||||
normalizedFormat,
|
||||
'einladungslayout',
|
||||
);
|
||||
setDownloadBusy(normalizedFormat);
|
||||
setError(null);
|
||||
|
||||
@@ -1412,14 +1420,14 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
if (normalizedFormat === 'png') {
|
||||
const dataUrl = await generatePngDataUrl(exportOptions);
|
||||
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
|
||||
await triggerDownloadFromDataUrl(dataUrl, filename);
|
||||
} else if (normalizedFormat === 'pdf') {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
|
||||
} else {
|
||||
throw new Error(`Unsupported format: ${normalizedFormat}`);
|
||||
}
|
||||
@@ -1509,34 +1517,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (formRef.current) {
|
||||
if (typeof formRef.current.requestSubmit === 'function') {
|
||||
formRef.current.requestSubmit();
|
||||
} else {
|
||||
formRef.current.submit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
|
||||
disabled={saving || resetting}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
@@ -1845,7 +1827,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
<div ref={actionsSentinelRef} className="h-1 w-full" />
|
||||
|
||||
@@ -239,7 +239,7 @@ export function DesignerCanvas({
|
||||
const elementId = target.elementId;
|
||||
|
||||
const bounds = target.getBoundingRect();
|
||||
let nextPatch: Partial<LayoutElement> = {
|
||||
const 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),
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user