upgrade to tamagui v2 and guest pwa overhaul

This commit is contained in:
Codex Agent
2026-02-02 13:01:20 +01:00
parent 2e78f3ab8d
commit 7c6e14ffe2
168 changed files with 47462 additions and 8914 deletions

View File

@@ -0,0 +1,742 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, FlipHorizontal, Image, ListVideo, RefreshCcw, Sparkles, UploadCloud, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
import { useAppearance } from '@/hooks/use-appearance';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import SurfaceCard from '../components/SurfaceCard';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value;
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const candidate = Object.values(obj).find((item) => typeof item === 'string' && item.trim() !== '');
if (typeof candidate === 'string') return candidate;
}
return undefined;
}
export default function UploadScreen() {
const { token, event } = useEventData();
const identity = useOptionalGuestIdentity();
const { items, add } = useUploadQueue();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t, locale } = useTranslation();
const { markCompleted } = useGuestTaskProgress(token ?? undefined);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [uploadDialog, setUploadDialog] = React.useState<UploadErrorDialog | null>(null);
const [cameraState, setCameraState] = React.useState<'idle' | 'starting' | 'ready' | 'denied' | 'blocked' | 'unsupported' | 'error' | 'preview'>('idle');
const [facingMode, setFacingMode] = React.useState<'user' | 'environment'>('environment');
const [mirror, setMirror] = React.useState(true);
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const autoApprove = event?.guest_upload_visibility === 'immediate';
const isExpanded = cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview';
const queueCount = items.filter((item) => item.status !== 'done').length;
const sendingCount = items.filter((item) => item.status === 'uploading').length;
const taskIdParam = searchParams.get('taskId');
const parsedTaskId = taskIdParam ? Number(taskIdParam) : NaN;
const taskId = Number.isFinite(parsedTaskId) ? parsedTaskId : undefined;
const [task, setTask] = React.useState<TaskItem | null>(null);
const [taskLoading, setTaskLoading] = React.useState(false);
const [taskError, setTaskError] = React.useState<string | null>(null);
const [pendingItems, setPendingItems] = React.useState<PendingUpload[]>([]);
const [pendingCount, setPendingCount] = React.useState(0);
const [pendingLoading, setPendingLoading] = React.useState(false);
const previewQueueItems = React.useMemo(() => items.filter((item) => item.status !== 'done').slice(0, 3), [items]);
const previewQueueUrls = React.useMemo(() => {
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return [];
}
return previewQueueItems.map((item) => ({
key: item.id ?? item.fileName,
url: URL.createObjectURL(item.blob),
}));
}, [previewQueueItems]);
React.useEffect(() => {
return () => {
previewQueueUrls.forEach((item) => URL.revokeObjectURL(item.url));
};
}, [previewQueueUrls]);
const loadPending = React.useCallback(async () => {
if (!token) {
setPendingItems([]);
setPendingCount(0);
return;
}
setPendingLoading(true);
try {
const result = await fetchPendingUploadsSummary(token, 3);
setPendingItems(result.items);
setPendingCount(result.totalCount);
} catch (err) {
console.error('Pending uploads load failed', err);
setPendingItems([]);
setPendingCount(0);
} finally {
setPendingLoading(false);
}
}, [token]);
React.useEffect(() => {
void loadPending();
}, [loadPending]);
React.useEffect(() => {
let active = true;
if (!token || !taskId) {
setTask(null);
setTaskLoading(false);
setTaskError(null);
return;
}
setTaskLoading(true);
setTaskError(null);
fetchTasks(token, { locale })
.then((tasks) => {
if (!active) return;
const match = tasks.find((item) => {
const id = item?.id ?? item?.task_id ?? item?.slug;
return String(id) === String(taskId);
}) ?? null;
setTask(match);
setTaskLoading(false);
})
.catch((err) => {
if (!active) return;
setTaskError(err instanceof Error ? err.message : t('tasks.error', 'Tasks could not be loaded.'));
setTask(null);
setTaskLoading(false);
});
return () => {
active = false;
};
}, [locale, t, taskId, token]);
const enqueueFile = React.useCallback(
async (file: File) => {
if (!token) return;
await add({ eventToken: token, fileName: file.name, blob: file, task_id: taskId ?? null });
},
[add, taskId, token]
);
const triggerConfetti = React.useCallback(async () => {
if (typeof window === 'undefined') return;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (prefersReducedMotion) return;
try {
const { default: confetti } = await import('canvas-confetti');
confetti({
particleCount: 70,
spread: 65,
origin: { x: 0.5, y: 0.35 },
ticks: 160,
scalar: 0.9,
});
} catch (error) {
console.warn('Confetti could not start', error);
}
}, []);
const uploadFiles = React.useCallback(
async (files: File[]) => {
if (!token || files.length === 0) return;
if (files.length === 0) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
setError(null);
setUploadDialog(null);
for (const file of files) {
if (!navigator.onLine) {
await enqueueFile(file);
continue;
}
try {
setUploading({ name: file.name, progress: 0 });
await uploadPhoto(token, file, taskId, undefined, {
guestName: identity?.name ?? undefined,
onProgress: (percent) => {
setUploading((prev) => (prev ? { ...prev, progress: percent } : prev));
},
maxRetries: 1,
});
if (taskId) {
markCompleted(taskId);
}
if (autoApprove) {
void triggerConfetti();
}
void loadPending();
} catch (err) {
const uploadErr = err as { code?: string; meta?: Record<string, unknown> };
console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
await enqueueFile(file);
} finally {
setUploading(null);
}
}
},
[autoApprove, enqueueFile, identity?.name, loadPending, markCompleted, t, taskId, token, triggerConfetti]
);
const handleFiles = React.useCallback(
async (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
await uploadFiles(files);
},
[uploadFiles]
);
const handlePick = React.useCallback(() => {
inputRef.current?.click();
}, []);
const stopCamera = React.useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setCameraState('idle');
}, []);
const startCamera = React.useCallback(
async (modeOverride?: 'user' | 'environment') => {
if (!navigator.mediaDevices?.getUserMedia) {
setCameraState('unsupported');
return;
}
const mode = modeOverride ?? facingMode;
setCameraState('starting');
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: mode },
audio: false,
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setFacingMode(mode);
setCameraState('ready');
} catch (err) {
const error = err as { name?: string };
if (error?.name === 'NotAllowedError') {
setCameraState('denied');
} else if (error?.name === 'SecurityError') {
setCameraState('blocked');
} else if (error?.name === 'NotFoundError') {
setCameraState('error');
} else {
setCameraState('error');
}
}
},
[facingMode]
);
const handleSwitchCamera = React.useCallback(async () => {
const nextMode = facingMode === 'user' ? 'environment' : 'user';
stopCamera();
await startCamera(nextMode);
}, [facingMode, startCamera, stopCamera]);
const handleCapture = React.useCallback(async () => {
const video = videoRef.current;
if (!video) return;
if (cameraState !== 'ready') {
await startCamera();
return;
}
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
setError(t('upload.cameraError.explanation', 'Camera could not be started.'));
return;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (mirror && facingMode === 'user') {
ctx.translate(width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(video, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
stopCamera();
setCameraState('preview');
}, [cameraState, facingMode, mirror, startCamera, stopCamera, t]);
const handleRetake = React.useCallback(async () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
await startCamera();
}, [previewUrl, startCamera]);
const handleUseImage = React.useCallback(async () => {
if (!previewFile) return;
await uploadFiles([previewFile]);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
setCameraState('idle');
}, [previewFile, previewUrl, uploadFiles]);
const handleAbortCamera = React.useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
stopCamera();
}, [previewUrl, stopCamera]);
React.useEffect(() => {
return () => stopCamera();
}, [stopCamera]);
if (!token) {
return (
<AppShell>
<YStack padding="$4" borderRadius="$card" backgroundColor="$surface">
<Text fontSize="$4" fontWeight="$7">
{t('uploadV2.errors.eventMissing', 'Event not found')}
</Text>
</YStack>
</AppShell>
);
}
return (
<AppShell>
<YStack gap="$4">
{taskId ? (
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.title', 'Your next task')}
</Text>
</XStack>
{taskLoading ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{t('tasks.loading', 'Loading tasks...')}
</Text>
) : task ? (
<>
<Text fontSize="$4" fontWeight="$7" marginTop="$3">
{getTaskValue(task, 'title') ?? getTaskValue(task, 'name') ?? t('tasks.page.title', 'Task')}
</Text>
{getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt') ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt')}
</Text>
) : null}
</>
) : taskError ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{taskError}
</Text>
) : null}
</SurfaceCard>
) : null}
<YStack
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(15, 23, 42, 0.7), rgba(8, 12, 24, 0.9)), radial-gradient(circle at 20% 20%, rgba(255, 79, 216, 0.2), transparent 50%)'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 255, 0.82)), radial-gradient(circle at 20% 20%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), transparent 60%)',
boxShadow: isExpanded
? isDark
? '0 28px 60px rgba(2, 6, 23, 0.55)'
: '0 22px 44px rgba(15, 23, 42, 0.16)'
: isDark
? '0 22px 40px rgba(2, 6, 23, 0.5)'
: '0 18px 32px rgba(15, 23, 42, 0.12)',
borderRadius: isExpanded ? 28 : undefined,
transition: 'box-shadow 360ms ease, border-radius 360ms ease',
}}
>
<YStack
position="relative"
alignItems="center"
justifyContent="center"
style={{
height: isExpanded ? 'min(84vh, 720px)' : 320,
transition: 'height 360ms cubic-bezier(0.22, 0.61, 0.36, 1)',
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt={t('upload.preview.imageAlt', 'Captured photo')}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<video
ref={videoRef}
playsInline
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: cameraState === 'ready' ? 1 : 0,
transform: mirror && facingMode === 'user' ? 'scaleX(-1)' : 'none',
transition: 'opacity 200ms ease',
}}
/>
)}
{(cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview') ? (
<Button
size="$3"
circular
position="absolute"
top="$3"
right="$3"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.8)'}
borderWidth={1}
borderColor={cardBorder}
onPress={handleAbortCamera}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={iconColor} />
</Button>
) : null}
{cameraState !== 'ready' && cameraState !== 'preview' ? (
<YStack alignItems="center" gap="$2" padding="$4">
<Camera size={32} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7" textAlign="center">
{cameraState === 'unsupported'
? t('upload.cameraUnsupported.title', 'Camera not available')
: cameraState === 'blocked'
? t('upload.cameraBlocked.title', 'Camera blocked')
: cameraState === 'denied'
? t('upload.cameraDenied.title', 'Camera access denied')
: t('upload.cameraTitle', 'Camera')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
{cameraState === 'unsupported'
? t('upload.cameraUnsupported.message', 'Your device does not support live camera preview in this browser.')
: cameraState === 'blocked'
? t('upload.cameraBlocked.message', 'Camera access is blocked by the site security policy.')
: cameraState === 'denied'
? t('upload.cameraDenied.prompt', 'We need access to your camera. Allow the request or pick a photo from your gallery.')
: t('uploadV2.preview.subtitle', 'Stay in the flow keep the camera ready.')}
</Text>
<Button
size="$3"
borderRadius="$pill"
backgroundColor="$primary"
onPress={() => startCamera()}
disabled={cameraState === 'unsupported' || cameraState === 'blocked'}
>
{t('upload.buttons.startCamera', 'Start camera')}
</Button>
</YStack>
) : null}
</YStack>
<XStack
gap="$2"
padding={isExpanded ? '$2' : '$3'}
alignItems="center"
justifyContent="space-between"
borderTopWidth={1}
borderColor={cardBorder}
backgroundColor={isDark ? 'rgba(10, 14, 28, 0.7)' : 'rgba(255, 255, 255, 0.75)'}
>
{cameraState === 'preview' ? (
<XStack gap="$2" flex={1} justifyContent="space-between">
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleRetake}
flex={1}
justifyContent="center"
>
<Text fontSize="$2" fontWeight="$6">
{t('upload.review.retake', 'Retake')}
</Text>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor="$primary"
onPress={handleUseImage}
flex={1}
justifyContent="center"
>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('upload.review.keep', 'Use image')}
</Text>
</Button>
</XStack>
) : (
<>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handlePick}
paddingHorizontal="$4"
gap="$2"
alignSelf="center"
flexShrink={0}
justifyContent="center"
>
<Image size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.galleryCta', 'Upload from gallery')}
</Text>
</Button>
<XStack gap="$2">
<XStack alignItems="center" gap="$2">
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleSwitchCamera}
disabled={cameraState === 'unsupported' || cameraState === 'blocked'}
>
<RefreshCcw size={16} color={iconColor} />
</Button>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('upload.controls.switchCamera', 'Switch camera')}
</Text>
</XStack>
{facingMode === 'user' ? (
<Button
size="$3"
circular
backgroundColor={mirror ? '$primary' : mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => setMirror((prev) => !prev)}
>
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
</Button>
) : null}
</XStack>
</>
)}
</XStack>
</YStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$4" fontWeight="$7">
{t('uploadQueue.title', 'Uploads')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('uploadV2.queue.summary', { waiting: queueCount, sending: sendingCount }, '{waiting} waiting, {sending} sending')}
</Text>
{uploading ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{t(
'uploadV2.queue.uploading',
{ name: uploading.name, progress: uploading.progress },
'Uploading {name} · {progress}%'
)}
</Text>
) : null}
{uploadDialog ? (
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7" color={uploadDialog.tone === 'danger' ? '#FCA5A5' : '$color'}>
{uploadDialog.title}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{uploadDialog.description}
</Text>
{uploadDialog.hint ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{uploadDialog.hint}
</Text>
) : null}
</YStack>
) : null}
{error ? (
<Text fontSize="$2" color="#FCA5A5">
{error}
</Text>
) : null}
<XStack gap="$2">
{previewQueueUrls.length > 0 ? (
previewQueueUrls.map((item) => (
<YStack
key={item.key}
flex={1}
height={72}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
>
<img src={item.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</YStack>
))
) : (
<YStack flex={1} height={72} borderRadius="$tile" backgroundColor="$muted" borderWidth={1} borderColor={cardBorder} />
)}
</XStack>
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.title', 'Pending uploads')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{pendingLoading
? t('pendingUploads.loading', 'Loading uploads...')
: t('pendingUploads.subtitle', 'Your photos are waiting for approval.')}
</Text>
{pendingItems.length > 0 ? (
<XStack gap="$2">
{pendingItems.map((photo) => (
<YStack
key={photo.id}
flex={1}
height={72}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
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={iconColor} />
)}
</YStack>
))}
</XStack>
) : (
<Text fontSize="$2" color="$color" opacity={0.6}>
{t('pendingUploads.emptyTitle', 'No pending uploads')}
</Text>
)}
</YStack>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate('../queue')}
alignSelf="flex-start"
width="auto"
paddingHorizontal="$3"
gap="$2"
justifyContent="flex-start"
>
<ListVideo size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.queue.button', 'Queue')}
</Text>
</Button>
</YStack>
</YStack>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={(event) => {
handleFiles(event.target.files);
if (event.target) {
event.target.value = '';
}
}}
/>
</AppShell>
);
}