upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
307
resources/js/guest-v2/screens/UploadQueueScreen.tsx
Normal file
307
resources/js/guest-v2/screens/UploadQueueScreen.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
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 { useAppearance } from '@/hooks/use-appearance';
|
||||
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 { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user