1467 lines
54 KiB
TypeScript
1467 lines
54 KiB
TypeScript
// @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(true);
|
||
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}`);
|
||
}
|