307 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|