import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import Header from '../components/Header'; import BottomNav from '../components/BottomNav'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { uploadPhoto, type UploadError } from '../services/photosApi'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { cn } from '@/lib/utils'; import { AlertTriangle, Camera, Grid3X3, ImagePlus, Info, Loader2, RotateCcw, Sparkles, Zap, ZapOff, } from 'lucide-react'; import { getEventPackage, type EventPackage } from '../services/eventApi'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; import { useEventStats } from '../context/EventStatsContext'; interface Task { id: number; title: string; description: string; instructions?: string; duration: number; emotion?: { slug: string; name: string }; difficulty?: 'easy' | 'medium' | 'hard'; } type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported'; type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading'; type CameraPreferences = { facingMode: 'user' | 'environment'; countdownSeconds: number; countdownEnabled: boolean; gridEnabled: boolean; mirrorFrontPreview: boolean; flashPreferred: boolean; }; type TaskPayload = Partial & { id: number }; function isTaskPayload(value: unknown): value is TaskPayload { if (typeof value !== 'object' || value === null) { return false; } const candidate = value as { id?: unknown }; return typeof candidate.id === 'number'; } function getErrorName(error: unknown): string | undefined { if (typeof error === 'object' && error !== null && 'name' in error) { const name = (error as { name?: unknown }).name; return typeof name === 'string' ? name : undefined; } return undefined; } const DEFAULT_PREFS: CameraPreferences = { facingMode: 'environment', countdownSeconds: 3, countdownEnabled: true, gridEnabled: true, mirrorFrontPreview: true, flashPreferred: false, }; const LIMIT_CARD_STYLES: Record = { neutral: { card: 'border-slate-200 bg-white/90 text-slate-900 dark:border-white/15 dark:bg-white/10 dark:text-white', badge: 'bg-slate-900/10 text-slate-900 dark:bg-white/20 dark:text-white', bar: 'bg-emerald-500', }, warning: { card: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-50', badge: 'bg-white/70 text-amber-900 dark:bg-amber-400/25 dark:text-amber-50', bar: 'bg-amber-500', }, danger: { card: 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-400/50 dark:bg-rose-500/15 dark:text-rose-50', badge: 'bg-white/70 text-rose-900 dark:bg-rose-400/20 dark:text-rose-50', bar: 'bg-rose-500', }, }; export default function UploadPage() { const { token } = useParams<{ token: string }>(); const eventKey = token ?? ''; const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { markCompleted, completedCount } = useGuestTaskProgress(token); const { t } = useTranslation(); const stats = useEventStats(); const taskIdParam = searchParams.get('task'); const emotionSlug = searchParams.get('emotion') || ''; const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed'; const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs'; const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; const [task, setTask] = useState(null); const [loadingTask, setLoadingTask] = useState(true); const [permissionState, setPermissionState] = useState('idle'); const [permissionMessage, setPermissionMessage] = useState(null); const [preferences, setPreferences] = useState(DEFAULT_PREFS); const [mode, setMode] = useState('preview'); const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds); const [statusMessage, setStatusMessage] = useState(''); const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [uploadWarning, setUploadWarning] = useState(null); const [errorDialog, setErrorDialog] = useState(null); const [eventPackage, setEventPackage] = useState(null); const [canUpload, setCanUpload] = useState(true); const limitCards = useMemo( () => buildLimitSummaries(eventPackage?.limits ?? null, t), [eventPackage?.limits, t] ); const [showPrimer, setShowPrimer] = useState(() => { if (typeof window === 'undefined') return false; return window.localStorage.getItem(primerStorageKey) !== '1'; }); const videoRef = useRef(null); const canvasRef = useRef(null); const fileInputRef = useRef(null); const liveRegionRef = useRef(null); const streamRef = useRef(null); const countdownTimerRef = useRef(null); const uploadProgressTimerRef = useRef(null); const taskId = useMemo(() => { if (!taskIdParam) return null; const parsed = parseInt(taskIdParam, 10); return Number.isFinite(parsed) ? parsed : null; }, [taskIdParam]); // Load preferences from storage useEffect(() => { if (typeof window === 'undefined') return; try { const stored = window.localStorage.getItem(prefsStorageKey); if (stored) { const parsed = JSON.parse(stored) as Partial; setPreferences((prev) => ({ ...prev, ...parsed })); } } catch (error) { console.warn('Failed to parse camera preferences', error); } }, [prefsStorageKey]); // Persist preferences when they change useEffect(() => { if (typeof window === 'undefined') return; try { window.localStorage.setItem(prefsStorageKey, JSON.stringify(preferences)); } catch (error) { console.warn('Failed to persist camera preferences', error); } }, [preferences, prefsStorageKey]); // Load task metadata useEffect(() => { if (!token || taskId === null) { setLoadingTask(false); return; } let active = true; async function loadTask() { const currentTaskId = taskId; const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`); const fallbackDescription = t('upload.taskInfo.fallbackDescription'); const fallbackInstructions = t('upload.taskInfo.fallbackInstructions'); try { setLoadingTask(true); const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`); if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); const payload = (await res.json()) as unknown; const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : []; const found = entries.find((entry) => entry.id === currentTaskId) ?? null; if (!active) return; if (found) { setTask({ id: found.id, title: found.title || fallbackTitle, description: found.description || fallbackDescription, instructions: found.instructions ?? fallbackInstructions, duration: found.duration || 2, emotion: found.emotion, difficulty: found.difficulty ?? 'medium', }); } else { setTask({ id: currentTaskId, title: fallbackTitle, description: fallbackDescription, instructions: fallbackInstructions, duration: 2, emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } : undefined, difficulty: 'medium', }); } } catch (error) { console.error('Failed to fetch task', error); if (active) { setTask({ id: currentTaskId, title: fallbackTitle, description: fallbackDescription, instructions: fallbackInstructions, duration: 2, emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } : undefined, difficulty: 'medium', }); } } finally { if (active) setLoadingTask(false); } } loadTask(); return () => { active = false; }; }, [eventKey, taskId, emotionSlug, t, token]); // Check upload limits useEffect(() => { if (!eventKey || !task) return; const checkLimits = async () => { try { const pkg = await getEventPackage(eventKey); setEventPackage(pkg); if (!pkg) { setCanUpload(true); setUploadError(null); setUploadWarning(null); return; } const photoLimits = pkg.limits?.photos ?? null; const galleryLimits = pkg.limits?.gallery ?? null; let canUploadCurrent = pkg.limits?.can_upload_photos ?? true; let errorMessage: string | null = null; if (photoLimits?.state === 'limit_reached') { canUploadCurrent = false; if (typeof photoLimits.limit === 'number') { errorMessage = t('upload.limitReached') .replace('{used}', `${photoLimits.used}`) .replace('{max}', `${photoLimits.limit}`); } else { errorMessage = t('upload.errors.photoLimit'); } } if (galleryLimits?.state === 'expired') { canUploadCurrent = false; errorMessage = t('upload.errors.galleryExpired'); } setCanUpload(canUploadCurrent); setUploadError(errorMessage); setUploadWarning(null); } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); setUploadError(t('upload.limitCheckError')); setUploadWarning(null); } }; checkLimits(); }, [eventKey, task, t]); const stopStream = useCallback(() => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } }, []); const attachStreamToVideo = useCallback((stream: MediaStream) => { if (!videoRef.current) return; videoRef.current.srcObject = stream; videoRef.current .play() .then(() => { if (videoRef.current) { videoRef.current.muted = true; } }) .catch((error) => console.error('Video play error', error)); }, []); const createConstraint = useCallback( (mode: 'user' | 'environment'): MediaStreamConstraints => ({ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, facingMode: { ideal: mode }, }, audio: false, }), [] ); const startCamera = useCallback(async () => { if (!supportsCamera) { setPermissionState('unsupported'); setPermissionMessage(t('upload.cameraUnsupported.message')); return; } if (!task || mode === 'uploading') return; try { setPermissionState('prompt'); setPermissionMessage(null); const stream = await navigator.mediaDevices.getUserMedia(createConstraint(preferences.facingMode)); stopStream(); streamRef.current = stream; attachStreamToVideo(stream); setPermissionState('granted'); } catch (error: unknown) { console.error('Camera access error', error); stopStream(); const errorName = getErrorName(error); if (errorName === 'NotAllowedError') { setPermissionState('denied'); setPermissionMessage(t('upload.cameraDenied.explanation')); } else if (errorName === 'NotFoundError') { setPermissionState('error'); setPermissionMessage(t('upload.cameraUnsupported.message')); } else { setPermissionState('error'); setPermissionMessage(t('upload.cameraError.explanation')); } } }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]); useEffect(() => { if (!task || loadingTask) return; startCamera(); return () => { stopStream(); }; }, [task, loadingTask, startCamera, stopStream, preferences.facingMode]); // Countdown live region updates useEffect(() => { if (!liveRegionRef.current) return; if (mode === 'countdown') { liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`); } else if (mode === 'review') { liveRegionRef.current.textContent = t('upload.review.readyAnnouncement'); } else if (mode === 'uploading') { liveRegionRef.current.textContent = t('upload.status.uploading'); } else { liveRegionRef.current.textContent = ''; } }, [mode, countdownValue, t]); const dismissPrimer = useCallback(() => { setShowPrimer(false); if (typeof window !== 'undefined') { window.localStorage.setItem(primerStorageKey, '1'); } }, [primerStorageKey]); const handleToggleGrid = useCallback(() => { setPreferences((prev) => ({ ...prev, gridEnabled: !prev.gridEnabled })); }, []); const handleToggleMirror = useCallback(() => { setPreferences((prev) => ({ ...prev, mirrorFrontPreview: !prev.mirrorFrontPreview })); }, []); const handleToggleCountdown = useCallback(() => { setPreferences((prev) => ({ ...prev, countdownEnabled: !prev.countdownEnabled })); }, []); const handleSwitchCamera = useCallback(() => { setPreferences((prev) => ({ ...prev, facingMode: prev.facingMode === 'user' ? 'environment' : 'user', })); }, []); const handleToggleFlashPreference = useCallback(() => { setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred })); }, []); const resetCountdownTimer = useCallback(() => { if (countdownTimerRef.current) { window.clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } }, []); const performCapture = useCallback(() => { if (!videoRef.current || !canvasRef.current) { setUploadError(t('upload.captureError')); setMode('preview'); return; } const video = videoRef.current; const canvas = canvasRef.current; const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { setUploadError(t('upload.feedError')); setMode('preview'); startCamera(); return; } canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); if (!context) { setUploadError(t('upload.canvasError')); setMode('preview'); return; } context.save(); const shouldMirror = preferences.facingMode === 'user' && preferences.mirrorFrontPreview; if (shouldMirror) { context.scale(-1, 1); context.drawImage(video, -width, 0, width, height); } else { context.drawImage(video, 0, 0, width, height); } context.restore(); canvas.toBlob( (blob) => { if (!blob) { setUploadError(t('upload.captureError')); setMode('preview'); return; } const timestamp = Date.now(); const fileName = `photo-${timestamp}.jpg`; const file = new File([blob], fileName, { type: 'image/jpeg', lastModified: timestamp }); const dataUrl = canvas.toDataURL('image/jpeg', 0.92); setReviewPhoto({ dataUrl, file }); setMode('review'); }, 'image/jpeg', 0.92 ); }, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]); const beginCapture = useCallback(() => { setUploadError(null); if (preferences.countdownEnabled && preferences.countdownSeconds > 0) { setMode('countdown'); setCountdownValue(preferences.countdownSeconds); resetCountdownTimer(); countdownTimerRef.current = window.setInterval(() => { setCountdownValue((prev) => { if (prev <= 1) { resetCountdownTimer(); performCapture(); return preferences.countdownSeconds; } return prev - 1; }); }, 1000); } else { performCapture(); } }, [performCapture, preferences.countdownEnabled, preferences.countdownSeconds, resetCountdownTimer]); const handleRetake = useCallback(() => { setReviewPhoto(null); setUploadProgress(0); setUploadError(null); setMode('preview'); }, []); const navigateAfterUpload = useCallback( (photoId: number | undefined) => { if (!eventKey || !task) return; const params = new URLSearchParams(); params.set('uploaded', 'true'); if (task.id) params.set('task', String(task.id)); if (photoId) params.set('photo', String(photoId)); if (emotionSlug) params.set('emotion', emotionSlug); navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`); }, [emotionSlug, navigate, eventKey, task] ); const handleUsePhoto = useCallback(async () => { if (!eventKey || !reviewPhoto || !task || !canUpload) return; setMode('uploading'); setUploadProgress(5); setUploadError(null); setStatusMessage(t('upload.status.preparing')); if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); } uploadProgressTimerRef.current = window.setInterval(() => { setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev)); }, 400); try { const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined); setUploadProgress(100); setStatusMessage(t('upload.status.completed')); markCompleted(task.id); stopStream(); navigateAfterUpload(photoId); } catch (error: unknown) { console.error('Upload failed', error); const uploadErr = error as UploadError; setUploadWarning(null); const meta = uploadErr.meta as Record | undefined; const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t); setErrorDialog(dialog); setUploadError(dialog.description); if ( uploadErr.code === 'photo_limit_exceeded' || uploadErr.code === 'upload_device_limit' || uploadErr.code === 'event_package_missing' || uploadErr.code === 'event_not_found' || uploadErr.code === 'gallery_expired' ) { setCanUpload(false); } setMode('review'); } finally { if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); uploadProgressTimerRef.current = null; } setStatusMessage(''); } }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]); const handleGalleryPick = useCallback((event: React.ChangeEvent) => { if (!canUpload) return; const file = event.target.files?.[0]; if (!file) return; setUploadError(null); const reader = new FileReader(); reader.onload = () => { setReviewPhoto({ dataUrl: reader.result as string, file }); setMode('review'); }; reader.onerror = () => { setUploadError(t('upload.galleryPickError')); }; reader.readAsDataURL(file); }, [canUpload, t]); const handleOpenInspiration = useCallback(() => { if (!eventKey) return; navigate(`/e/${encodeURIComponent(eventKey)}/gallery`); }, [eventKey, navigate]); const difficultyBadgeClass = useMemo(() => { if (!task) return 'text-white'; switch (task.difficulty) { case 'easy': return 'text-emerald-400'; case 'hard': return 'text-rose-400'; default: return 'text-amber-300'; } }, [task]); const isCameraActive = permissionState === 'granted' && mode !== 'uploading'; const showTaskOverlay = task && mode !== 'uploading'; const relativeLastUpload = useMemo( () => formatRelativeTimeLabel(stats.latestPhotoAt, t), [stats.latestPhotoAt, t], ); useEffect(() => () => { resetCountdownTimer(); if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); } }, [resetCountdownTimer]); const heroEmotion = task?.emotion?.name ?? t('upload.hud.moodFallback'); const limitStatusSection = limitCards.length > 0 ? (

{t('upload.limitSummary.title')}

{t('upload.limitSummary.subtitle')}

{t('upload.limitSummary.badgeLabel')}
{limitCards.map((card) => { const styles = LIMIT_CARD_STYLES[card.tone]; return (

{card.label}

{card.valueLabel}

{card.badgeLabel}
{card.progress !== null && (
)}

{card.description}

); })}
) : null; const dialogToneIconClass: Record, string> = { danger: 'text-rose-500', warning: 'text-amber-500', info: 'text-sky-500', }; const errorDialogNode = ( { if (!open) setErrorDialog(null); }}>
{errorDialog?.tone === 'info' ? ( ) : ( )} {errorDialog?.title ?? ''}
{errorDialog?.description ?? ''} {errorDialog?.hint ? (

{errorDialog.hint}

) : null}
); const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => ( <>
{content}
{errorDialogNode} ); if (!supportsCamera && !task) { return renderWithDialog( <> {limitStatusSection} {t('upload.cameraUnsupported.message')} ); } if (loadingTask) { return renderWithDialog(

{t('upload.preparing')}

); } if (!canUpload) { return renderWithDialog( <> {limitStatusSection} {t('upload.limitReached') .replace('{used}', `${eventPackage?.used_photos || 0}`) .replace('{max}', `${eventPackage?.package.max_photos || 0}`)} ); } const renderPrimer = () => ( showPrimer && (

{t('upload.primer.title')}

{t('upload.primer.body.part1')}{' '} {t('upload.primer.body.part2')}

) ); const renderPermissionNotice = () => { if (permissionState === 'granted') return null; if (permissionState === 'unsupported') { return ( {t('upload.cameraUnsupported.message')} ); } if (permissionState === 'denied' || permissionState === 'error') { return (
{permissionMessage}
); } return ( {t('upload.cameraDenied.prompt')} ); }; return renderWithDialog( <>
{permissionState !== 'granted' && renderPermissionNotice()} {limitStatusSection} {renderPrimer()}
, 'space-y-6 pb-[140px]' ); } function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string { if (!value) { return t('upload.hud.relative.now'); } const timestamp = new Date(value).getTime(); if (Number.isNaN(timestamp)) { return t('upload.hud.relative.now'); } const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000)); if (diffMinutes < 1) { return t('upload.hud.relative.now'); } if (diffMinutes < 60) { return t('upload.hud.relative.minutes').replace('{count}', `${diffMinutes}`); } const hours = Math.round(diffMinutes / 60); if (hours < 24) { return t('upload.hud.relative.hours').replace('{count}', `${hours}`); } const days = Math.round(hours / 24); return t('upload.hud.relative.days').replace('{count}', `${days}`); }