Files
fotospiel-app/resources/js/guest/pages/UploadPage.tsx

1467 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
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,
ChevronDown,
Grid3X3,
Menu,
ImagePlus,
Info,
Loader2,
RotateCcw,
Timer,
Sparkles,
FlipHorizontal,
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';
import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
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<Task> & { 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<LimitSummaryCard['tone'], { card: string; badge: string; bar: string }> = {
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 } = useGuestTaskProgress(token);
const identity = useGuestIdentity();
const { t, locale } = useTranslation();
const stats = useEventStats();
const { branding } = useEventBranding();
const { event } = useEventData();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
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<Task | null>(null);
const [loadingTask, setLoadingTask] = useState(true);
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
const [preferences, setPreferences] = useState<CameraPreferences>(DEFAULT_PREFS);
const [mode, setMode] = useState<CameraMode>('preview');
const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds);
const [statusMessage, setStatusMessage] = useState<string>('');
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [immersiveMode, setImmersiveMode] = useState(false);
const [showCelebration, setShowCelebration] = useState(false);
const [showHeroOverlay, setShowHeroOverlay] = useState(true);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
const limitCards = useMemo<LimitSummaryCard[]>(
() => buildLimitSummaries(eventPackage?.limits ?? null, t),
[eventPackage?.limits, t]
);
useEffect(() => {
if (typeof document === 'undefined') return undefined;
const className = 'guest-immersive';
if (immersiveMode) {
document.body.classList.add(className);
} else {
document.body.classList.remove(className);
}
return () => {
document.body.classList.remove(className);
};
}, [immersiveMode]);
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(primerStorageKey) !== '1';
});
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const liveRegionRef = useRef<HTMLDivElement | null>(null);
const cameraViewportRef = useRef<HTMLDivElement | null>(null);
const cameraShellRef = useRef<HTMLElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const countdownTimerRef = useRef<number | null>(null);
const uploadProgressTimerRef = useRef<number | null>(null);
const taskId = useMemo(() => {
if (!taskIdParam) return null;
const parsed = parseInt(taskIdParam, 10);
return Number.isFinite(parsed) ? parsed : null;
}, [taskIdParam]);
const tasksUrl = useMemo(() => {
if (!eventKey) return '/tasks';
return `/e/${encodeURIComponent(eventKey)}/tasks`;
}, [eventKey]);
// 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<CameraPreferences>;
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() {
if (taskId === null) return;
const currentTaskId = Number(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?locale=${encodeURIComponent(locale)}`,
{
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
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)
: Array.isArray((payload as any)?.data)
? (payload as any).data.filter(isTaskPayload)
: Array.isArray((payload as any)?.tasks)
? (payload as any).tasks.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, locale]);
// Check upload limits
useEffect(() => {
if (!eventKey) 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, 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 (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, t]);
useEffect(() => {
if (loadingTask) return;
startCamera();
return () => {
stopStream();
};
}, [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 handleToggleImmersive = useCallback(async () => {
setImmersiveMode((prev) => !prev);
const shell = cameraShellRef.current;
if (!shell) return;
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
: false;
if (prefersReducedMotion) return;
try {
if (!document.fullscreenElement) {
await shell.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (error) {
console.warn('Fullscreen toggle failed', error);
}
}, []);
const triggerConfetti = 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 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) 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?.id]
);
const handleUsePhoto = useCallback(async () => {
if (!eventKey || !reviewPhoto || !canUpload) return;
setMode('uploading');
setUploadProgress(2);
setUploadError(null);
setUploadWarning(null);
setStatusMessage(t('upload.status.preparing'));
if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current);
uploadProgressTimerRef.current = null;
}
const maxEdge = 2400;
const targetBytes = 4_000_000;
let fileForUpload = reviewPhoto.file;
try {
setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
const optimized = await compressPhoto(reviewPhoto.file, {
maxEdge,
targetBytes,
qualityStart: 0.82,
});
fileForUpload = optimized;
setUploadProgress(10);
if (optimized.size < reviewPhoto.file.size - 50_000) {
const saved = formatBytes(reviewPhoto.file.size - optimized.size);
setUploadWarning(
t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}')
.replace('{saved}', saved)
);
}
} catch (e) {
console.warn('Image optimization failed, uploading original', e);
setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich wir laden das Original hoch.'));
}
try {
const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, {
maxRetries: 2,
guestName: identity.name || undefined,
onProgress: (percent) => {
setUploadProgress(Math.max(15, Math.min(98, percent)));
setStatusMessage(t('upload.status.uploading'));
},
onRetry: (attempt) => {
setUploadWarning(
t('upload.retrying', 'Verbindung holperig neuer Versuch ({attempt}).')
.replace('{attempt}', `${attempt}`)
);
},
});
setUploadProgress(100);
setStatusMessage(t('upload.status.completed'));
if (task?.id) {
markCompleted(task.id);
}
setShowCelebration(true);
if (typeof window !== 'undefined') {
window.setTimeout(() => setShowCelebration(false), 1800);
}
void triggerConfetti();
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (photoId && !arr.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
}
} catch (error) {
console.warn('Failed to persist my-photo-ids', error);
}
await new Promise((resolve) => {
window.setTimeout(resolve, 420);
});
stopStream();
navigateAfterUpload(photoId);
} catch (error: unknown) {
console.error('Upload failed', error);
const uploadErr = error as UploadError;
setUploadWarning(null);
const meta = uploadErr.meta as Record<string, unknown> | undefined;
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t);
setErrorDialog(dialog);
setUploadError(dialog.description);
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
setUploadWarning(
t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.')
);
}
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 {
setStatusMessage('');
}
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti]);
const handleGalleryPick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
const file = event.target.files?.[0];
if (!file) return;
setUploadError(null);
setUploadWarning(null);
setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
let prepared = file;
try {
prepared = await compressPhoto(file, {
maxEdge: 2400,
targetBytes: 4_000_000,
qualityStart: 0.82,
});
if (prepared.size < file.size - 50_000) {
const saved = formatBytes(file.size - prepared.size);
setUploadWarning(
t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}')
.replace('{saved}', saved)
);
}
} catch (error) {
console.warn('Gallery image optimization failed, falling back to original', error);
setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich wir laden das Original hoch.'));
}
if (prepared.size > 12_000_000) {
setStatusMessage('');
setUploadError(
t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.')
);
event.target.value = '';
return;
}
const dataUrl = await readAsDataUrl(prepared);
setReviewPhoto({ dataUrl, file: prepared });
setMode('review');
setStatusMessage('');
event.target.value = '';
}, [canUpload, t]);
const emotionLabel = useMemo(() => {
if (task?.emotion?.name) return task.emotion.name;
if (emotionSlug) {
return emotionSlug.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}
return t('upload.hud.moodFallback');
}, [emotionSlug, t, task?.emotion?.name]);
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],
);
const socialChips = useMemo(
() => [
{
id: 'online',
label: t('upload.hud.cards.online'),
value: stats.onlineGuests > 0 ? `${stats.onlineGuests}` : '0',
},
{
id: 'emotion',
label: t('upload.taskInfo.emotion').replace('{value}', emotionLabel),
value: t('upload.hud.moodLabel').replace('{mood}', emotionLabel),
},
{
id: 'last-upload',
label: t('upload.hud.cards.lastUpload'),
value: relativeLastUpload,
},
],
[emotionLabel, relativeLastUpload, stats.onlineGuests, t],
);
useEffect(() => () => {
resetCountdownTimer();
if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current);
}
}, [resetCountdownTimer]);
useEffect(() => {
setTaskDetailsExpanded(false);
}, [task?.id]);
useEffect(() => {
if (task) {
setShowHeroOverlay(false);
} else {
setShowHeroOverlay(true);
}
}, [task]);
const handlePrimaryAction = useCallback(() => {
setShowHeroOverlay(false);
if (!isCameraActive) {
startCamera();
return;
}
beginCapture();
}, [beginCapture, isCameraActive, startCamera]);
const taskFloatingCard = showTaskOverlay && task ? (
<button
type="button"
onClick={() => setTaskDetailsExpanded((prev) => !prev)}
className="absolute left-4 right-4 top-6 z-30 rounded-3xl border border-white/40 bg-black/60 p-4 text-left text-white shadow-2xl backdrop-blur transition hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/60 sm:left-6 sm:right-6 sm:top-8"
>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="flex items-center gap-2 rounded-full text-[11px]">
<Sparkles className="h-3.5 w-3.5" />
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
</Badge>
<span className={cn('text-xs font-semibold uppercase tracking-wide', difficultyBadgeClass)}>
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
</span>
<span className="ml-auto flex items-center text-xs uppercase tracking-wide text-white/70">
{emotionLabel}
<ChevronDown
className={cn('ml-1 h-3.5 w-3.5 transition', taskDetailsExpanded ? 'rotate-180' : 'rotate-0')}
/>
</span>
</div>
<p className="mt-3 text-sm font-semibold leading-snug">{task.title}</p>
{taskDetailsExpanded ? (
<div className="mt-2 space-y-2 text-xs text-white/80">
<p>{task.description}</p>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
{task.instructions && (
<span>
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
</span>
)}
{preferences.countdownEnabled && (
<span className="rounded-full border border-white/20 px-2 py-0.5">
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</span>
)}
{emotionLabel && (
<span className="rounded-full border border-white/20 px-2 py-0.5">
{t('upload.taskInfo.emotion').replace('{value}', emotionLabel)}
</span>
)}
</div>
</div>
) : null}
</button>
) : null;
const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? (
<div className="absolute left-4 right-4 top-4 z-30 rounded-2xl border border-white/25 bg-black/60 px-4 py-3 text-white shadow-2xl backdrop-blur sm:left-6 sm:right-6 sm:top-5">
<div className="flex items-center justify-between gap-2">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Bereit für dein Foto?</p>
<p className="text-base font-semibold leading-tight">Teile den Moment mit allen Gästen.</p>
<p className="text-xs text-white/75">Zieh eine Mission oder starte direkt.</p>
</div>
<Badge variant="secondary" className="rounded-full bg-white/15 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-white/90">
Live
</Badge>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
className="rounded-full bg-white text-black shadow"
onClick={() => {
setShowHeroOverlay(false);
navigate(tasksUrl);
}}
>
Mission ziehen
</Button>
<Button
size="sm"
variant="secondary"
className="rounded-full border border-white/30 bg-white/10 text-white"
onClick={() => {
setShowHeroOverlay(false);
navigate(tasksUrl);
}}
>
Stimmung wählen
</Button>
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 text-[11px] text-white/85">
<Sparkles className="h-4 w-4 text-amber-200" />
Mini-Mission: Fang ein Lachen ein
</span>
</div>
</div>
) : null;
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
danger: 'text-rose-500',
warning: 'text-amber-500',
info: 'text-sky-500',
};
const errorDialogNode = (
<Dialog open={Boolean(errorDialog)} onOpenChange={(open) => { if (!open) setErrorDialog(null); }}>
<DialogContent>
<DialogHeader className="space-y-3">
<div className="flex items-center gap-3">
{errorDialog?.tone === 'info' ? (
<Info className={cn('h-5 w-5', dialogToneIconClass.info)} />
) : (
<AlertTriangle className={cn('h-5 w-5', dialogToneIconClass[errorDialog?.tone ?? 'danger'])} />
)}
<DialogTitle>{errorDialog?.title ?? ''}</DialogTitle>
</div>
<DialogDescription>{errorDialog?.description ?? ''}</DialogDescription>
{errorDialog?.hint ? (
<p className="text-sm text-muted-foreground">{errorDialog.hint}</p>
) : null}
</DialogHeader>
<DialogFooter>
<Button onClick={() => setErrorDialog(null)}>{t('upload.dialogs.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => (
<>
<div className={wrapperClassName}>{content}</div>
{errorDialogNode}
</>
);
if (loadingTask) {
return renderWithDialog(
<div className="flex flex-col items-center justify-center gap-4 text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</div>
);
}
if (!canUpload) {
return renderWithDialog(
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package?.max_photos || 0}`)}
</AlertDescription>
</Alert>
);
}
const renderPrimer = () => (
showPrimer && (
<div className="rounded-[28px] border border-pink-200/60 bg-white/90 p-4 text-sm text-pink-900 shadow-lg dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-50">
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="text-left">
<p className="font-semibold">{t('upload.primer.title')}</p>
<p className="mt-1">
{t('upload.primer.body.part1')}{' '}
{t('upload.primer.body.part2')}
</p>
</div>
<Button variant="ghost" size="sm" onClick={dismissPrimer}>
{t('upload.primer.dismiss')}
</Button>
</div>
</div>
)
);
const renderPermissionNotice = () => {
if (permissionState === 'granted') return null;
const titles: Record<PermissionState, string> = {
idle: t('upload.cameraDenied.title'),
prompt: t('upload.cameraDenied.title'),
granted: '',
denied: t('upload.cameraDenied.title'),
error: t('upload.cameraError.title'),
unsupported: t('upload.cameraUnsupported.title'),
};
const fallbackMessages: Record<PermissionState, string> = {
idle: t('upload.cameraDenied.prompt'),
prompt: t('upload.cameraDenied.prompt'),
granted: '',
denied: t('upload.cameraDenied.explanation'),
error: t('upload.cameraError.explanation'),
unsupported: t('upload.cameraUnsupported.message'),
};
const title = titles[permissionState];
const description = permissionMessage ?? fallbackMessages[permissionState];
const canRetryCamera = permissionState !== 'unsupported';
return (
<div
className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white"
style={{ borderRadius: radius, fontFamily: bodyFont }}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900/10 dark:bg-white/10">
<Camera className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs text-slate-600 dark:text-white/70">{description}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
{canRetryCamera && (
<Button
onClick={startCamera}
size="sm"
style={buttonStyle === 'outline' ? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : { borderRadius: radius }}
>
{t('upload.buttons.startCamera')}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
style={buttonStyle === 'outline' ? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : { borderRadius: radius }}
>
{t('upload.galleryButton')}
</Button>
</div>
</div>
);
};
const isCountdownActive = mode === 'countdown';
const countdownProgress = preferences.countdownEnabled && preferences.countdownSeconds > 0
? Math.max(0, Math.min(1, countdownValue / preferences.countdownSeconds))
: 0;
const countdownDegrees = Math.round(countdownProgress * 360);
const controlIconButtonBase =
'flex h-10 w-10 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur transition hover:border-white/40 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60';
return renderWithDialog(
<>
<div
ref={cameraShellRef as unknown as React.RefObject<HTMLDivElement>}
className="relative flex min-h-screen flex-col gap-4 pb-[calc(env(safe-area-inset-bottom,0px)+12px)] pt-3"
style={bodyFont ? { fontFamily: bodyFont } : undefined}
>
{taskFloatingCard}
{heroOverlay}
{uploadsRequireApproval ? (
<div className="mx-4 rounded-xl border border-amber-300/70 bg-amber-50/80 p-3 text-amber-900 shadow-sm backdrop-blur dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
<p className="text-sm font-semibold">{t('upload.review.noticeTitle', 'Uploads werden geprüft')}</p>
<p className="text-xs">{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}</p>
</div>
) : null}
<section
className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }}
>
<div
ref={cameraViewportRef}
className="relative w-full"
style={{
height: 'calc(100vh - 160px)',
minHeight: '70vh',
maxHeight: '90vh',
}}
>
<video
ref={videoRef}
className={cn(
'absolute inset-0 h-full w-full object-cover transition-transform duration-200',
preferences.facingMode === 'user' && preferences.mirrorFrontPreview ? '-scale-x-100' : 'scale-x-100',
!isCameraActive && 'opacity-30'
)}
playsInline
muted
/>
{preferences.gridEnabled && (
<div
className="pointer-events-none absolute inset-0 z-10"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0.25) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,0.25) 1px, transparent 1px)',
backgroundSize: '33.333% 100%, 100% 33.333%',
}}
/>
)}
{!isCameraActive && (
<div className="absolute left-4 top-4 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
<Camera className="h-4 w-4 text-pink-400" />
<span>
{permissionState === 'unsupported'
? t('upload.cameraUnsupported.title')
: t('upload.cameraDenied.title')}
</span>
</div>
)}
{mode === 'countdown' && (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white">
<div className="text-6xl font-bold">{countdownValue}</div>
<p className="mt-2 text-sm text-white/70">{t('upload.countdownReady')}</p>
</div>
)}
{mode === 'review' && reviewPhoto && (
<div className="absolute inset-0 z-40 flex flex-col bg-black">
<img src={reviewPhoto.dataUrl} alt="Aufgenommenes Foto" className="h-full w-full object-contain" />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-3 py-2 backdrop-blur">
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.gridEnabled && 'border-white bg-white text-black'
)}
onClick={handleToggleGrid}
title={t('upload.controls.toggleGrid')}
aria-pressed={preferences.gridEnabled}
>
<Grid3X3 className="h-4 w-4" />
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.countdownEnabled && 'border-white bg-white text-black'
)}
onClick={handleToggleCountdown}
title={t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
aria-pressed={preferences.countdownEnabled}
>
<Timer className="h-4 w-4" />
<span className="sr-only">
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</span>
</Button>
{preferences.facingMode === 'user' && (
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.mirrorFrontPreview && 'border-white bg-white text-black'
)}
onClick={handleToggleMirror}
title={t('upload.controls.toggleMirror')}
aria-pressed={preferences.mirrorFrontPreview}
>
<FlipHorizontal className="h-4 w-4" />
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
</Button>
)}
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
preferences.flashPreferred && 'border-white bg-white text-black'
)}
onClick={handleToggleFlashPreference}
disabled={preferences.facingMode !== 'environment'}
title={t('upload.controls.toggleFlash')}
aria-pressed={preferences.flashPreferred}
>
{preferences.flashPreferred ? <Zap className="h-4 w-4 text-yellow-300" /> : <ZapOff className="h-4 w-4" />}
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
controlIconButtonBase,
immersiveMode && 'border-white bg-white text-black'
)}
onClick={handleToggleImmersive}
title={
immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')
}
aria-pressed={immersiveMode}
>
<Menu className="h-4 w-4" />
<span className="sr-only">
{immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')}
</span>
</Button>
</div>
</div>
{mode === 'uploading' && (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-black/80 text-white">
<Loader2 className="h-10 w-10 animate-spin" />
<div className="w-1/2 min-w-[200px] max-w-sm">
<div className="h-2 rounded-full bg-white/20">
<div
className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{statusMessage && <p className="mt-2 text-center text-xs text-white/80">{statusMessage}</p>}
</div>
</div>
)}
</div>
<div
className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4"
style={{ fontFamily: bodyFont }}
>
{uploadWarning && (
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
<AlertDescription className="text-xs">
{uploadWarning}
</AlertDescription>
</Alert>
)}
{uploadError && (
<Alert variant="destructive" className="bg-red-500/10 text-white">
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
{uploadError}
</AlertDescription>
</Alert>
)}
{showCelebration && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/20 bg-white/10 px-4 py-3 text-sm text-white shadow-lg backdrop-blur">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-200" />
<p className="font-semibold">
{t('upload.taskInfo.completed', 'Aufgabe gelöst!')}
</p>
</div>
<Button
size="sm"
variant="secondary"
className="rounded-full border border-white/30 bg-white/80 text-slate-900 hover:bg-white"
onClick={() => navigate(tasksUrl)}
>
{t('upload.taskInfo.nextPrompt', 'Gleich noch eine Aufgabe?')}
</Button>
</div>
)}
<div className="flex items-center justify-center gap-8">
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur hover:border-white/40 hover:bg-white/15"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="h-6 w-6" />
<span className="sr-only">{t('upload.galleryButton')}</span>
</Button>
{mode === 'review' && reviewPhoto ? (
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
{t('upload.review.retake')}
</Button>
<Button
className="flex-1 animate-pulse bg-pink-500 text-white shadow-lg hover:bg-pink-600 focus-visible:ring-pink-300"
onClick={handleUsePhoto}
>
{t('upload.review.keep')}
</Button>
</div>
) : (
<div className="relative h-24 w-24">
{!isCountdownActive && mode !== 'uploading' && (
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/30 opacity-60 animate-ping" />
)}
{isCountdownActive && (
<div
className="absolute inset-1 rounded-full"
style={{
background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.12) ${countdownDegrees}deg)`,
}}
/>
)}
<div className="absolute inset-2 rounded-full bg-black/70 shadow-[0_10px_40px_rgba(0,0,0,0.45)] backdrop-blur" />
<Button
size="lg"
className="relative z-10 flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 text-white shadow-2xl"
onClick={handlePrimaryAction}
disabled={mode === 'uploading' || isCountdownActive}
style={{
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 18px 36px ${branding.primaryColor}55`,
}}
>
{isCountdownActive ? (
<span className="text-3xl font-bold leading-none">{countdownValue}</span>
) : mode === 'uploading' ? (
<Loader2 className="h-9 w-9 animate-spin" />
) : (
<Camera className="h-8 w-8 sm:h-10 sm:w-10" style={{ height: '32px', width: '32px' }} />
)}
<span className="sr-only">
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
</span>
</Button>
</div>
)}
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur hover:border-white/40 hover:bg-white/15"
onClick={handleSwitchCamera}
>
<RotateCcw className="h-6 w-6" />
<span className="sr-only">{t('upload.switchCamera')}</span>
</Button>
</div>
</div>
</section>
</div>
{socialChips.length > 0 && (
<div className="mt-4 flex gap-3 overflow-x-auto pb-2">
{socialChips.map((chip) => (
<div
key={chip.id}
className="shrink-0 rounded-full border border-white/15 bg-white/80 px-4 py-2 text-xs font-semibold text-slate-800 shadow dark:border-white/10 dark:bg-white/10 dark:text-white"
>
<span className="block text-[10px] uppercase tracking-wide opacity-70">{chip.label}</span>
<span className="text-sm">{chip.value}</span>
</div>
))}
</div>
)}
{permissionState !== 'granted' && renderPermissionNotice()}
{renderPrimer()}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleGalleryPick}
/>
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
<canvas ref={canvasRef} className="hidden" />
</>
,
'space-y-6 pb-[140px]'
);
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
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}`);
}