import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { ArrowRight, Camera, FlipHorizontal, Image, ListVideo, RefreshCcw, Sparkles, UploadCloud, X, Zap, ZapOff } 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 { useGuestThemeVariant } from '../lib/guestTheme'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from '@/shared/guest/i18n/useTranslation'; import { useGuestTaskProgress } from '@/shared/guest/hooks/useGuestTaskProgress'; import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/shared/guest/lib/uploadErrorDialog'; import { fetchTasks, type TaskItem } from '../services/tasksApi'; import { pushGuestToast } from '../lib/toast'; import { getBentoSurfaceTokens } from '../lib/bento'; import { buildEventPath } from '../lib/routes'; import { compressPhoto, formatBytes } from '@/shared/guest/lib/image'; 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; 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, tasksEnabled } = useEventData(); const identity = useOptionalGuestIdentity(); const { items, add } = useUploadQueue(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const mockPreviewEnabled = import.meta.env.DEV && ['1', 'true', 'yes'].includes((searchParams.get('mockPreview') ?? '').toLowerCase()); const { t, locale } = useTranslation(); const { markCompleted } = useGuestTaskProgress(token ?? undefined); const inputRef = React.useRef(null); const videoRef = React.useRef(null); const streamRef = React.useRef(null); const mockPreviewTimerRef = React.useRef(null); const [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null); const [error, setError] = React.useState(null); const [uploadDialog, setUploadDialog] = React.useState(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 [flashPreferred, setFlashPreferred] = React.useState(false); const [previewFile, setPreviewFile] = React.useState(null); const [previewUrl, setPreviewUrl] = React.useState(null); const { isDark } = useGuestThemeVariant(); const bentoSurface = getBentoSurfaceTokens(isDark); const cardBorder = bentoSurface.borderColor; const cardShadow = bentoSurface.shadow; const hardShadow = isDark ? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)' : '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)'; 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 fabShadow = isDark ? '0 20px 40px rgba(255, 79, 216, 0.38), 0 0 0 8px rgba(255, 79, 216, 0.16)' : '0 18px 32px rgba(15, 23, 42, 0.2), 0 0 0 8px rgba(255, 255, 255, 0.7)'; const accessoryShadow = isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)'; 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 = tasksEnabled && Number.isFinite(parsedTaskId) ? parsedTaskId : undefined; const [task, setTask] = React.useState(null); const [taskLoading, setTaskLoading] = React.useState(false); const [taskError, setTaskError] = React.useState(null); const [pendingItems, setPendingItems] = React.useState([]); const [pendingCount, setPendingCount] = React.useState(0); const [pendingLoading, setPendingLoading] = React.useState(false); const optimizeTargetBytes = 1_500_000; const optimizeMaxEdge = 2560; 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 || !tasksEnabled) { 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, tasksEnabled, 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 prepareUploadFile = React.useCallback( async (file: File) => { const shouldOptimize = file.size > optimizeTargetBytes || file.type !== 'image/jpeg'; if (!shouldOptimize) { return file; } try { const optimized = await compressPhoto(file, { targetBytes: optimizeTargetBytes, maxEdge: optimizeMaxEdge }); if (optimized.size >= file.size) { return file; } if (optimized.size < file.size - 50_000) { const saved = formatBytes(file.size - optimized.size); pushGuestToast({ text: t( 'upload.optimizedNotice', { saved }, 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}' ), type: 'info', }); } return optimized; } catch (error) { console.warn('Image optimization failed, uploading original', error); pushGuestToast({ text: t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.'), type: 'info', }); return file; } }, [optimizeMaxEdge, optimizeTargetBytes, t] ); const persistMyPhotoId = React.useCallback((photoId: number) => { if (!photoId) return; try { const raw = localStorage.getItem('my-photo-ids'); const parsed = raw ? JSON.parse(raw) : []; const list = Array.isArray(parsed) ? parsed.filter((value) => Number.isFinite(Number(value))) : []; if (!list.includes(photoId)) { localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...list])); } } catch (error) { console.warn('Failed to persist my-photo-ids', 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); let redirectPhotoId: number | null = null; for (const file of files) { const preparedFile = await prepareUploadFile(file); if (!navigator.onLine) { await enqueueFile(preparedFile); pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' }); continue; } try { setUploading({ name: file.name, progress: 0 }); const photoId = await uploadPhoto(token, preparedFile, 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(); } pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' }); void loadPending(); persistMyPhotoId(photoId); if (autoApprove && photoId) { redirectPhotoId = photoId; } } catch (err) { const uploadErr = err as { code?: string; meta?: Record }; console.error('Upload failed, enqueueing', err); setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t)); await enqueueFile(preparedFile); } finally { setUploading(null); } } if (autoApprove && redirectPhotoId) { navigate(buildEventPath(token, `/gallery?photo=${redirectPhotoId}`)); } }, [ autoApprove, enqueueFile, identity?.name, loadPending, markCompleted, navigate, prepareUploadFile, 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/')); const next = files[0]; if (!next) { setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.')); return; } if (previewUrl) { URL.revokeObjectURL(previewUrl); } const url = URL.createObjectURL(next); setPreviewFile(next); setPreviewUrl(url); setCameraState('preview'); }, [previewUrl, t] ); const handlePick = React.useCallback(() => { inputRef.current?.click(); }, []); const stopCamera = React.useCallback(() => { if (mockPreviewTimerRef.current) { window.clearTimeout(mockPreviewTimerRef.current); mockPreviewTimerRef.current = null; } 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 (mockPreviewEnabled) { const mode = modeOverride ?? facingMode; if (mockPreviewTimerRef.current) { window.clearTimeout(mockPreviewTimerRef.current); } setFacingMode(mode); setCameraState('starting'); mockPreviewTimerRef.current = window.setTimeout(() => { setCameraState('ready'); }, 420); return; } 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, mockPreviewEnabled] ); const handleSwitchCamera = React.useCallback(async () => { const nextMode = facingMode === 'user' ? 'environment' : 'user'; stopCamera(); await startCamera(nextMode); }, [facingMode, startCamera, stopCamera]); const handleToggleFlash = React.useCallback(() => { setFlashPreferred((prev) => !prev); }, []); const handleCapture = React.useCallback(async () => { if (mockPreviewEnabled) { const canvas = document.createElement('canvas'); const width = 1400; const height = 1000; canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) return; const gradient = ctx.createLinearGradient(0, 0, width, height); gradient.addColorStop(0, '#ff6ad5'); gradient.addColorStop(0.5, '#8b5cf6'); gradient.addColorStop(1, '#38bdf8'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, height); ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.fillRect(80, 80, width - 160, height - 160); ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 6; ctx.strokeRect(80, 80, width - 160, height - 160); ctx.fillStyle = 'rgba(15, 23, 42, 0.7)'; ctx.font = '700 64px system-ui, sans-serif'; ctx.fillText('Mock Capture', 120, height - 140); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92)); if (!blob) return; const file = new File([blob], `mock-${Date.now()}.jpg`, { type: blob.type }); const url = URL.createObjectURL(file); setPreviewFile(file); setPreviewUrl(url); stopCamera(); setCameraState('preview'); return; } 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((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, mockPreviewEnabled, startCamera, stopCamera, t]); React.useEffect(() => { if (!mockPreviewEnabled) return; void startCamera(); return () => { if (mockPreviewTimerRef.current) { window.clearTimeout(mockPreviewTimerRef.current); mockPreviewTimerRef.current = null; } }; }, [mockPreviewEnabled, startCamera]); const handleRetake = React.useCallback(async () => { if (previewUrl) { URL.revokeObjectURL(previewUrl); } setPreviewFile(null); setPreviewUrl(null); if (cameraState === 'preview') { await startCamera(); } }, [cameraState, 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 ( {t('uploadV2.errors.eventMissing', 'Event not found')} ); } return ( {taskId ? ( {t('tasks.page.title', 'Your next task')} {taskLoading ? ( {t('tasks.loading', 'Loading tasks...')} ) : task ? ( <> {getTaskValue(task, 'title') ?? getTaskValue(task, 'name') ?? t('tasks.page.title', 'Task')} {getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt') ? ( {getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt')} ) : null} ) : taskError ? ( {taskError} ) : null} ) : null} {previewUrl ? ( {t('upload.preview.imageAlt', ) : ( {cameraState === 'preview' ? null : ( {facingMode === 'user' ? ( ) : null} )} {t('uploadQueue.title', 'Uploads')} {t('uploadV2.queue.summary', { waiting: queueCount, sending: sendingCount }, '{waiting} waiting, {sending} sending')} {uploading ? ( {t( 'uploadV2.queue.uploading', { name: uploading.name, progress: uploading.progress }, 'Uploading {name} · {progress}%' )} ) : null} {uploadDialog ? ( {uploadDialog.title} {uploadDialog.description} {uploadDialog.hint ? ( {uploadDialog.hint} ) : null} ) : null} {error ? ( {error} ) : null} {previewQueueUrls.length > 0 ? ( previewQueueUrls.map((item) => ( )) ) : ( )} {t('pendingUploads.title', 'Pending uploads')} {pendingLoading ? t('pendingUploads.loading', 'Loading uploads...') : t('pendingUploads.subtitle', 'Your photos are waiting for approval.')} {pendingItems.length > 0 ? ( {pendingItems.map((photo) => ( {photo.thumbnail_url ? ( ) : ( )} ))} ) : ( {t('pendingUploads.emptyTitle', 'No pending uploads')} )} { handleFiles(event.target.files); if (event.target) { event.target.value = ''; } }} /> ); }