rework of the event admin UI
This commit is contained in:
@@ -4,7 +4,7 @@ import type { EventAddonSummary } from '../../api';
|
||||
|
||||
type Props = {
|
||||
addons: EventAddonSummary[];
|
||||
t: (key: string, fallback: string) => string;
|
||||
t: (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export function AddonSummaryList({ addons, t }: Props) {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LayoutDashboard, CalendarDays, Camera, Settings } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
@@ -19,6 +28,7 @@ import { UserMenu } from './UserMenu';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { EventSwitcher, EventMenuBar } from './EventNav';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { CommandShelf } from './CommandShelf';
|
||||
|
||||
type NavItem = {
|
||||
key: string;
|
||||
@@ -30,14 +40,24 @@ type NavItem = {
|
||||
prefetchKey?: string;
|
||||
};
|
||||
|
||||
type PageTab = {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
badge?: React.ReactNode;
|
||||
};
|
||||
|
||||
interface AdminLayoutProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
disableCommandShelf?: boolean;
|
||||
tabs?: PageTab[];
|
||||
currentTabKey?: string;
|
||||
}
|
||||
|
||||
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
|
||||
export function AdminLayout({ title, subtitle, actions, children, disableCommandShelf, tabs, currentTabKey }: AdminLayoutProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
|
||||
const { events } = useEventContext();
|
||||
@@ -167,7 +187,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EventSwitcher />
|
||||
{disableCommandShelf ? <EventSwitcher compact /> : null}
|
||||
{actions}
|
||||
<NotificationCenter />
|
||||
<UserMenu />
|
||||
@@ -203,7 +223,8 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<EventMenuBar />
|
||||
{disableCommandShelf ? <EventMenuBar /> : <CommandShelf />}
|
||||
{tabs && tabs.length ? <PageTabsNav tabs={tabs} currentKey={currentTabKey} /> : null}
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16">
|
||||
@@ -216,6 +237,116 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
);
|
||||
}
|
||||
|
||||
function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: string }) {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('common');
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const isActive = (tab: PageTab): boolean => {
|
||||
if (currentKey) {
|
||||
return tab.key === currentKey;
|
||||
}
|
||||
return location.pathname === tab.href || location.pathname.startsWith(tab.href);
|
||||
};
|
||||
|
||||
const activeTab = React.useMemo(() => tabs.find((tab) => isActive(tab)), [tabs, location.pathname, currentKey]);
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-200/70 bg-white/80 backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-2 sm:px-6">
|
||||
<div className="hidden gap-2 md:flex">
|
||||
{tabs.map((tab) => {
|
||||
const active = isActive(tab);
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
|
||||
active
|
||||
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
|
||||
: 'bg-white text-slate-600 hover:text-slate-900 dark:bg-white/5 dark:text-slate-300 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge !== undefined ? (
|
||||
<Badge
|
||||
variant={active ? 'secondary' : 'outline'}
|
||||
className={cn(
|
||||
active ? 'bg-white/20 text-white' : 'text-slate-600 dark:text-slate-300',
|
||||
'rounded-full text-[11px]'
|
||||
)}
|
||||
>
|
||||
{tab.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl border border-slate-200/70 bg-white px-3 py-2 text-left text-sm font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-slate-200"
|
||||
>
|
||||
<span>
|
||||
{activeTab?.label ?? t('navigation.tabs.active', { defaultValue: 'Bereich wählen' })}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-rose-500">
|
||||
{t('navigation.tabs.open', { defaultValue: 'Tabs' })}
|
||||
</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="rounded-t-3xl border-t border-slate-200/70 bg-white/95 pb-6 pt-6 dark:border-white/10 dark:bg-slate-950/95"
|
||||
>
|
||||
<SheetHeader className="px-4 pt-0 text-left">
|
||||
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('navigation.tabs.title', { defaultValue: 'Bereich auswählen' })}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('navigation.tabs.subtitle', { defaultValue: 'Wechsle schnell zwischen Event-Bereichen.' })}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 grid gap-2 px-4">
|
||||
{tabs.map((tab) => {
|
||||
const active = isActive(tab);
|
||||
return (
|
||||
<Link
|
||||
key={`sheet-${tab.key}`}
|
||||
to={tab.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-2xl border px-4 py-3 text-sm font-medium shadow-sm transition',
|
||||
active
|
||||
? 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
: 'border-slate-200 bg-white text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge !== undefined ? (
|
||||
<Badge
|
||||
variant={active ? 'secondary' : 'outline'}
|
||||
className={cn(active ? 'bg-white/30 text-rose-700' : 'text-slate-600 dark:text-slate-200', 'rounded-full text-[11px]')}
|
||||
>
|
||||
{tab.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantMobileNav({
|
||||
items,
|
||||
onPrefetch,
|
||||
|
||||
404
resources/js/admin/components/CommandShelf.tsx
Normal file
404
resources/js/admin/components/CommandShelf.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
ClipboardList,
|
||||
MessageSquare,
|
||||
PlugZap,
|
||||
PlusCircle,
|
||||
QrCode,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { EventSwitcher, EventMenuBar } from './EventNav';
|
||||
import {
|
||||
ADMIN_EVENT_CREATE_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
} from '../constants';
|
||||
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||||
|
||||
const MOBILE_SHELF_COACHMARK_KEY = 'tenant-admin:command-shelf-mobile-tip';
|
||||
|
||||
type CommandAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
href: string;
|
||||
};
|
||||
|
||||
function formatNumber(value?: number | null): string {
|
||||
if (typeof value !== 'number') {
|
||||
return '–';
|
||||
}
|
||||
if (value > 999) {
|
||||
return `${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function CommandShelf() {
|
||||
const { events, activeEvent, isLoading } = useEventContext();
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const navigate = useNavigate();
|
||||
const [mobileShelfOpen, setMobileShelfOpen] = React.useState(false);
|
||||
const [coachmarkDismissed, setCoachmarkDismissed] = React.useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(MOBILE_SHELF_COACHMARK_KEY) === '1';
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3">
|
||||
<div className="h-6 w-40 animate-pulse rounded-lg bg-slate-200/70 dark:bg-white/10" />
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-${index.toString()}`}
|
||||
className="h-20 animate-pulse rounded-2xl bg-slate-100/80 dark:bg-white/10"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
return (
|
||||
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 rounded-3xl border border-dashed border-rose-200/60 bg-white/70 p-5 text-center dark:border-white/10 dark:bg-white/5">
|
||||
<Sparkles className="mx-auto h-6 w-6 text-rose-500 dark:text-rose-200" />
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-white">
|
||||
{t('commandShelf.empty.title', 'Starte mit deinem ersten Event')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{t('commandShelf.empty.hint', 'Erstelle ein Event, dann bündeln wir hier deine wichtigsten Tools.')}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
{t('commandShelf.empty.cta', 'Event anlegen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeEvent) {
|
||||
return (
|
||||
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 rounded-3xl border border-slate-200 bg-white/80 p-5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-white">
|
||||
{t('commandShelf.selectEvent.title', 'Kein aktives Event ausgewählt')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{t('commandShelf.selectEvent.hint', 'Wähle unten ein Event aus, um Status und Aktionen zu sehen.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<EventSwitcher compact />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const slug = activeEvent.slug;
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
const formattedDate = formatEventDate(activeEvent.event_date, locale);
|
||||
const engagementMode = resolveEngagementMode(activeEvent);
|
||||
const handleActionClick = React.useCallback((href: string, closeSheet = false) => {
|
||||
if (closeSheet) {
|
||||
setMobileShelfOpen(false);
|
||||
}
|
||||
navigate(href);
|
||||
}, [navigate]);
|
||||
const handleDismissCoachmark = React.useCallback(() => {
|
||||
setCoachmarkDismissed(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(MOBILE_SHELF_COACHMARK_KEY, '1');
|
||||
}
|
||||
}, []);
|
||||
const showCoachmark = !coachmarkDismissed && !mobileShelfOpen;
|
||||
|
||||
const actionItems: CommandAction[] = [
|
||||
{
|
||||
key: 'photos',
|
||||
label: t('commandShelf.actions.photos.label', 'Fotos moderieren'),
|
||||
description: t('commandShelf.actions.photos.desc', 'Prüfe neue Uploads, Highlights & Sperren.'),
|
||||
icon: Camera,
|
||||
href: ADMIN_EVENT_PHOTOS_PATH(slug),
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: t('commandShelf.actions.tasks.label', 'Aufgaben pflegen'),
|
||||
description: t('commandShelf.actions.tasks.desc', 'Mission Cards & Moderation im Blick.'),
|
||||
icon: ClipboardList,
|
||||
href: ADMIN_EVENT_TASKS_PATH(slug),
|
||||
},
|
||||
{
|
||||
key: 'invites',
|
||||
label: t('commandShelf.actions.invites.label', 'QR & Einladungen'),
|
||||
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
|
||||
icon: QrCode,
|
||||
href: ADMIN_EVENT_INVITES_PATH(slug),
|
||||
},
|
||||
{
|
||||
key: 'photobooth',
|
||||
label: t('commandShelf.actions.photobooth.label', 'Photobooth anbinden'),
|
||||
description: t('commandShelf.actions.photobooth.desc', 'FTP-Zugang und Rate-Limits steuern.'),
|
||||
icon: PlugZap,
|
||||
href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug),
|
||||
},
|
||||
{
|
||||
key: 'toolkit',
|
||||
label: t('commandShelf.actions.toolkit.label', 'Event-Day Toolkit'),
|
||||
description: t('commandShelf.actions.toolkit.desc', 'Broadcasts, Aufgaben & Quicklinks.'),
|
||||
icon: MessageSquare,
|
||||
href: ADMIN_EVENT_TOOLKIT_PATH(slug),
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
key: 'photos',
|
||||
label: t('commandShelf.metrics.photos', 'Uploads'),
|
||||
value: activeEvent.photo_count,
|
||||
hint: t('commandShelf.metrics.total', 'gesamt'),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: t('commandShelf.metrics.pending', 'Moderation'),
|
||||
value: activeEvent.pending_photo_count,
|
||||
hint: t('commandShelf.metrics.pendingHint', 'offen'),
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: t('commandShelf.metrics.tasks', 'Aufgaben'),
|
||||
value: activeEvent.tasks_count,
|
||||
hint: t('commandShelf.metrics.tasksHint', 'aktiv'),
|
||||
},
|
||||
{
|
||||
key: 'invites',
|
||||
label: t('commandShelf.metrics.invites', 'Einladungen'),
|
||||
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
|
||||
hint: t('commandShelf.metrics.invitesHint', 'live'),
|
||||
},
|
||||
];
|
||||
|
||||
const statusLabel = activeEvent.status === 'published'
|
||||
? t('commandShelf.status.published', 'Veröffentlicht')
|
||||
: t('commandShelf.status.draft', 'Entwurf');
|
||||
|
||||
const liveBadge = activeEvent.is_active
|
||||
? t('commandShelf.status.live', 'Live für Gäste')
|
||||
: t('commandShelf.status.hidden', 'Versteckt');
|
||||
|
||||
const engagementLabel = engagementMode === 'photo_only'
|
||||
? t('commandShelf.status.photoOnly', 'Nur Foto-Modus')
|
||||
: t('commandShelf.status.tasksMode', 'Mission Cards aktiv');
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hidden border-b border-slate-200/80 bg-white/80 px-4 py-5 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70 md:block">
|
||||
<div className="mx-auto w-full max-w-6xl space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-300">
|
||||
{t('commandShelf.sectionTitle', 'Aktuelles Event')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{resolveEventDisplayName(activeEvent)}
|
||||
</h2>
|
||||
<Badge className={cn(activeEvent.status === 'published' ? 'bg-emerald-500 text-white' : 'bg-amber-500 text-white')}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs uppercase tracking-wide">
|
||||
{liveBadge}
|
||||
</Badge>
|
||||
{engagementMode ? (
|
||||
<Badge variant="outline" className="text-xs uppercase tracking-wide">
|
||||
{engagementLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{formattedDate ? `${formattedDate} · ` : ''}
|
||||
{activeEvent.package?.name ?? t('commandShelf.packageFallback', 'Standard-Paket')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<EventSwitcher compact />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full text-rose-600 hover:bg-rose-50 dark:text-rose-200 dark:hover:bg-rose-200/10"
|
||||
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t('commandShelf.cta.toolkit', 'Event-Day öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-500 dark:text-slate-300">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.key} className="rounded-2xl border border-slate-200/80 px-3 py-2 text-center dark:border-white/10">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em]">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">{metric.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||||
{actionItems.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
onClick={() => handleActionClick(action.href)}
|
||||
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-3 text-left transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:hover:border-rose-300/40"
|
||||
>
|
||||
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<EventMenuBar />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200/80 bg-white/85 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/80 md:hidden">
|
||||
<div className="mx-auto w-full max-w-6xl space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-white">{resolveEventDisplayName(activeEvent)}</h2>
|
||||
<Badge className={cn(activeEvent.status === 'published' ? 'bg-emerald-500 text-white' : 'bg-amber-500 text-white')}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs uppercase tracking-wide">
|
||||
{liveBadge}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{formattedDate ? `${formattedDate} · ` : ''}
|
||||
{activeEvent.package?.name ?? t('commandShelf.packageFallback', 'Standard-Paket')}
|
||||
</p>
|
||||
{engagementMode ? (
|
||||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">{engagementLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-500 dark:text-slate-300">
|
||||
{metrics.map((metric) => (
|
||||
<div key={`mobile-${metric.key}`} className="rounded-2xl border border-slate-200/80 px-3 py-2 text-left dark:border-white/10">
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.3em]">{metric.label}</p>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">{metric.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCoachmark ? (
|
||||
<div className="rounded-2xl border border-rose-200/70 bg-rose-50/80 px-4 py-3 text-sm text-rose-700 dark:border-rose-300/40 dark:bg-rose-300/20 dark:text-rose-100">
|
||||
<p>{t('commandShelf.mobile.tip', 'Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.')}</p>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={handleDismissCoachmark} className="text-rose-700 hover:bg-rose-100 dark:text-rose-100 dark:hover:bg-rose-300/20">
|
||||
{t('commandShelf.mobile.tipCta', 'Verstanden')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Sheet open={mobileShelfOpen} onOpenChange={setMobileShelfOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button className="w-full rounded-2xl bg-rose-600 text-white shadow-lg shadow-rose-400/30">
|
||||
{t('commandShelf.mobile.openActions', 'Schnellaktionen öffnen')}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="rounded-t-3xl border-t border-slate-200/70 bg-white/95 pb-6 pt-6 dark:border-white/10 dark:bg-slate-950/95"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-4">
|
||||
<div className="mx-auto h-1.5 w-12 rounded-full bg-slate-300" aria-hidden />
|
||||
<SheetHeader className="px-4 pt-0 text-left">
|
||||
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und Einladungen an einem Ort.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">
|
||||
{metrics.map((metric) => (
|
||||
<div key={`sheet-${metric.key}`} className="rounded-xl border border-slate-200 px-3 py-1.5 dark:border-white/10">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</span>
|
||||
<span className="ml-2 uppercase tracking-[0.25em]">{metric.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-2 px-4">
|
||||
{actionItems.map((action) => (
|
||||
<button
|
||||
key={`sheet-action-${action.key}`}
|
||||
type="button"
|
||||
onClick={() => handleActionClick(action.href, true)}
|
||||
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/95 p-3 text-left shadow-sm transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<EventMenuBar />
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CalendarDays, ChevronDown, PlusCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type TenantEvent } from '../api';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -25,39 +26,8 @@ import {
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||
} from '../constants';
|
||||
import type { TenantEvent } from '../api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function resolveEventName(event: TenantEvent): string {
|
||||
const name = event.name;
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (name && typeof name === 'object') {
|
||||
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
return event.slug ?? 'Event';
|
||||
}
|
||||
|
||||
function formatEventDate(value?: string | null, locale = 'de-DE'): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
|
||||
|
||||
function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']) {
|
||||
return [
|
||||
@@ -71,14 +41,19 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
|
||||
];
|
||||
}
|
||||
|
||||
export function EventSwitcher() {
|
||||
type EventSwitcherProps = {
|
||||
buttonClassName?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function EventSwitcher({ buttonClassName, compact = false }: EventSwitcherProps = {}) {
|
||||
const { events, activeEvent, selectEvent } = useEventContext();
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
const buttonLabel = activeEvent ? resolveEventName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
|
||||
const buttonLabel = activeEvent ? resolveEventDisplayName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
|
||||
const buttonHint = activeEvent?.event_date
|
||||
? formatEventDate(activeEvent.event_date, locale)
|
||||
: events.length > 1
|
||||
@@ -93,13 +68,24 @@ export function EventSwitcher() {
|
||||
}
|
||||
};
|
||||
|
||||
const buttonClasses = cn(
|
||||
'rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white',
|
||||
compact && 'px-3 text-xs sm:text-sm',
|
||||
buttonClassName,
|
||||
);
|
||||
|
||||
const buttonLabelClasses = compact ? 'text-sm' : 'hidden sm:inline';
|
||||
const hintClasses = compact
|
||||
? 'text-xs text-slate-500 dark:text-slate-300'
|
||||
: 'text-xs text-slate-500 dark:text-slate-300 sm:ml-2';
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white">
|
||||
<Button variant="outline" size="sm" className={buttonClasses}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">{buttonLabel}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-300 sm:ml-2">
|
||||
<span className={buttonLabelClasses}>{buttonLabel}</span>
|
||||
<span className={hintClasses}>
|
||||
{buttonHint}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
@@ -138,7 +124,7 @@ export function EventSwitcher() {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{resolveEventName(event)}</p>
|
||||
<p className="text-sm font-semibold">{resolveEventDisplayName(event)}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">{date ?? t('eventSwitcher.noDate', 'Kein Datum')}</p>
|
||||
</div>
|
||||
{isActive ? (
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
ClipboardList,
|
||||
PlugZap,
|
||||
QrCode,
|
||||
Sparkles,
|
||||
CalendarDays,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import type { TenantEvent, DashboardSummary } from '../../api';
|
||||
import type { LimitWarning } from '../../lib/limitWarnings';
|
||||
import { resolveEventDisplayName, formatEventDate, formatEventStatusLabel, resolveEngagementMode } from '../../lib/events';
|
||||
|
||||
type DashboardEventFocusCardProps = {
|
||||
event: TenantEvent | null;
|
||||
limitWarnings: LimitWarning[];
|
||||
summary: DashboardSummary | null;
|
||||
dateLocale: string;
|
||||
onCreateEvent: () => void;
|
||||
onOpenEvent: () => void;
|
||||
onOpenPhotos: () => void;
|
||||
onOpenInvites: () => void;
|
||||
onOpenTasks: () => void;
|
||||
onOpenPhotobooth: () => void;
|
||||
};
|
||||
|
||||
export function DashboardEventFocusCard({
|
||||
event,
|
||||
limitWarnings,
|
||||
summary,
|
||||
dateLocale,
|
||||
onCreateEvent,
|
||||
onOpenEvent,
|
||||
onOpenPhotos,
|
||||
onOpenInvites,
|
||||
onOpenTasks,
|
||||
onOpenPhotobooth,
|
||||
}: DashboardEventFocusCardProps) {
|
||||
const { t } = useTranslation('dashboard', { keyPrefix: 'dashboard.eventFocus' });
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<Card className="border border-dashed border-rose-200/80 bg-white/80 shadow-sm shadow-rose-100/40 dark:border-white/20 dark:bg-white/5">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-rose-600 dark:text-rose-200">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('empty.eyebrow', 'Noch kein Event aktiv')}
|
||||
</div>
|
||||
<CardTitle className="text-lg text-slate-900 dark:text-white">
|
||||
{t('empty.title', 'Leg mit deinem ersten Event los')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('empty.description', 'Importiere ein Mission Pack, lege Branding fest und teile sofort den Gästelink.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="rounded-full bg-brand-rose px-6 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]" onClick={onCreateEvent}>
|
||||
{t('empty.cta', 'Event anlegen')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const eventName = resolveEventDisplayName(event);
|
||||
const dateLabel = formatEventDate(event.event_date, dateLocale) ?? t('noDate', 'Kein Datum gesetzt');
|
||||
const statusLabel = formatEventStatusLabel(event.status ?? null, tc);
|
||||
const isLive = Boolean(event.is_active || event.status === 'published');
|
||||
const engagementMode = resolveEngagementMode(event);
|
||||
|
||||
const overviewStats = [
|
||||
{
|
||||
key: 'uploads',
|
||||
label: t('stats.uploads', 'Uploads gesamt'),
|
||||
value: Number(event.photo_count ?? 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: 'likes',
|
||||
label: t('stats.likes', 'Likes'),
|
||||
value: Number(event.like_count ?? 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: t('stats.tasks', 'Aktive Aufgaben'),
|
||||
value: Number(event.tasks_count ?? 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: 'invites',
|
||||
label: t('stats.invites', 'Einladungen live'),
|
||||
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
key: 'photos',
|
||||
label: t('actions.photos', 'Uploads prüfen'),
|
||||
description: t('actions.photosHint', 'Neueste Uploads ansehen und verstecken.'),
|
||||
icon: Camera,
|
||||
handler: onOpenPhotos,
|
||||
},
|
||||
{
|
||||
key: 'invites',
|
||||
label: t('actions.invites', 'QR & Einladungen'),
|
||||
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
|
||||
icon: QrCode,
|
||||
handler: onOpenInvites,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: t('actions.tasks', 'Mission Packs & Emotionen'),
|
||||
description: t('actions.tasksHint', 'Kollektionen importieren und Emotionen aktivieren.'),
|
||||
icon: ClipboardList,
|
||||
handler: onOpenTasks,
|
||||
},
|
||||
{
|
||||
key: 'photobooth',
|
||||
label: t('actions.photobooth', 'Photobooth binden'),
|
||||
description: t('actions.photoboothHint', 'FTP-Daten freigeben und Rate-Limit prüfen.'),
|
||||
icon: PlugZap,
|
||||
handler: onOpenPhotobooth,
|
||||
},
|
||||
];
|
||||
|
||||
const latestUploads = summary?.new_photos ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-slate-200 bg-white/90 shadow-lg shadow-rose-100/40 dark:border-white/10 dark:bg-white/5">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
|
||||
{t('eyebrow', 'Aktuelles Event')}
|
||||
</div>
|
||||
<CardTitle className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{eventName}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('dateLabel', { defaultValue: 'Eventdatum: {{date}}', date: dateLabel })}
|
||||
</CardDescription>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge className={isLive ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-800'}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-semibold">
|
||||
{isLive ? t('badges.live', 'Live für Gäste') : t('badges.hidden', 'Noch versteckt')}
|
||||
</Badge>
|
||||
{engagementMode === 'photo_only' ? (
|
||||
<Badge variant="outline" className="text-xs font-semibold">
|
||||
{t('badges.photoOnly', 'Nur Foto-Modus')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs font-semibold">
|
||||
{t('badges.missionMode', 'Mission Cards aktiv')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="rounded-full border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40" onClick={onOpenEvent}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
{t('viewEvent', 'Event öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{overviewStats.map((stat) => (
|
||||
<div key={stat.key} className="rounded-2xl border border-slate-200 bg-white/80 p-4 text-sm text-slate-600 dark:border-white/10 dark:bg-white/5">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-300">{stat.label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
onClick={action.handler}
|
||||
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-slate-200 bg-sky-50 p-4 text-slate-800 shadow-inner shadow-sky-100 dark:border-white/10 dark:bg-white/10 dark:text-white">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('latestUploads.title', 'Neueste Uploads')}</p>
|
||||
<p className="mt-2 text-3xl font-semibold">{latestUploads}</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{t('latestUploads.hint', 'Gerade eingetroffen – prüfe sie schnell.')}</p>
|
||||
<Button size="sm" variant="secondary" className="mt-4" onClick={onOpenPhotos}>
|
||||
{t('actions.photos', 'Uploads prüfen')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4 text-slate-800 shadow-inner shadow-slate-100 dark:border-white/10 dark:bg-white/5 dark:text-white">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('invitesCard.title', 'Galerie & Einladungen')}</p>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-200">
|
||||
{t('invitesCard.description', 'Kopiere den Gästelink oder exportiere QR-Karten.')}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onOpenInvites}>
|
||||
{t('invitesCard.cta', 'Links verwalten')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onOpenPhotobooth}>
|
||||
{t('invitesCard.secondaryCta', 'Photobooth öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900 dark:border-amber-300/40 dark:bg-amber-500/10 dark:text-amber-100' : undefined}
|
||||
>
|
||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t(`limitWarnings.${warning.scope}`, {
|
||||
defaultValue:
|
||||
warning.scope === 'photos'
|
||||
? 'Fotos'
|
||||
: warning.scope === 'guests'
|
||||
? 'Gäste'
|
||||
: 'Galerie',
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">{warning.message}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user