Files
fotospiel-app/resources/js/guest-v2/screens/UploadQueueScreen.tsx
Codex Agent 298a8375b6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 branding and theming
2026-02-03 15:18:44 +01:00

307 lines
12 KiB
TypeScript

import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { RefreshCcw, Trash2, UploadCloud } from 'lucide-react';
import AppShell from '../components/AppShell';
import SurfaceCard from '../components/SurfaceCard';
import { useUploadQueue } from '../services/uploadApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useEventData } from '../context/EventDataContext';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
type ProgressMap = Record<number, number>;
export default function UploadQueueScreen() {
const { t } = useTranslation();
const { locale } = useLocale();
const { token } = useEventData();
const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue();
const [progress, setProgress] = React.useState<ProgressMap>({});
const { isDark } = useGuestThemeVariant();
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [pending, setPending] = React.useState<PendingUpload[]>([]);
const [pendingLoading, setPendingLoading] = React.useState(false);
const [pendingError, setPendingError] = React.useState<string | null>(null);
const formatter = React.useMemo(
() => new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
[locale]
);
const formatTimestamp = React.useCallback(
(value?: string | null) => {
if (!value) {
return t('pendingUploads.card.justNow', 'Just now');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return t('pendingUploads.card.justNow', 'Just now');
}
return formatter.format(date);
},
[formatter, t]
);
const loadPendingUploads = React.useCallback(async () => {
if (!token) {
setPending([]);
return;
}
try {
setPendingLoading(true);
setPendingError(null);
const result = await fetchPendingUploadsSummary(token, 12);
setPending(result.items);
} catch (err) {
console.error('Pending uploads load failed', err);
setPendingError(t('pendingUploads.error', 'Failed to load uploads. Please try again.'));
} finally {
setPendingLoading(false);
}
}, [t, token]);
React.useEffect(() => {
void loadPendingUploads();
}, [loadPendingUploads]);
React.useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ id?: number; progress?: number }>).detail;
if (!detail?.id || typeof detail.progress !== 'number') return;
setProgress((prev) => ({ ...prev, [detail.id!]: detail.progress! }));
};
window.addEventListener('queue-progress', handler as EventListener);
return () => window.removeEventListener('queue-progress', handler as EventListener);
}, []);
const activeCount = items.filter((item) => item.status !== 'done').length;
const failedCount = items.filter((item) => item.status === 'error').length;
return (
<AppShell>
<YStack gap="$4">
<SurfaceCard glow>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<UploadCloud size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('uploadQueue.title', 'Uploads')}
</Text>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('uploadQueue.description', 'Keep track of queued uploads and retries.')}
</Text>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={refresh}
>
<RefreshCcw size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t(
'uploadQueue.summary',
{ waiting: activeCount, failed: failedCount },
'{waiting} waiting · {failed} failed'
)}
</Text>
</SurfaceCard>
<SurfaceCard>
<XStack gap="$2" flexWrap="wrap">
<Button size="$3" borderRadius="$pill" backgroundColor="$primary" onPress={retryAll} flex={1} minWidth={140}>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<RefreshCcw size={16} color="white" />
<Text fontSize="$2" fontWeight="$6" color="white">
{t('uploadQueue.actions.retryAll', 'Retry all')}
</Text>
</XStack>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={clearFinished}
flex={1}
minWidth={140}
>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<Trash2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadQueue.actions.clearFinished', 'Clear finished')}
</Text>
</XStack>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={loadPendingUploads}
flex={1}
minWidth={160}
>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<RefreshCcw size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('pendingUploads.refresh', 'Refresh')}
</Text>
</XStack>
</Button>
</XStack>
</SurfaceCard>
<YStack gap="$3">
{loading ? (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('common.actions.loading', 'Loading...')}
</Text>
</SurfaceCard>
) : items.length === 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('uploadQueue.emptyTitle', 'No queued uploads')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('uploadQueue.emptyDescription', 'Once photos are queued, they will appear here.')}
</Text>
</SurfaceCard>
) : (
items
.slice()
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
.map((item) => {
const pct = typeof item.id === 'number' ? progress[item.id] : undefined;
return (
<SurfaceCard key={item.id ?? `${item.fileName}-${item.createdAt}`} padding="$3">
<XStack alignItems="center" justifyContent="space-between" gap="$3">
<YStack flex={1} gap="$1">
<Text fontSize="$3" fontWeight="$7" numberOfLines={1}>
{item.fileName}
</Text>
<Text fontSize="$2" color={mutedText}>
{item.status === 'done'
? t('uploadQueue.status.uploaded', 'Uploaded')
: item.status === 'uploading'
? t('uploadQueue.status.uploading', 'Uploading')
: item.status === 'error'
? t('uploadQueue.status.failed', 'Failed')
: t('uploadQueue.status.waiting', 'Waiting')}
{item.retries
? t('uploadQueue.status.retries', { count: item.retries }, ' · {count} retries')
: ''}
</Text>
</YStack>
<YStack minWidth={72} alignItems="flex-end">
<Text fontSize="$2" color={mutedText}>
{pct !== undefined
? t('uploadQueue.progress', { progress: pct }, '{progress}%')
: item.status === 'done'
? t('uploadQueue.progress', { progress: 100 }, '{progress}%')
: ''}
</Text>
</YStack>
</XStack>
<YStack
height={6}
borderRadius={999}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
overflow="hidden"
marginTop="$2"
>
<YStack
height="100%"
width={`${pct ?? (item.status === 'done' ? 100 : 0)}%`}
backgroundColor="$primary"
/>
</YStack>
</SurfaceCard>
);
})
)}
</YStack>
<SurfaceCard>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7">
{t('pendingUploads.title', 'Pending uploads')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('pendingUploads.subtitle', 'Your photos are waiting for approval.')}
</Text>
</YStack>
</SurfaceCard>
<YStack gap="$3">
{pendingLoading ? (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('pendingUploads.loading', 'Loading uploads...')}
</Text>
</SurfaceCard>
) : pendingError ? (
<SurfaceCard>
<Text fontSize="$3" color="#FCA5A5">
{pendingError}
</Text>
</SurfaceCard>
) : pending.length === 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.emptyTitle', 'No pending uploads')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('pendingUploads.emptyBody', 'Once you upload a photo, it will appear here until it is approved.')}
</Text>
</SurfaceCard>
) : (
pending.map((photo) => (
<SurfaceCard key={photo.id} padding="$3">
<XStack alignItems="center" gap="$3">
<YStack
width={72}
height={72}
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
overflow="hidden"
alignItems="center"
justifyContent="center"
>
{photo.thumbnail_url ? (
<img src={photo.thumbnail_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<UploadCloud size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
)}
</YStack>
<YStack flex={1} gap="$1">
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.card.pending', 'Waiting for approval')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('pendingUploads.card.uploadedAt', 'Uploaded {time}').replace('{time}', formatTimestamp(photo.created_at))}
</Text>
</YStack>
</XStack>
</SurfaceCard>
))
)}
</YStack>
</YStack>
</AppShell>
);
}