Files
fotospiel-app/resources/js/admin/components/CommandShelf.tsx

415 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, isError, refetch } = 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 (isError) {
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-amber-200/70 bg-amber-50/70 p-5 dark:border-amber-200/20 dark:bg-amber-500/10">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500" />
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-800 dark:text-white">
{t('commandShelf.error.title', 'Events konnten nicht geladen werden')}
</p>
<p className="text-xs text-slate-600 dark:text-slate-200">
{t('commandShelf.error.hint', 'Bitte versuche es erneut oder lade die Seite neu.')}
</p>
</div>
</div>
<div className="flex flex-wrap justify-between gap-3">
<EventSwitcher compact />
<Button
size="sm"
variant="outline"
className="rounded-full"
onClick={() => refetch()}
>
{t('commandShelf.error.retry', 'Erneut laden')}
</Button>
</div>
</div>
</section>
);
}
if (!events.length) {
// Hide the empty hero entirely; dashboard content already handles the zero-events case.
return null;
}
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>
</>
);
}