rework of the event admin UI

This commit is contained in:
Codex Agent
2025-11-24 17:17:39 +01:00
parent 4667ec8073
commit 8947a37261
37 changed files with 4381 additions and 874 deletions

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, ArrowLeft, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react';
import { AlertCircle, ArrowLeft, CheckCircle2, Circle, Clock3, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -12,19 +12,24 @@ import { AdminLayout } from '../components/AdminLayout';
import {
PhotoboothStatus,
TenantEvent,
type EventToolkit,
type TenantPhoto,
disableEventPhotobooth,
enableEventPhotobooth,
getEvent,
getEventPhotoboothStatus,
getEventToolkit,
rotateEventPhotobooth,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
import { buildEventTabs } from '../lib/eventTabs';
type State = {
event: TenantEvent | null;
status: PhotoboothStatus | null;
toolkit: EventToolkit | null;
loading: boolean;
updating: boolean;
error: string | null;
@@ -38,6 +43,7 @@ export default function EventPhotoboothPage() {
const [state, setState] = React.useState<State>({
event: null,
status: null,
toolkit: null,
loading: true,
updating: false,
error: null,
@@ -56,10 +62,19 @@ export default function EventPhotoboothPage() {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
const toolkitPromise = getEventToolkit(slug)
.then((data) => data)
.catch((toolkitError) => {
if (!isAuthError(toolkitError)) {
console.warn('[Photobooth] Toolkit konnte nicht geladen werden', toolkitError);
}
return null;
});
const [eventData, statusData, toolkitData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug), toolkitPromise]);
setState({
event: eventData,
status: statusData,
toolkit: toolkitData,
loading: false,
updating: false,
error: null,
@@ -129,9 +144,9 @@ export default function EventPhotoboothPage() {
}
}
async function handleDisable(): Promise<void> {
async function handleDisable(options?: { skipConfirm?: boolean }): Promise<void> {
if (!slug) return;
if (!window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) {
if (!options?.skipConfirm && !window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) {
return;
}
@@ -157,7 +172,7 @@ export default function EventPhotoboothPage() {
}
}
const { event, status, loading, updating, error } = state;
const { event, status, toolkit, loading, updating, error } = state;
const title = event
? t('management.photobooth.titleForEvent', { defaultValue: 'Fotobox-Uploads verwalten', event: resolveEventName(event.name) })
: t('management.photobooth.title', 'Fotobox-Uploads');
@@ -165,6 +180,59 @@ export default function EventPhotoboothPage() {
'management.photobooth.subtitle',
'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.'
);
const eventTabs = React.useMemo(() => {
if (!event || !slug) {
return [];
}
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, {
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
photos: event.photo_count ?? event.pending_photo_count ?? undefined,
tasks: event.tasks_count ?? undefined,
});
}, [event, slug, t]);
const recentPhotos = React.useMemo(() => toolkit?.photos?.recent ?? [], [toolkit?.photos?.recent]);
const photoboothRecent = React.useMemo(() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth'), [recentPhotos]);
const effectiveRecentPhotos = React.useMemo(
() => (photoboothRecent.length > 0 ? photoboothRecent : recentPhotos),
[photoboothRecent, recentPhotos],
);
const uploads24h = React.useMemo(
() => countUploadsInWindow(effectiveRecentPhotos, 24 * 60 * 60 * 1000),
[effectiveRecentPhotos],
);
const recentShare = React.useMemo(() => {
if (recentPhotos.length === 0) {
return null;
}
const ratio = photoboothRecent.length / recentPhotos.length;
return Math.round(ratio * 100);
}, [photoboothRecent.length, recentPhotos.length]);
const lastUploadAt = React.useMemo(() => {
const latestPhoto = selectLatestUpload(effectiveRecentPhotos);
if (latestPhoto?.uploaded_at) {
return latestPhoto.uploaded_at;
}
return status?.metrics?.last_upload_at ?? null;
}, [effectiveRecentPhotos, status?.metrics?.last_upload_at]);
const lastUploadSource: 'photobooth' | 'event' | null = photoboothRecent.length > 0
? 'photobooth'
: recentPhotos.length > 0
? 'event'
: null;
const eventUploadsTotal = toolkit?.metrics.uploads_total ?? event?.photo_count ?? 0;
const uploadStats = React.useMemo(
() => ({
uploads24h,
shareRecent: recentShare,
totalEventUploads: eventUploadsTotal ?? 0,
lastUploadAt,
source: lastUploadSource,
sampleSize: recentPhotos.length,
}),
[uploads24h, recentShare, eventUploadsTotal, lastUploadAt, lastUploadSource, recentPhotos.length],
);
const actions = (
<div className="flex gap-2">
@@ -181,7 +249,7 @@ export default function EventPhotoboothPage() {
);
return (
<AdminLayout title={title} subtitle={subtitle} actions={actions}>
<AdminLayout title={title} subtitle={subtitle} actions={actions} tabs={eventTabs} currentTabKey="photobooth">
{error ? (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('common:messages.error', 'Fehler')}</AlertTitle>
@@ -193,9 +261,25 @@ export default function EventPhotoboothPage() {
<PhotoboothSkeleton />
) : (
<div className="space-y-6">
<StatusCard status={status} />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<StatusCard status={status} />
<SetupChecklistCard status={status} />
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<ModePresetsCard
status={status}
updating={updating}
onEnable={handleEnable}
onDisable={() => handleDisable({ skipConfirm: true })}
onRotate={handleRotate}
/>
<UploadStatsCard stats={uploadStats} />
</div>
<CredentialsCard status={status} updating={updating} onEnable={handleEnable} onRotate={handleRotate} onDisable={handleDisable} />
<RateLimitCard status={status} />
<div className="grid gap-6 lg:grid-cols-2">
<StatusTimelineCard status={status} lastUploadAt={lastUploadAt} />
<RateLimitCard status={status} uploadsLastHour={status?.metrics?.uploads_last_hour ?? null} />
</div>
</div>
)}
</AdminLayout>
@@ -226,6 +310,216 @@ function PhotoboothSkeleton() {
);
}
type ModePresetsCardProps = {
status: PhotoboothStatus | null;
updating: boolean;
onEnable: () => Promise<void>;
onDisable: () => Promise<void>;
onRotate: () => Promise<void>;
};
function ModePresetsCard({ status, updating, onEnable, onDisable, onRotate }: ModePresetsCardProps) {
const { t } = useTranslation('management');
const activePreset: 'plan' | 'live' = status?.enabled ? 'live' : 'plan';
const [selectedPreset, setSelectedPreset] = React.useState<'plan' | 'live'>(activePreset);
React.useEffect(() => {
setSelectedPreset(activePreset);
}, [activePreset]);
const presets = React.useMemo(
() => [
{
key: 'plan' as const,
title: t('photobooth.presets.planTitle', 'Planungsmodus'),
description: t('photobooth.presets.planDescription', 'Zugang bleibt deaktiviert, um Tests vorzubereiten.'),
badge: t('photobooth.presets.badgePlan', 'Planung'),
icon: <Clock3 className="h-5 w-5 text-slate-500" />,
},
{
key: 'live' as const,
title: t('photobooth.presets.liveTitle', 'Live-Modus'),
description: t('photobooth.presets.liveDescription', 'FTP ist aktiv und Uploads werden direkt entgegen genommen.'),
badge: t('photobooth.presets.badgeLive', 'Live'),
icon: <PlugZap className="h-5 w-5 text-emerald-500" />,
},
],
[t],
);
const handleApply = React.useCallback(() => {
if (selectedPreset === activePreset) {
return;
}
if (selectedPreset === 'live') {
void onEnable();
} else {
void onDisable();
}
}, [activePreset, onDisable, onEnable, selectedPreset]);
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.presets.title', 'Modus wählen')}</CardTitle>
<CardDescription>{t('photobooth.presets.description', 'Passe die Photobooth an Vorbereitung oder Live-Betrieb an.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
{presets.map((preset) => {
const isSelected = selectedPreset === preset.key;
const isActive = activePreset === preset.key;
return (
<button
key={preset.key}
type="button"
onClick={() => setSelectedPreset(preset.key)}
className={`flex h-full flex-col items-start gap-3 rounded-2xl border p-4 text-left transition ${
isSelected ? 'border-emerald-400 bg-emerald-50/70 shadow-inner' : 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
{preset.icon}
<div>
<p className="text-sm font-semibold text-slate-900">{preset.title}</p>
<p className="text-xs text-slate-500">{preset.description}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Badge variant="outline" className="border-slate-200 text-slate-600">
{preset.badge}
</Badge>
{isActive ? (
<Badge className="bg-emerald-500/20 text-emerald-700">
{t('photobooth.presets.current', 'Aktiv')}
</Badge>
) : null}
</div>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-slate-200 pt-4">
<Button
onClick={handleApply}
disabled={selectedPreset === activePreset || updating}
className="min-w-[160px]"
>
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />}
{t('photobooth.presets.actions.apply', 'Modus übernehmen')}
</Button>
<Button variant="outline" onClick={onRotate} disabled={updating}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('photobooth.presets.actions.rotate', 'Zugang zurücksetzen')}
</Button>
</div>
</CardContent>
</Card>
);
}
type UploadStatsCardProps = {
stats: {
uploads24h: number;
shareRecent: number | null;
totalEventUploads: number;
lastUploadAt: string | null;
source: 'photobooth' | 'event' | null;
sampleSize: number;
};
};
function UploadStatsCard({ stats }: UploadStatsCardProps) {
const { t } = useTranslation('management');
const lastUploadLabel = stats.lastUploadAt ? formatPhotoboothDate(stats.lastUploadAt) : t('photobooth.stats.none', 'Noch keine Uploads');
const shareLabel = stats.shareRecent != null ? `${stats.shareRecent}%` : '—';
const sourceLabel = stats.source === 'photobooth'
? t('photobooth.stats.sourcePhotobooth', 'Quelle: Photobooth')
: stats.source === 'event'
? t('photobooth.stats.sourceEvent', 'Quelle: Event')
: null;
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.stats.title', 'Upload-Status')}</CardTitle>
<CardDescription>{t('photobooth.stats.description', 'Fokussiere deine Photobooth-Uploads der letzten Stunden.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.lastUpload', 'Letzter Upload')}</span>
<span className="font-semibold text-slate-900">{lastUploadLabel}</span>
</div>
{sourceLabel ? <p className="text-xs text-slate-500">{sourceLabel}</p> : null}
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.uploads24h', 'Uploads (24h)')}</span>
<span className="font-medium text-slate-900">{stats.uploads24h}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.share', 'Anteil Photobooth (letzte Uploads)')}</span>
<span className="font-medium text-slate-900">{shareLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.totalEvent', 'Uploads gesamt (Event)')}</span>
<span className="font-medium text-slate-900">{stats.totalEventUploads}</span>
</div>
<div className="flex items-center justify-between border-t border-dashed border-slate-200 pt-3 text-xs text-slate-500">
<span>{t('photobooth.stats.sample', 'Analysierte Uploads')}</span>
<span>{stats.sampleSize}</span>
</div>
</CardContent>
</Card>
);
}
function SetupChecklistCard({ status }: { status: PhotoboothStatus | null }) {
const { t } = useTranslation('management');
const steps = [
{
key: 'enable',
label: t('photobooth.checklist.enable', 'Zugang aktivieren'),
description: t('photobooth.checklist.enableCopy', 'Aktiviere den FTP-Account für eure Photobooth-Software.'),
done: Boolean(status?.enabled),
},
{
key: 'share',
label: t('photobooth.checklist.share', 'Zugang teilen'),
description: t('photobooth.checklist.shareCopy', 'Übergib Host, Benutzer & Passwort an den Betreiber.'),
done: Boolean(status?.username && status?.password),
},
{
key: 'monitor',
label: t('photobooth.checklist.monitor', 'Uploads beobachten'),
description: t('photobooth.checklist.monitorCopy', 'Verfolge Uploads & Limits direkt im Dashboard.'),
done: Boolean(status?.status === 'active'),
},
];
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.checklist.title', 'Setup-Checkliste')}</CardTitle>
<CardDescription>{t('photobooth.checklist.description', 'Erledige jeden Schritt, bevor Gäste Zugang erhalten.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{steps.map((step) => (
<div key={step.key} className="flex items-start gap-3 rounded-2xl border border-slate-200/70 p-3">
{step.done ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
) : (
<Circle className="mt-0.5 h-4 w-4 text-slate-300" />
)}
<div>
<p className={`text-sm font-semibold ${step.done ? 'text-emerald-700' : 'text-slate-800'}`}>{step.label}</p>
<p className="text-xs text-slate-500">{step.description}</p>
</div>
</div>
))}
</CardContent>
</Card>
);
}
function StatusCard({ status }: { status: PhotoboothStatus | null }) {
const { t } = useTranslation('management');
const isActive = Boolean(status?.enabled);
@@ -318,9 +612,64 @@ function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: Cr
);
}
function RateLimitCard({ status }: { status: PhotoboothStatus | null }) {
function StatusTimelineCard({ status, lastUploadAt }: { status: PhotoboothStatus | null; lastUploadAt?: string | null }) {
const { t } = useTranslation('management');
const entries = [
{
title: t('photobooth.timeline.activation', 'Freischaltung'),
body: status?.enabled
? t('photobooth.timeline.activationReady', 'Zugang ist aktiv.')
: t('photobooth.timeline.activationPending', 'Noch nicht aktiviert.'),
},
{
title: t('photobooth.timeline.credentials', 'Zugangsdaten'),
body: status?.username
? t('photobooth.timeline.credentialsReady', { defaultValue: 'Benutzer {{username}} ist bereit.', username: status.username })
: t('photobooth.timeline.credentialsPending', 'Noch keine Logindaten generiert.'),
},
{
title: t('photobooth.timeline.expiry', 'Ablauf'),
body: status?.expires_at
? t('photobooth.timeline.expiryHint', { defaultValue: 'Automatisches Abschalten am {{date}}', date: formatPhotoboothDate(status.expires_at) })
: t('photobooth.timeline.noExpiry', 'Noch kein Ablaufdatum gesetzt.'),
},
{
title: t('photobooth.timeline.lastUpload', 'Letzter Upload'),
body: lastUploadAt
? t('photobooth.timeline.lastUploadAt', { defaultValue: 'Zuletzt am {{date}}', date: formatPhotoboothDate(lastUploadAt) })
: t('photobooth.timeline.lastUploadPending', 'Noch keine Uploads registriert.'),
},
];
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.timeline.title', 'Status-Timeline')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{entries.map((entry, index) => (
<div key={entry.title} className="flex gap-3">
<div className="flex flex-col items-center">
<Clock3 className="h-4 w-4 text-slate-400" />
{index < entries.length - 1 ? <span className="mt-1 h-10 w-px bg-slate-200" /> : null}
</div>
<div>
<p className="text-sm font-semibold text-slate-800">{entry.title}</p>
<p className="text-xs text-slate-500">{entry.body}</p>
</div>
</div>
))}
</CardContent>
</Card>
);
}
function RateLimitCard({ status, uploadsLastHour }: { status: PhotoboothStatus | null; uploadsLastHour: number | null }) {
const { t } = useTranslation('management');
const rateLimit = status?.rate_limit_per_minute ?? 20;
const usage = uploadsLastHour != null ? Math.max(0, uploadsLastHour) : null;
const usageRatio = usage !== null && rateLimit > 0 ? usage / rateLimit : null;
const showWarning = usageRatio !== null && usageRatio >= 0.8;
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
@@ -342,6 +691,17 @@ function RateLimitCard({ status }: { status: PhotoboothStatus | null }) {
'Bei Überschreitung wird die Verbindung hart geblockt. Nach 60 Sekunden wird der Zugang automatisch wieder freigegeben.'
)}
</p>
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/70 p-3">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-slate-500">
<span>{t('photobooth.rateLimit.usage', 'Uploads letzte Stunde')}</span>
<span className="text-slate-900">{usage !== null ? `${usage}/${rateLimit}` : `${rateLimit}`}</span>
</div>
{showWarning ? (
<p className="mt-2 text-xs text-amber-600">
{t('photobooth.rateLimit.warning', 'Kurz vor dem Limit bitte Upload-Takt reduzieren oder Support informieren.')}
</p>
) : null}
</div>
<p className="mt-3 text-xs text-slate-500">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{t(
@@ -387,3 +747,53 @@ function Field({ label, value, copyable, sensitive, className }: FieldProps) {
</div>
);
}
function formatPhotoboothDate(iso: string | null): string {
if (!iso) {
return '—';
}
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function countUploadsInWindow(photos: TenantPhoto[], windowMs: number): number {
if (!photos.length) {
return 0;
}
const now = Date.now();
return photos.reduce((total, photo) => {
if (!photo.uploaded_at) {
return total;
}
const timestamp = Date.parse(photo.uploaded_at);
if (Number.isNaN(timestamp)) {
return total;
}
return now - timestamp <= windowMs ? total + 1 : total;
}, 0);
}
function selectLatestUpload(photos: TenantPhoto[]): TenantPhoto | null {
let latest: TenantPhoto | null = null;
let bestTime = -Infinity;
photos.forEach((photo) => {
if (!photo.uploaded_at) {
return;
}
const timestamp = Date.parse(photo.uploaded_at);
if (!Number.isNaN(timestamp) && timestamp > bestTime) {
latest = photo;
bestTime = timestamp;
}
});
return latest;
}