import React, { useCallback, useEffect, useMemo, useRef, useState } 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 { uploadPhoto } from '../services/photosApi'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { useAppearance } from '../../hooks/use-appearance'; 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 } from '../i18n/useTranslation'; 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; }; const DEFAULT_PREFS: CameraPreferences = { facingMode: 'environment', countdownSeconds: 3, countdownEnabled: true, gridEnabled: true, mirrorFrontPreview: true, flashPreferred: false, }; export default function UploadPage() { const { token: slug } = useParams<{ token: string }>(); const eventKey = slug ?? ''; const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { appearance } = useAppearance(); const isDarkMode = appearance === 'dark'; const { markCompleted } = useGuestTaskProgress(slug); const { t } = useTranslation(); 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 [taskError, setTaskError] = useState(null); 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 [eventPackage, setEventPackage] = useState(null); const [canUpload, setCanUpload] = useState(true); 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 (!slug || !taskId) { setTaskError(t('upload.loadError.title')); setLoadingTask(false); return; } let active = true; async function loadTask() { const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`); const fallbackDescription = t('upload.taskInfo.fallbackDescription'); const fallbackInstructions = t('upload.taskInfo.fallbackInstructions'); try { setLoadingTask(true); setTaskError(null); const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`); if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); const tasks = await res.json(); const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : 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: taskId!, 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) { setTaskError(t('upload.loadError.title')); setTask({ id: taskId!, 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]); // Check upload limits useEffect(() => { if (!eventKey || !task) return; const checkLimits = async () => { try { const pkg = await getEventPackage(eventKey); setEventPackage(pkg); if (pkg && pkg.used_photos >= pkg.package.max_photos) { setCanUpload(false); const maxLabel = pkg.package.max_photos == null ? t('upload.limitUnlimited') : `${pkg.package.max_photos}`; setUploadError( t('upload.limitReached') .replace('{used}', `${pkg.used_photos}`) .replace('{max}', maxLabel) ); } else { setCanUpload(true); setUploadError(null); } } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); setUploadError(t('upload.limitCheckError')); } }; 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: any) { console.error('Camera access error', error); stopStream(); if (error?.name === 'NotAllowedError') { setPermissionState('denied'); setPermissionMessage(t('upload.cameraDenied.explanation')); } else if (error?.name === '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: any) { console.error('Upload failed', error); setUploadError(error?.message || t('upload.status.failed')); 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 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 isUploadDisabled = !canUpload || !task; useEffect(() => () => { resetCountdownTimer(); if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); } }, [resetCountdownTimer]); if (!supportsCamera && !task) { return (
{t('upload.cameraUnsupported.message')}
); } if (loadingTask) { return (

{t('upload.preparing')}

); } if (!canUpload) { return (
{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 (
{permissionState !== 'granted' && renderPermissionNotice()}
); }