rework of the event admin UI
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user