Files
fotospiel-app/resources/js/guest/pages/UploadPage.tsx
Codex Agent 3e3a2c49d6 Implemented guest-only PWA using vite-plugin-pwa (the actual published package; @vite-pwa/plugin isn’t on npm) with
injectManifest, a new typed SW source, runtime caching, and a non‑blocking update toast with an action button. The
  guest shell now links a dedicated manifest and theme color, and background upload sync is managed in a single
  PwaManager component.

  Key changes (where/why)

  - vite.config.ts: added VitePWA injectManifest config, guest manifest, and output to /public so the SW can control /
    scope.
  - resources/js/guest/guest-sw.ts: new Workbox SW (precache + runtime caching for guest navigation, GET /api/v1/*,
    images, fonts) and preserves push/sync/notification logic.
  - resources/js/guest/components/PwaManager.tsx: registers SW, shows update/offline toasts, and processes the upload
    queue on sync/online.
  - resources/js/guest/components/ToastHost.tsx: action-capable toasts so update prompts can include a CTA.
  - resources/js/guest/i18n/messages.ts: added common.updateAvailable, common.updateAction, common.offlineReady.
  - resources/views/guest.blade.php: manifest + theme color + apple touch icon.
  - .gitignore: ignore generated public/guest-sw.js and public/guest.webmanifest; public/guest-sw.js removed since it’s
    now build output.
2025-12-27 10:59:44 +01:00

1579 lines
58 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 { motion } from 'framer-motion';
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 { FADE_SCALE, FADE_UP, prefersReducedMotion } from '../lib/motion';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement';
import { getDeviceId } from '../lib/device';
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' | 'blocked';
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;
}
function isCameraBlockedByPolicy(): boolean {
if (typeof document === 'undefined') {
return false;
}
const policy = (document as { permissionsPolicy?: { allowsFeature?: (feature: string) => boolean } })
.permissionsPolicy;
if (!policy?.allowsFeature) {
return false;
}
return !policy.allowsFeature('camera');
}
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 { event } = useEventData();
const tasksEnabled = isTaskModeEnabled(event);
const { t, locale } = useTranslation();
const stats = useEventStats();
const { branding } = useEventBranding();
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 demoReadOnly = Boolean(event?.demo_read_only);
const motionEnabled = !prefersReducedMotion();
const overlayMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_SCALE } : {};
const fadeUpMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_UP } : {};
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(true);
const [showCelebration, setShowCelebration] = useState(false);
const [showHeroOverlay, setShowHeroOverlay] = useState(true);
const kpiChipsRef = useRef<HTMLDivElement | null>(null);
const navSentinelRef = useRef<HTMLDivElement | null>(null);
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';
document.body.classList.add(className);
return () => {
document.body.classList.remove(className);
document.body.classList.remove('guest-nav-visible');
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const root = document.documentElement;
const updateViewportVar = () => {
const viewport = window.visualViewport?.height ?? window.innerHeight;
root.style.setProperty('--guest-viewport-height', `${viewport}px`);
};
updateViewportVar();
window.visualViewport?.addEventListener('resize', updateViewportVar);
window.visualViewport?.addEventListener('scroll', updateViewportVar);
window.addEventListener('resize', updateViewportVar);
return () => {
window.visualViewport?.removeEventListener('resize', updateViewportVar);
window.visualViewport?.removeEventListener('scroll', updateViewportVar);
window.removeEventListener('resize', updateViewportVar);
};
}, []);
const updateNavVisibility = useCallback(() => {
if (typeof document === 'undefined') {
return;
}
const shouldShow = typeof window !== 'undefined' && window.scrollY > 24;
document.body.classList.toggle('guest-nav-visible', shouldShow);
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
updateNavVisibility();
window.addEventListener('scroll', updateNavVisibility, { passive: true });
return () => {
document.body.classList.remove('guest-nav-visible');
window.removeEventListener('scroll', updateNavVisibility);
};
}, [updateNavVisibility]);
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 || !tasksEnabled) {
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 () => {
if (demoReadOnly) {
setCanUpload(false);
setUploadError(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'));
setUploadWarning(null);
return;
}
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();
}, [demoReadOnly, 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 {
if (isCameraBlockedByPolicy()) {
setPermissionState('blocked');
setPermissionMessage(t('upload.cameraBlocked.message'));
return;
}
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]);
const handleRecheckCamera = useCallback(() => {
if (isCameraBlockedByPolicy()) {
setPermissionState('blocked');
setPermissionMessage(t('upload.cameraBlocked.message'));
return;
}
setPermissionState('idle');
setPermissionMessage(null);
void startCamera();
}, [startCamera, 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);
const target = uploadsRequireApproval ? 'queue' : 'gallery';
navigate(`/e/${encodeURIComponent(eventKey)}/${target}?${params.toString()}`);
},
[emotionSlug, navigate, eventKey, task?.id, uploadsRequireApproval]
);
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 ? (
<motion.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"
{...overlayMotion}
>
<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}
</motion.button>
) : null;
const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? (
<motion.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" {...fadeUpMotion}>
<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>
</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>
</motion.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 && (
<motion.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"
{...fadeUpMotion}
>
<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>
</motion.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'),
blocked: t('upload.cameraBlocked.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'),
blocked: t('upload.cameraBlocked.message'),
};
const title = titles[permissionState];
const description = permissionMessage ?? fallbackMessages[permissionState];
const canRetryCamera = permissionState !== 'unsupported' && permissionState !== 'blocked';
const canRecheckCamera = permissionState === 'blocked';
const helpText = permissionState === 'blocked'
? t('upload.cameraBlocked.hint')
: permissionState === 'denied'
? t('upload.cameraDenied.hint')
: permissionState === 'error'
? t('upload.cameraError.hint')
: null;
return (
<motion.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 }}
{...fadeUpMotion}
>
<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>
{helpText ? (
<p className="mt-3 text-xs text-slate-600 dark:text-white/70">{helpText}</p>
) : null}
<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>
)}
{canRecheckCamera && (
<Button
onClick={handleRecheckCamera}
size="sm"
style={buttonStyle === 'outline'
? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` }
: { borderRadius: radius }}
>
{t('upload.buttons.recheckCamera')}
</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>
</motion.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';
const cameraControlsInset = 'calc(env(safe-area-inset-bottom, 0px) + 72px)';
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)+72px)] pt-3"
style={bodyFont ? { fontFamily: bodyFont } : undefined}
>
{taskFloatingCard}
{heroOverlay}
<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: `clamp(60vh, calc(var(--guest-viewport-height, 100dvh) - 220px - ${cameraControlsInset}), 82vh)`,
minHeight: '60vh',
maxHeight: '88vh',
}}
>
<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')
: permissionState === 'blocked'
? t('upload.cameraBlocked.title')
: t('upload.cameraDenied.title')}
</span>
</div>
)}
{permissionState !== 'granted' && (
<div className="absolute inset-x-4 top-16 z-30 sm:top-20">
{renderPermissionNotice()}
</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-2 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"
style={{ marginBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<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">
{uploadsRequireApproval ? (
<div className="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}
<div className="flex 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>
) : (
<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 ref={navSentinelRef} data-testid="nav-visibility-sentinel" className="h-px w-full" />
<div ref={kpiChipsRef} data-testid="upload-kpi-chips" 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>
</>
)}
{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}`);
}