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:
Codex Agent
2025-11-03 11:47:19 +01:00
parent 073b51e2d5
commit 20eda6b4f8
23 changed files with 2481 additions and 587 deletions

View File

@@ -1,21 +1,23 @@
{
"login": {
"title": "Tenant-Admin",
"badge": "Fotospiel Tenant Admin",
"hero_title": "Event-Steuerung, die sich wie Zuhause anfühlt.",
"hero_subtitle": "Wechsle mühelos zwischen Mandanten, behalte Live-Uploads im Blick und teile elegante Einladungen alles in einer ruhigen Oberfläche.",
"title": "Event-Admin",
"badge": "Fotospiel Event Admin",
"hero_tagline": "Kontrolle behalten, entspannt bleiben",
"hero_title": "Das Cockpit für dein Fotospiel Event",
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.",
"features": [
"Gestalte QR-Einladungen und druckfertige Layouts in wenigen Klicks passend zu eurer Marke.",
"Organisiere Aufgaben, Emotionen und Sammlungen für jeden Eventtyp ohne Excel-Chaos.",
"Bleib am Eventtag souverän mit Dashboards, Live-Statistiken und sofortiger Moderation."
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
],
"lead": "Die Anmeldung erfolgt über unseren sicheren OAuth-Login und bringt dich direkt wieder zurück.",
"panel_copy": "Melde dich mit deinen Fotospiel-Admin-Zugangsdaten an. Wir schützen dein Konto mit OAuth 2.1 und mandantenbewussten Berechtigungen.",
"lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
"panel_title": "Melde dich an",
"panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
"cta": "Mit Fotospiel-Login fortfahren",
"loading": "Bitte warten …",
"oauth_error_title": "Login aktuell nicht möglich",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
"support": "Du brauchst Zugriff? Wende dich an den Tenant-Inhaber oder schreibe an support@fotospiel.de wir helfen gern weiter.",
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
"appearance_label": "Darstellung"
}
}

View File

@@ -1,21 +1,23 @@
{
"login": {
"title": "Tenant Admin",
"badge": "Fotospiel Tenant Admin",
"hero_title": "Event control that feels at home.",
"hero_subtitle": "Switch between tenants, monitor live uploads, and share beautiful invites — all in one calm workspace.",
"title": "Event Admin",
"badge": "Fotospiel Event Admin",
"hero_tagline": "Stay in control, stay relaxed",
"hero_title": "Your cockpit for every Fotospiel event",
"hero_subtitle": "Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.",
"features": [
"Design QR invites and print-ready layouts that match your brand in minutes.",
"Coordinate tasks, emotions, and achievements for every event flow.",
"Stay confident on event day with dashboards, live stats, and instant moderation."
"Monitor uploads in real time and archive highlights effortlessly.",
"Create invites with personalized QR codes and share them instantly.",
"Run tasks, emotions, and slideshows right from the event dashboard."
],
"lead": "You will be redirected to our secure OAuth login and come right back afterwards.",
"panel_copy": "Sign in with your Fotospiel admin credentials to continue. We secure your account with OAuth 2.1 and tenant-aware permissions.",
"lead": "Use our secure OAuth login and land directly in the event dashboard.",
"panel_title": "Sign in",
"panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
"cta": "Continue with Fotospiel login",
"loading": "Signing you in …",
"oauth_error_title": "Login not possible right now",
"oauth_error": "Sign-in failed: {{message}}",
"support": "Need access? Contact your tenant owner or email support@fotospiel.de — we're happy to help.",
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
"appearance_label": "Appearance"
}
}

View File

@@ -30,6 +30,7 @@ import {
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import {
CANVAS_HEIGHT,
@@ -252,6 +253,7 @@ export default function EventInvitesPage(): React.ReactElement {
const event = state.event;
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
const eventDate = event?.event_date ?? null;
const selectedInvite = React.useMemo(
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
@@ -587,7 +589,13 @@ export default function EventInvitesPage(): React.ReactElement {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = `${selectedInvite.token || 'invite'}-qr.png`;
const eventDateSegment = normalizeEventDateSegment(eventDate);
const downloadName = buildDownloadFilename(
['QR Code fuer', eventName, eventDateSegment],
'png',
'qr-code',
);
link.download = downloadName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -596,7 +604,7 @@ export default function EventInvitesPage(): React.ReactElement {
console.error('[Invites] QR download failed', error);
setExportError(t('invites.export.qr.error', 'QR-Code konnte nicht gespeichert werden.'));
}
}, [selectedInvite, t]);
}, [selectedInvite, eventName, eventDate, t]);
const handleExportDownload = React.useCallback(
async (format: string) => {
@@ -609,6 +617,13 @@ export default function EventInvitesPage(): React.ReactElement {
setExportDownloadBusy(busyKey);
setExportError(null);
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
@@ -623,19 +638,17 @@ export default function EventInvitesPage(): React.ReactElement {
selectedId: null,
} as const;
const filenameStem = `${selectedInvite.token || 'invite'}-${exportLayout.id}`;
try {
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 as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
} else {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
}
@@ -646,7 +659,7 @@ export default function EventInvitesPage(): React.ReactElement {
setExportDownloadBusy(null);
}
},
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, eventName, eventDate, t]
);
const handleExportPrint = React.useCallback(
@@ -809,6 +822,7 @@ export default function EventInvitesPage(): React.ReactElement {
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
eventDate={eventDate}
saving={customizerSaving}
resetting={customizerResetting}
onSave={handleSaveCustomization}

View File

@@ -53,108 +53,149 @@ export default function LoginPage(): JSX.Element {
}));
}, [t]);
const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed');
const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event');
const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.');
const panelTitle = t('login.panel_title', t('login.title', 'Event Admin'));
const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.');
const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.');
const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.");
const isLoading = status === 'loading';
return (
<div className="relative min-h-screen overflow-hidden bg-[var(--brand-navy)] text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,var(--brand-rose-soft)_0%,rgba(3,7,18,0.65)_55%,rgba(15,76,117,0.9)_100%)] opacity-95" />
<div className="pointer-events-none absolute inset-y-0 right-[-25%] w-[55%] bg-[radial-gradient(circle_at_center,var(--brand-sky)_0%,rgba(255,255,255,0)_70%)] opacity-40" />
<div className="pointer-events-none absolute inset-y-0 left-[-20%] w-[45%] bg-[radial-gradient(circle_at_center,var(--brand-rose)_0%,rgba(255,255,255,0)_65%)] opacity-35" />
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
<div className="relative z-10 flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 pt-10">
<div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 pt-10 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
<AppLogoIcon className="h-7 w-7 text-white" />
</span>
<div>
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge')}</p>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge', 'Fotospiel Event Admin')}</p>
<p className="text-lg font-semibold">Fotospiel</p>
</div>
</div>
<AppearanceToggleDropdown />
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-6 pb-16 pt-12">
<div className="grid flex-1 gap-12 lg:grid-cols-[0.95fr_1.05fr]" data-testid="tenant-login-layout">
<section className="order-2 space-y-10 lg:order-1">
<div className="space-y-5">
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1 text-sm font-medium text-white/80 backdrop-blur">
<Sparkles className="h-4 w-4 text-[var(--brand-gold)]" />
{t('login.badge')}
</span>
<h1 className="text-4xl font-semibold leading-tight sm:text-5xl">
{t('login.hero_title')}
</h1>
<p className="max-w-xl text-base text-white/80 sm:text-lg">
{t('login.hero_subtitle')}
</p>
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pb-16 pt-12 sm:px-6 lg:px-8">
<div className="mb-10 space-y-5 text-center md:hidden">
<span className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
{heroTagline}
</span>
<h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
<p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
<div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
<section className="relative hidden h-full flex-col justify-between gap-10 overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 md:flex">
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute -inset-16 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.5),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
</div>
{featureList.length ? (
<div className="grid gap-4 sm:grid-cols-2">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-${index}`}
className="group relative overflow-hidden rounded-2xl border border-white/15 bg-white/10 p-5 shadow-lg shadow-black/5 backdrop-blur transition hover:border-white/35"
>
<Icon className="mb-3 h-5 w-5 text-[var(--brand-gold)] transition group-hover:text-white" />
<p className="text-sm text-white/90">{text}</p>
</div>
))}
<div className="relative z-10 flex flex-col gap-6">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
<Sparkles className="h-4 w-4" aria-hidden />
<span className="font-sans-marketing">{heroTagline}</span>
</div>
) : null}
<p className="flex items-center gap-2 text-sm text-white/70">
<ArrowRight className="h-4 w-4" />
{t('login.lead')}
<div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
<p className="text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
{featureList.length ? (
<ul className="space-y-4">
{featureList.map(({ text, Icon }, index) => (
<li key={`login-feature-desktop-${index}`} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="space-y-1">
<p className="text-sm font-semibold tracking-tight sm:text-base">{text}</p>
</span>
</li>
))}
</ul>
) : null}
</div>
<p className="relative z-10 flex items-center gap-2 text-xs font-medium text-white/75">
<ArrowRight className="h-4 w-4" aria-hidden />
{leadCopy}
</p>
</section>
<section className="order-1 lg:order-2">
<div className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-6 scale-95 rounded-3xl bg-white/20 opacity-50 blur-2xl" />
<div className="relative overflow-hidden rounded-3xl border border-white/20 bg-white/90 p-10 text-slate-900 shadow-2xl shadow-black/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 dark:text-slate-50">
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{t('login.title')}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('login.panel_copy')}</p>
</div>
{oauthError ? (
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
</Alert>
) : null}
<Button
size="lg"
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--brand-rose)] via-[var(--brand-gold)] to-[var(--brand-sky)] px-8 py-3 text-base font-semibold text-slate-900 shadow-lg shadow-rose-400/30 transition hover:opacity-90 focus-visible:ring-4 focus-visible:ring-brand-rose/40 dark:text-slate-900"
disabled={isLoading}
onClick={() => login(redirectTarget)}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{t('login.loading')}
</>
) : (
<>
{t('login.cta')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-300">
{t('login.support')}
</p>
<section className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-4 scale-95 rounded-3xl bg-white/20 opacity-45 blur-2xl" aria-hidden />
<div className="relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/15 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
<div className="space-y-3">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200/50 bg-rose-50/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500/80 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200/80">
{t('login.badge', 'Fotospiel Event Admin')}
</span>
<div className="space-y-1">
<h2 className="text-2xl font-semibold">{panelTitle}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
</div>
</div>
{oauthError ? (
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
</Alert>
) : null}
<Button
size="lg"
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
disabled={isLoading}
onClick={() => login(redirectTarget)}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{t('login.loading', 'Signing you in …')}
</>
) : (
<>
{t('login.cta', 'Continue with Fotospiel login')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<div className="space-y-2 text-xs leading-relaxed text-slate-500 dark:text-slate-300">
<p>{leadCopy}</p>
<p>{supportCopy}</p>
</div>
</div>
</section>
</div>
{featureList.length ? (
<div className="mt-10 grid gap-4 md:hidden">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-mobile-${index}`}
className="flex items-start gap-3 rounded-2xl border border-white/15 bg-white/10 p-4 text-sm text-white/85 shadow-lg shadow-black/15 backdrop-blur"
>
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
<Icon className="h-4 w-4" aria-hidden />
</span>
<p>{text}</p>
</div>
))}
</div>
) : null}
</main>
</div>
</div>

View File

@@ -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" />

View File

@@ -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),
};

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()}`;
}