799 lines
30 KiB
TypeScript
799 lines
30 KiB
TypeScript
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, {
|
||
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;
|
||
}
|