der tenant admin hat eine neue, mobil unterstützende UI, login redirect funktioniert, typescript fehler wurden bereinigt. Neue Blog Posts von ChatGPT eingebaut, übersetzt von Gemini 2.5

This commit is contained in:
Codex Agent
2025-11-05 19:27:10 +01:00
parent adb93b5f9d
commit c6ac04eb15
44 changed files with 1995 additions and 1949 deletions

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react';
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Share2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
@@ -28,6 +29,7 @@ import { useTranslation } from 'react-i18next';
export default function EventsPage() {
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const [rows, setRows] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
@@ -47,28 +49,107 @@ export default function EventsPage() {
})();
}, []);
const actions = (
<>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={() => navigate(adminPath('/events/new'))}
>
<Plus className="h-4 w-4" /> {t('events.list.actions.create', 'Neues Event')}
</Button>
<Link to={ADMIN_SETTINGS_PATH}>
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
<Settings className="h-4 w-4" /> {t('events.list.actions.settings', 'Einstellungen')}
</Button>
</Link>
</>
const translateManagement = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
return (
<AdminLayout
title={t('events.list.title', 'Deine Events')}
subtitle={t('events.list.subtitle', 'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.')}
actions={actions}
const translateCommon = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
[tCommon],
);
const pageTitle = translateManagement('events.list.title', 'Deine Events');
const pageSubtitle = translateManagement(
'events.list.subtitle',
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
);
const totalEvents = rows.length;
const publishedEvents = React.useMemo(() => rows.filter((event) => event.status === 'published').length, [rows]);
const nextEvent = React.useMemo(() => {
return rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null;
}, [rows]);
const heroDescription = t(
'events.list.hero.description',
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'
);
const heroSummaryCopy = totalEvents > 0
? t('events.list.hero.summary', ':count Events aktiv verwaltet halte Aufgaben und Uploads im Blick.', { count: totalEvents })
: t('events.list.hero.summary_empty', 'Noch keine Events starte jetzt mit deinem ersten Konzept.');
const heroSecondaryCopy = t(
'events.list.hero.secondary',
'Erstelle Events im Admin, begleite Gäste live vor Ort und prüfe Kennzahlen im Marketing-Dashboard.'
);
const heroBadge = t('events.list.badge.dashboard', 'Tenant Dashboard');
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => navigate(adminPath('/events/new'))}
>
{t('events.list.actions.create', 'Neues Event')}
</Button>
);
const heroSecondaryAction = (
<Button size="sm" variant="outline" className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white" asChild>
<Link to={ADMIN_SETTINGS_PATH}>
{t('events.list.actions.settings', 'Einstellungen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
);
const heroAside = (
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/95 p-5 text-slate-900 shadow-lg shadow-pink-200/20 backdrop-blur">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">
{t('events.list.hero.published_label', 'Veröffentlichte Events')}
</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">{publishedEvents}</p>
<p className="text-xs text-slate-500">
{t('events.list.hero.total_label', ':count insgesamt', { count: totalEvents })}
</p>
</div>
{nextEvent ? (
<div className="rounded-xl border border-pink-100 bg-pink-50/70 p-4 text-slate-900 shadow-inner shadow-pink-200/50">
<p className="text-xs uppercase tracking-wide text-pink-600">
{t('events.list.hero.next_label', 'Nächstes Event')}
</p>
<p className="mt-1 text-sm font-semibold">{renderName(nextEvent.name)}</p>
<p className="text-xs text-slate-600">{formatDate(nextEvent.event_date)}</p>
{nextEvent.slug ? (
<Button
variant="ghost"
size="sm"
className="mt-3 h-8 justify-start px-3 text-pink-600 hover:bg-pink-100"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(nextEvent.slug))}
>
{t('events.list.hero.open_event', 'Event öffnen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
</div>
) : (
<p className="rounded-xl border border-dashed border-pink-200/70 bg-white/70 p-4 text-xs text-slate-600">
{t('events.list.hero.no_upcoming', 'Plane ein Datum, um hier die nächste Station zu sehen.')}
</p>
)}
</div>
</FrostedSurface>
);
return (
<AdminLayout title={pageTitle} subtitle={pageSubtitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
@@ -76,58 +157,70 @@ export default function EventsPage() {
</Alert>
)}
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<TenantHeroCard
badge={heroBadge}
title={pageTitle}
description={heroDescription}
supporting={[heroSummaryCopy, heroSecondaryCopy]}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<FrostedCard className="mt-6">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl font-semibold text-slate-900">{t('events.list.overview.title', 'Übersicht')}</CardTitle>
<CardDescription className="text-slate-600">
{rows.length === 0
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: rows.length })}
<CardTitle>{t('events.list.overview.title', 'Übersicht')}</CardTitle>
<CardDescription>
{loading
? t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …')
: totalEvents === 0
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: totalEvents })}
</CardDescription>
</div>
<div className="flex items-center gap-2 text-sm text-pink-600">
<Sparkles className="h-4 w-4" /> {t('events.list.badge.dashboard', 'Tenant Dashboard')}
</div>
<Badge className="bg-pink-100 text-pink-700">{t('events.list.badge.dashboard', 'Tenant Dashboard')}</Badge>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<LoadingState />
) : rows.length === 0 ? (
) : totalEvents === 0 ? (
<EmptyState onCreate={() => navigate(adminPath('/events/new'))} />
) : (
<div className="space-y-4">
{rows.map((event) => (
<EventCard key={event.id} event={event} translateCommon={tCommon} />
<EventCard key={event.id} event={event} translate={translateManagement} translateCommon={translateCommon} />
))}
</div>
)}
</CardContent>
</Card>
</FrostedCard>
</AdminLayout>
);
}
function EventCard({
event,
translate,
translateCommon,
}: {
event: TenantEvent;
translateCommon: (key: string, options?: Record<string, unknown>) => string;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
translateCommon: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
}) {
const slug = event.slug;
const isPublished = event.status === 'published';
const photoCount = event.photo_count ?? 0;
const likeCount = event.like_count ?? 0;
const limitWarnings = React.useMemo(
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, opts)),
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
[event.limits, translateCommon],
);
return (
<div className="rounded-2xl border border-white/80 bg-white/90 p-5 shadow-md shadow-pink-200/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
<FrostedSurface className="p-5 transition hover:-translate-y-0.5 hover:shadow-lg">
{limitWarnings.length > 0 && (
<div className="mb-3 space-y-1">
<div className="mb-4 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
@@ -143,61 +236,63 @@ function EventCard({
</div>
)}
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1 font-medium text-pink-700">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100/90 px-3 py-1 font-medium text-pink-700">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(event.event_date)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 font-medium text-sky-700">
Photos: {photoCount}
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100/90 px-3 py-1 font-medium text-sky-700">
{translate('events.list.badges.photos', 'Fotos')}: {photoCount}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 font-medium text-amber-700">
Likes: {likeCount}
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100/90 px-3 py-1 font-medium text-amber-700">
{translate('events.list.badges.likes', 'Likes')}: {likeCount}
</span>
</div>
</div>
<Badge
className={
isPublished
? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30'
: 'bg-slate-200 text-slate-700'
}
>
{isPublished ? 'Veroeffentlicht' : 'Entwurf'}
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
{isPublished
? translateCommon('events.status.published', 'Veröffentlicht')
: translateCommon('events.status.draft', 'Entwurf')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
Details <ArrowRight className="ml-1 h-3.5 w-3.5" />
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>Bearbeiten</Link>
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>{translateCommon('actions.edit', 'Bearbeiten')}</Link>
</Button>
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>Fotos moderieren</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>Mitglieder</Link>
</Button>
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>Toolkit</Link>
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>
{translate('events.list.actions.members', 'Mitglieder')}
</Link>
</Button>
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>
{translate('events.list.actions.tasks', 'Tasks')}
</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
<Share2 className="h-3.5 w-3.5" /> {translate('events.list.actions.invites', 'QR-Einladungen')}
</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>{translate('events.list.actions.toolkit', 'Toolkit')}</Link>
</Button>
</div>
</div>
</FrostedSurface>
);
}
@@ -205,9 +300,9 @@ function LoadingState() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
<FrostedSurface
key={index}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
className="h-24 animate-pulse bg-gradient-to-r from-white/40 via-white/70 to-white/40"
/>
))}
</div>
@@ -216,11 +311,11 @@ function LoadingState() {
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-pink-200 bg-white/70 p-10 text-center">
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
<p className="text-sm text-slate-600">
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
@@ -228,11 +323,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
</div>
<Button
onClick={onCreate}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
>
<Plus className="mr-1 h-4 w-4" /> Event erstellen
</Button>
</div>
</FrostedSurface>
);
}