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'; 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 { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { appearance } = useAppearance(); const isDarkMode = appearance === 'dark'; const { markCompleted } = useGuestTaskProgress(slug); const taskIdParam = searchParams.get('task'); const emotionSlug = searchParams.get('emotion') || ''; const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed'; const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : '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('Keine Aufgabeninformationen gefunden.'); setLoadingTask(false); return; } let active = true; async function loadTask() { try { setLoadingTask(true); setTaskError(null); const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/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 || `Aufgabe ${taskId!}`, description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.', instructions: found.instructions, duration: found.duration || 2, emotion: found.emotion, difficulty: found.difficulty ?? 'medium', }); } else { setTask({ id: taskId!, title: `Aufgabe ${taskId!}`, description: 'Halte den Moment fest und teile ihn mit allen Gästen.', instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', 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('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.'); setTask({ id: taskId!, title: `Aufgabe ${taskId!}`, description: 'Halte den Moment fest und teile ihn mit allen Gästen.', instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', 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; }; }, [slug, taskId, emotionSlug]); // Check upload limits useEffect(() => { if (!slug || !task) return; const checkLimits = async () => { try { const pkg = await getEventPackage(slug); setEventPackage(pkg); if (pkg && pkg.used_photos >= pkg.package.max_photos) { setCanUpload(false); setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.'); } else { setCanUpload(true); } } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.'); } }; checkLimits(); }, [slug, task]); 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('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.'); 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( 'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.' ); } else if (error?.name === 'NotFoundError') { setPermissionState('error'); setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.'); } else { setPermissionState('error'); setPermissionMessage(`Kamera konnte nicht gestartet werden: ${error?.message || 'Unbekannter Fehler'}`); } } }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task]); 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 = `Foto wird in ${countdownValue} Sekunden aufgenommen.`; } else if (mode === 'review') { liveRegionRef.current.textContent = 'Foto aufgenommen. �berpr�fe die Vorschau.'; } else if (mode === 'uploading') { liveRegionRef.current.textContent = 'Foto wird hochgeladen.'; } else { liveRegionRef.current.textContent = ''; } }, [mode, countdownValue]); 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('Kamera nicht bereit. Bitte versuche es erneut.'); setMode('preview'); return; } const video = videoRef.current; const canvas = canvasRef.current; const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.'); setMode('preview'); startCamera(); return; } canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); if (!context) { setUploadError('Canvas konnte nicht initialisiert werden.'); 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('Foto konnte nicht erstellt werden.'); 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]); 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 (!slug || !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(slug!)}/gallery?${params.toString()}`); }, [emotionSlug, navigate, slug, task] ); const handleUsePhoto = useCallback(async () => { if (!slug || !reviewPhoto || !task || !canUpload) return; setMode('uploading'); setUploadProgress(5); setUploadError(null); setStatusMessage('Foto wird vorbereitet...'); if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); } uploadProgressTimerRef.current = window.setInterval(() => { setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev)); }, 400); try { const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined); setUploadProgress(100); setStatusMessage('Upload abgeschlossen.'); markCompleted(task.id); stopStream(); navigateAfterUpload(photoId); } catch (error: any) { console.error('Upload failed', error); setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.'); setMode('review'); } finally { if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); uploadProgressTimerRef.current = null; } setStatusMessage(''); } }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]); 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('Auswahl fehlgeschlagen. Bitte versuche es erneut.'); }; reader.readAsDataURL(file); }, [canUpload]); 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 (
Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen.
); } if (loadingTask) { return (

Aufgabe und Kamera werden vorbereitet ...

); } if (!canUpload) { return (
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos). Kontaktieren Sie den Organisator für ein Package-Upgrade.
); } const renderPrimer = () => ( showPrimer && (

Bereit für dein Shooting?

Suche dir gutes Licht, halte die Stimmung der Aufgabe fest und nutze die Kontrollleiste für Countdown, Grid und Kamerawechsel.

) ); const renderPermissionNotice = () => { if (permissionState === 'granted') return null; if (permissionState === 'unsupported') { return ( Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen. ); } if (permissionState === 'denied' || permissionState === 'error') { return (
{permissionMessage}
); } return ( Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie. ); }; return (
{permissionState !== 'granted' && renderPermissionNotice()}
); }