rework of the event admin UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user