Files
fotospiel-app/resources/js/admin/pages/EventPhotoboothPage.tsx
2025-11-24 17:17:39 +01:00

800 lines
30 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, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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;
};
export default function EventPhotoboothPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']);
const [state, setState] = React.useState<State>({
event: null,
status: null,
toolkit: null,
loading: true,
updating: false,
error: null,
});
const load = React.useCallback(async () => {
if (!slug) {
setState((prev) => ({
...prev,
loading: false,
error: t('management.photobooth.errors.missingSlug', 'Kein Event ausgewählt.'),
}));
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
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,
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
loading: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')),
}));
} else {
setState((prev) => ({ ...prev, loading: false }));
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
async function handleEnable(): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await enableEventPhotobooth(slug);
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
async function handleRotate(): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await rotateEventPhotobooth(slug);
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
async function handleDisable(options?: { skipConfirm?: boolean }): Promise<void> {
if (!slug) return;
if (!options?.skipConfirm && !window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) {
return;
}
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await disableEventPhotobooth(slug);
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
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');
const subtitle = t(
'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">
{slug ? (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('management.photobooth.actions.backToEvent', 'Zur Detailansicht')}
</Button>
) : null}
<Button variant="ghost" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{t('management.photobooth.actions.allEvents', 'Zur Eventliste')}
</Button>
</div>
);
return (
<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>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<PhotoboothSkeleton />
) : (
<div className="space-y-6">
<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} />
<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>
);
}
function resolveEventName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
return Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function PhotoboothSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-3xl border border-slate-200/80 bg-white/70 p-6 shadow-sm">
<div className="h-4 w-32 animate-pulse rounded bg-slate-200/80" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
<div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
);
}
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);
const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800';
const icon = isActive ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />;
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle>{t('photobooth.status.heading', 'Status')}</CardTitle>
<CardDescription>
{isActive
? t('photobooth.status.active', 'Photobooth-Link ist aktiv.')
: t('photobooth.status.inactive', 'Noch keine Photobooth-Uploads angebunden.')}
</CardDescription>
</div>
<div className="flex items-center gap-3">
{icon}
<Badge className={badgeColor}>
{isActive ? t('photobooth.status.badgeActive', 'AKTIV') : t('photobooth.status.badgeInactive', 'INAKTIV')}
</Badge>
</div>
</CardHeader>
{status?.expires_at ? (
<CardContent className="text-sm text-slate-600">
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
date: new Date(status.expires_at).toLocaleString(),
})}
</CardContent>
) : null}
</Card>
);
}
type CredentialCardProps = {
status: PhotoboothStatus | null;
updating: boolean;
onEnable: () => Promise<void>;
onRotate: () => Promise<void>;
onDisable: () => Promise<void>;
};
function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) {
const { t } = useTranslation('management');
const isActive = Boolean(status?.enabled);
return (
<Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40">
<CardHeader>
<CardTitle>{t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}</CardTitle>
<CardDescription>
{t(
'photobooth.credentials.description',
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
</div>
<div className="flex flex-wrap gap-3">
{isActive ? (
<>
<Button onClick={onRotate} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('photobooth.actions.rotate', 'Zugang neu generieren')}
</Button>
<Button variant="outline" onClick={onDisable} disabled={updating}>
<Power className="mr-2 h-4 w-4" />
{t('photobooth.actions.disable', 'Deaktivieren')}
</Button>
</>
) : (
<Button onClick={onEnable} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />}
{t('photobooth.actions.enable', 'Photobooth aktivieren')}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
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">
<CardHeader className="flex flex-row items-center gap-3">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
<div>
<CardTitle>{t('photobooth.rateLimit.heading', 'Sicherheit & Limits')}</CardTitle>
<CardDescription>
{t('photobooth.rateLimit.description', 'Uploads werden strikt auf {{count}} Fotos pro Minute begrenzt.', {
count: rateLimit,
})}
</CardDescription>
</div>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-slate-600">
<p>
{t(
'photobooth.rateLimit.body',
'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(
'photobooth.rateLimit.hint',
'Ablaufzeit stimmt mit dem Event-Ende überein. Nach Ablauf wird der Account automatisch entfernt.'
)}
</p>
</CardContent>
</Card>
);
}
type FieldProps = {
label: string;
value: string;
copyable?: boolean;
sensitive?: boolean;
className?: string;
};
function Field({ label, value, copyable, sensitive, className }: FieldProps) {
const [copied, setCopied] = React.useState(false);
const showValue = sensitive && value && value !== '—' ? '•'.repeat(Math.min(6, value.length)) : value;
async function handleCopy() {
if (!copyable || !value || value === '—') return;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className={`rounded-2xl border border-slate-200/80 bg-white/70 p-4 shadow-inner ${className ?? ''}`}>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-base font-medium text-slate-900">{showValue}</span>
{copyable ? (
<Button variant="ghost" size="icon" onClick={handleCopy} aria-label="Copy" disabled={!value || value === '—'}>
{copied ? <ShieldCheck className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4 text-slate-500" />}
</Button>
) : null}
</div>
</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;
}