390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
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 { 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,
|
|
disableEventPhotobooth,
|
|
enableEventPhotobooth,
|
|
getEvent,
|
|
getEventPhotoboothStatus,
|
|
rotateEventPhotobooth,
|
|
} from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { getApiErrorMessage } from '../lib/apiError';
|
|
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
|
|
|
type State = {
|
|
event: TenantEvent | null;
|
|
status: PhotoboothStatus | 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,
|
|
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 [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
|
|
setState({
|
|
event: eventData,
|
|
status: statusData,
|
|
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(): Promise<void> {
|
|
if (!slug) return;
|
|
if (!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, 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 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}>
|
|
{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">
|
|
<StatusCard status={status} />
|
|
<CredentialsCard status={status} updating={updating} onEnable={handleEnable} onRotate={handleRotate} onDisable={handleDisable} />
|
|
<RateLimitCard status={status} />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 RateLimitCard({ status }: { status: PhotoboothStatus | null }) {
|
|
const { t } = useTranslation('management');
|
|
const rateLimit = status?.rate_limit_per_minute ?? 20;
|
|
|
|
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>
|
|
<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>
|
|
);
|
|
}
|