413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } from 'lucide-react';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Switch } from '@tamagui/switch';
|
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
|
import {
|
|
getEvent,
|
|
getEventPhotoboothStatus,
|
|
enableEventPhotobooth,
|
|
disableEventPhotobooth,
|
|
rotateEventPhotobooth,
|
|
createEventPhotoboothConnectCode,
|
|
PhotoboothStatus,
|
|
TenantEvent,
|
|
} from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { getApiErrorMessage } from '../lib/apiError';
|
|
import { formatEventDate } from '../lib/events';
|
|
import toast from 'react-hot-toast';
|
|
import { adminPath } from '../constants';
|
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
|
import { useAdminTheme } from './theme';
|
|
|
|
export default function MobileEventPhotoboothPage() {
|
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
|
const slug = slugParam ?? null;
|
|
const navigate = useNavigate();
|
|
const { t, i18n } = useTranslation('management');
|
|
const { text, muted, border, surface, danger } = useAdminTheme();
|
|
|
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
|
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [updating, setUpdating] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [connectCode, setConnectCode] = React.useState<string | null>(null);
|
|
const [connectExpiresAt, setConnectExpiresAt] = React.useState<string | null>(null);
|
|
const [connectLoading, setConnectLoading] = React.useState(false);
|
|
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
|
|
|
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!slug) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
|
|
setEvent(eventData);
|
|
setStatus(statusData);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [slug, t]);
|
|
|
|
React.useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
|
|
const handleEnable = async () => {
|
|
if (!slug) return;
|
|
const nextMode = 'sparkbooth';
|
|
setUpdating(true);
|
|
try {
|
|
const result = await enableEventPhotobooth(slug, { mode: nextMode });
|
|
setStatus(result);
|
|
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')));
|
|
}
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleDisable = async () => {
|
|
if (!slug) return;
|
|
const mode = 'sparkbooth';
|
|
setUpdating(true);
|
|
try {
|
|
const result = await disableEventPhotobooth(slug, { mode });
|
|
setStatus(result);
|
|
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')));
|
|
}
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleRotate = async () => {
|
|
if (!slug) return;
|
|
const mode = 'sparkbooth';
|
|
setUpdating(true);
|
|
try {
|
|
const result = await rotateEventPhotobooth(slug, { mode });
|
|
setStatus(result);
|
|
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')));
|
|
}
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateConnectCode = async () => {
|
|
if (!slug) return;
|
|
setConnectLoading(true);
|
|
try {
|
|
const result = await createEventPhotoboothConnectCode(slug);
|
|
setConnectCode(result.code || null);
|
|
setConnectExpiresAt(result.expires_at ?? null);
|
|
toast.success(t('photobooth.connectCode.actions.generated', 'Verbindungscode erstellt'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(
|
|
getApiErrorMessage(err, t('photobooth.connectCode.errors.failed', 'Verbindungscode konnte nicht erstellt werden.'))
|
|
);
|
|
}
|
|
} finally {
|
|
setConnectLoading(false);
|
|
}
|
|
};
|
|
|
|
const spark = status?.sparkbooth ?? null;
|
|
const metrics = spark?.metrics ?? null;
|
|
const expiresAt = spark?.expires_at ?? status?.expires_at;
|
|
const lastUploadAt = metrics?.last_upload_at;
|
|
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
|
|
const uploadsTotal = metrics?.uploads_total;
|
|
const uploadUrl = spark?.upload_url ?? status?.upload_url;
|
|
const responseFormat = spark?.response_format ?? 'json';
|
|
const username = spark?.username ?? status?.username ?? null;
|
|
const password = spark?.password ?? status?.password ?? null;
|
|
|
|
const modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)');
|
|
|
|
const isActive = Boolean(status?.enabled);
|
|
const title = t('photobooth.title', 'Photobooth');
|
|
|
|
const handleToggle = (checked: boolean) => {
|
|
if (!slug || updating) return;
|
|
if (checked) {
|
|
void handleEnable();
|
|
} else {
|
|
void handleDisable();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="home"
|
|
title={title}
|
|
onBack={back}
|
|
headerActions={
|
|
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
<RefreshCcw size={18} color={text} />
|
|
</HeaderActionButton>
|
|
}
|
|
>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{error}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 3 }).map((_, idx) => (
|
|
<SkeletonCard key={`ph-skel-${idx}`} height={110} />
|
|
))}
|
|
</YStack>
|
|
) : (
|
|
<YStack space="$2">
|
|
<MobileCard space="$3">
|
|
<XStack justifyContent="space-between" alignItems="center" space="$3" flexWrap="wrap">
|
|
<YStack space="$1" flex={1} minWidth={0}>
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('photobooth.title', 'Photobooth')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
|
</Text>
|
|
</YStack>
|
|
<YStack alignItems="flex-end" space="$2">
|
|
<PillBadge tone={isActive ? 'success' : 'warning'}>
|
|
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
|
|
</PillBadge>
|
|
<XStack alignItems="center" space="$2">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
|
</Text>
|
|
<Switch
|
|
size="$4"
|
|
checked={isActive}
|
|
disabled={updating}
|
|
onCheckedChange={handleToggle}
|
|
aria-label={t('photobooth.actions.toggle', 'Toggle photobooth access')}
|
|
>
|
|
<Switch.Thumb />
|
|
</Switch>
|
|
</XStack>
|
|
</YStack>
|
|
</XStack>
|
|
<YStack space="$1" marginTop="$2">
|
|
<XStack justifyContent="space-between" alignItems="center">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.stats.lastUpload', 'Last upload')}
|
|
</Text>
|
|
<Text fontSize="$xs" fontWeight="700" color={text}>
|
|
{lastUploadAt ? formatEventDate(lastUploadAt, locale) : t('photobooth.status.never', 'Never')}
|
|
</Text>
|
|
</XStack>
|
|
<XStack justifyContent="space-between" alignItems="center">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.status.expires', 'Access expires')}
|
|
</Text>
|
|
<Text fontSize="$xs" fontWeight="700" color={text}>
|
|
{expiresAt ? formatEventDate(expiresAt, locale) : '—'}
|
|
</Text>
|
|
</XStack>
|
|
</YStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('photobooth.selector.title', 'Connection')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.selector.description', 'Use the Fotospiel uploader app for HTTP uploads.')}
|
|
</Text>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('photobooth.credentials.uploaderTitle', 'Uploader App (HTTP)')}
|
|
</Text>
|
|
</XStack>
|
|
<YStack space="$1">
|
|
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
|
|
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
|
|
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
|
|
<CredentialRow label={t('photobooth.uploader.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.uploader.hint', 'POST with media file or base64 "media" field; app uses these credentials.')}
|
|
</Text>
|
|
<YStack space="$2" marginTop="$2">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.connectCode.description', 'Create a 6-digit code for the uploader app.')}
|
|
</Text>
|
|
<CTAButton
|
|
label={
|
|
connectLoading
|
|
? t('common.processing', '...')
|
|
: t('photobooth.connectCode.actions.generate', 'Generate connect code')
|
|
}
|
|
onPress={handleGenerateConnectCode}
|
|
iconLeft={<PlugZap size={14} color={surface} />}
|
|
disabled={!isActive || updating || connectLoading}
|
|
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
|
/>
|
|
{connectCode ? (
|
|
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
|
|
) : null}
|
|
{connectExpiresAt ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('photobooth.connectCode.expires', 'Expires: {{date}}', {
|
|
date: formatEventDate(connectExpiresAt, locale),
|
|
})}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
</YStack>
|
|
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
<XStack flex={1} minWidth={0}>
|
|
<CTAButton
|
|
label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')}
|
|
onPress={() => handleRotate()}
|
|
iconLeft={<RefreshCw size={14} color={surface} />}
|
|
disabled={updating}
|
|
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
|
/>
|
|
</XStack>
|
|
<XStack flex={1} minWidth={0}>
|
|
<CTAButton
|
|
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
|
|
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
|
tone={isActive ? 'ghost' : 'primary'}
|
|
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
|
|
disabled={updating}
|
|
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
|
/>
|
|
</XStack>
|
|
</XStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('photobooth.status.heading', 'Status')}
|
|
</Text>
|
|
<YStack space="$1">
|
|
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
|
|
<StatusRow
|
|
icon={<PlugZap size={16} color={text} />}
|
|
label={t('photobooth.status.heading', 'Status')}
|
|
value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
|
/>
|
|
<StatusRow
|
|
icon={<RefreshCcw size={16} color={text} />}
|
|
label={t('photobooth.rateLimit.label', 'Rate limit (uploads/min)')}
|
|
value={status?.rate_limit_per_minute != null ? String(status.rate_limit_per_minute) : '—'}
|
|
/>
|
|
<StatusRow
|
|
icon={<Clock3 size={16} color={text} />}
|
|
label={t('photobooth.status.expires', 'Access expires')}
|
|
value={expiresAt ? formatEventDate(expiresAt, locale) ?? '—' : '—'}
|
|
/>
|
|
{lastUploadAt ? (
|
|
<StatusRow
|
|
icon={<Clock3 size={16} color={text} />}
|
|
label={t('photobooth.stats.lastUpload', 'Last upload')}
|
|
value={formatEventDate(lastUploadAt, locale) ?? '—'}
|
|
/>
|
|
) : null}
|
|
{uploads24h != null ? (
|
|
<StatusRow
|
|
icon={<RefreshCcw size={16} color={text} />}
|
|
label={t('photobooth.stats.uploads24h', 'Uploads last 24h')}
|
|
value={String(uploads24h)}
|
|
/>
|
|
) : null}
|
|
{uploadsTotal != null ? (
|
|
<StatusRow
|
|
icon={<RefreshCcw size={16} color={text} />}
|
|
label={t('photobooth.stats.uploadsTotal', 'Uploads total')}
|
|
value={String(uploadsTotal)}
|
|
/>
|
|
) : null}
|
|
</YStack>
|
|
</MobileCard>
|
|
</YStack>
|
|
)}
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function CredentialRow({ label, value, border, masked }: { label: string; value: string; border: string; masked?: boolean }) {
|
|
const { t } = useTranslation('management');
|
|
const { muted, text } = useAdminTheme();
|
|
return (
|
|
<XStack alignItems="center" justifyContent="space-between" borderWidth={1} borderColor={border} borderRadius="$3" padding="$2">
|
|
<YStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{label}
|
|
</Text>
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{masked ? '••••••••' : value}
|
|
</Text>
|
|
</YStack>
|
|
<Pressable
|
|
onPress={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
toast.success(t('common.copied', 'Kopiert'));
|
|
} catch {
|
|
toast.error(t('common.copyFailed', 'Kopieren fehlgeschlagen'));
|
|
}
|
|
}}
|
|
>
|
|
<Copy size={16} color={muted} />
|
|
</Pressable>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
|
const { text } = useAdminTheme();
|
|
return (
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<XStack alignItems="center" space="$2">
|
|
{icon}
|
|
<Text fontSize="$sm" color={text}>
|
|
{label}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{value}
|
|
</Text>
|
|
</XStack>
|
|
);
|
|
}
|