further improvements for the mobile admin
This commit is contained in:
@@ -4,6 +4,7 @@ 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 { useTheme } from '@tamagui/core';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
@@ -35,6 +36,7 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
|
||||
const [selectedMode, setSelectedMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [updating, setUpdating] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -49,6 +51,7 @@ export default function MobileEventPhotoboothPage() {
|
||||
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
|
||||
setEvent(eventData);
|
||||
setStatus(statusData);
|
||||
setSelectedMode(statusData.mode ?? 'ftp');
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
|
||||
@@ -62,12 +65,20 @@ export default function MobileEventPhotoboothPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status?.mode) {
|
||||
setSelectedMode(status.mode);
|
||||
}
|
||||
}, [status?.mode]);
|
||||
|
||||
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
|
||||
if (!slug) return;
|
||||
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await enableEventPhotobooth(slug, { mode: mode ?? status?.mode ?? 'ftp' });
|
||||
const result = await enableEventPhotobooth(slug, { mode: nextMode });
|
||||
setStatus(result);
|
||||
setSelectedMode(result.mode ?? nextMode);
|
||||
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -80,10 +91,12 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!slug) return;
|
||||
const mode = status?.mode ?? selectedMode ?? 'ftp';
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await disableEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
|
||||
const result = await disableEventPhotobooth(slug, { mode });
|
||||
setStatus(result);
|
||||
setSelectedMode(result.mode ?? mode);
|
||||
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -96,10 +109,12 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
const handleRotate = async () => {
|
||||
if (!slug) return;
|
||||
const mode = selectedMode ?? status?.mode ?? 'ftp';
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await rotateEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
|
||||
const result = await rotateEventPhotobooth(slug, { mode });
|
||||
setStatus(result);
|
||||
setSelectedMode(result.mode ?? mode);
|
||||
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -110,8 +125,24 @@ export default function MobileEventPhotoboothPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeMode = selectedMode ?? status?.mode ?? 'ftp';
|
||||
const isSpark = activeMode === 'sparkbooth';
|
||||
const spark = status?.sparkbooth ?? null;
|
||||
const ftp = status?.ftp ?? null;
|
||||
const metrics = isSpark ? spark?.metrics ?? null : status?.metrics ?? null;
|
||||
const expiresAt = isSpark ? spark?.expires_at ?? status?.expires_at : status?.expires_at ?? spark?.expires_at;
|
||||
const lastUploadAt = metrics?.last_upload_at;
|
||||
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
|
||||
const uploadsTotal = metrics?.uploads_total;
|
||||
const connectionPath = status?.path ?? '—';
|
||||
const ftpUrl = status?.ftp_url ?? '—';
|
||||
const uploadUrl = isSpark ? spark?.upload_url ?? status?.upload_url : null;
|
||||
const responseFormat = spark?.response_format ?? 'json';
|
||||
const username = isSpark ? spark?.username ?? status?.username : status?.username ?? spark?.username ?? null;
|
||||
const password = isSpark ? spark?.password ?? status?.password : status?.password ?? spark?.password ?? null;
|
||||
|
||||
const modeLabel =
|
||||
status?.mode === 'sparkbooth'
|
||||
activeMode === 'sparkbooth'
|
||||
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
|
||||
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
||||
|
||||
@@ -120,6 +151,15 @@ export default function MobileEventPhotoboothPage() {
|
||||
const subtitle =
|
||||
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (!slug || updating) return;
|
||||
if (checked) {
|
||||
void handleEnable(status?.mode ?? 'ftp');
|
||||
} else {
|
||||
void handleDisable();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
@@ -148,9 +188,9 @@ export default function MobileEventPhotoboothPage() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<MobileCard space="$2">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<YStack space="$1">
|
||||
<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>
|
||||
@@ -161,25 +201,71 @@ export default function MobileEventPhotoboothPage() {
|
||||
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={isActive ? 'success' : 'warning'}>
|
||||
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
|
||||
</PillBadge>
|
||||
<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', 'Choose adapter')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'photobooth.selector.description',
|
||||
'FTP (Classic) works with most booths. Sparkbooth uses HTTP POST without FTP.'
|
||||
)}
|
||||
</Text>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
|
||||
<XStack flex={1} minWidth={0}>
|
||||
<CTAButton
|
||||
label={t('photobooth.credentials.heading', 'FTP credentials')}
|
||||
tone={status?.mode === 'ftp' ? 'primary' : 'ghost'}
|
||||
onPress={() => handleEnable('ftp')}
|
||||
label={t('photobooth.mode.ftp', 'FTP (Classic)')}
|
||||
tone={activeMode === 'ftp' ? 'primary' : 'ghost'}
|
||||
onPress={() => setSelectedMode('ftp')}
|
||||
disabled={updating}
|
||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
||||
/>
|
||||
</XStack>
|
||||
<XStack flex={1} minWidth={0}>
|
||||
<CTAButton
|
||||
label={t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)')}
|
||||
tone={status?.mode === 'sparkbooth' ? 'primary' : 'ghost'}
|
||||
onPress={() => handleEnable('sparkbooth')}
|
||||
label={t('photobooth.mode.sparkbooth', 'Sparkbooth (HTTP POST)')}
|
||||
tone={activeMode === 'sparkbooth' ? 'primary' : 'ghost'}
|
||||
onPress={() => setSelectedMode('sparkbooth')}
|
||||
disabled={updating}
|
||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
||||
/>
|
||||
@@ -188,28 +274,51 @@ export default function MobileEventPhotoboothPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('photobooth.credentials.heading', 'FTP credentials')}
|
||||
</Text>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{isSpark ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)') : t('photobooth.credentials.heading', 'FTP credentials')}
|
||||
</Text>
|
||||
{!isSpark && ftp?.require_ftps ? <PillBadge tone="warning">{t('photobooth.credentials.ftps', 'FTPS required')}</PillBadge> : null}
|
||||
</XStack>
|
||||
<YStack space="$1">
|
||||
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={status?.host ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={status?.username ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={status?.password ?? '—'} border={border} masked />
|
||||
{status?.upload_url ? <CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={status.upload_url} border={border} /> : null}
|
||||
{isSpark ? (
|
||||
<>
|
||||
<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.sparkbooth.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.sparkbooth.hint', 'POST with media file or base64 "media" field; username/password required.')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={ftp?.host ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.port', 'Port')} value={String(ftp?.port ?? '—')} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.path', 'Target folder')} value={connectionPath} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.postUrl', 'FTP URL')} value={ftpUrl} 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 />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
|
||||
<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') : t('photobooth.actions.enable', 'Activate photobooth')}
|
||||
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
||||
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
|
||||
onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
|
||||
tone={isActive ? 'ghost' : 'primary'}
|
||||
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
|
||||
disabled={updating}
|
||||
@@ -230,18 +339,35 @@ export default function MobileEventPhotoboothPage() {
|
||||
label={t('photobooth.status.heading', 'Status')}
|
||||
value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
||||
/>
|
||||
{status?.metrics?.uploads_last_hour != null ? (
|
||||
<StatusRow
|
||||
icon={<RefreshCcw size={16} color={text} />}
|
||||
label={t('photobooth.rateLimit.usage', 'Uploads last hour')}
|
||||
value={String(status.metrics.uploads_last_hour)}
|
||||
/>
|
||||
) : null}
|
||||
{status?.metrics?.last_upload_at ? (
|
||||
<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', 'Letzter Upload')}
|
||||
value={formatEventDate(status.metrics.last_upload_at, locale) ?? '—'}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user