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

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