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

1200 lines
43 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.
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,
ImagePlus,
Info,
Loader2,
RotateCcw,
Sparkles,
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 { compressPhoto, formatBytes } from '../lib/image';
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 { t, locale } = useTranslation();
const stats = useEventStats();
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 [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]
);
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 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]);
// 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,
},
}
);
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) : [];
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 || !task) 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, task, t]);
const stopStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
}, []);
const attachStreamToVideo = useCallback((stream: MediaStream) => {
if (!videoRef.current) return;
videoRef.current.srcObject = stream;
videoRef.current
.play()
.then(() => {
if (videoRef.current) {
videoRef.current.muted = true;
}
})
.catch((error) => console.error('Video play error', error));
}, []);
const createConstraint = useCallback(
(mode: 'user' | 'environment'): MediaStreamConstraints => ({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
facingMode: { ideal: mode },
},
audio: false,
}),
[]
);
const startCamera = useCallback(async () => {
if (!supportsCamera) {
setPermissionState('unsupported');
setPermissionMessage(t('upload.cameraUnsupported.message'));
return;
}
if (!task || mode === 'uploading') return;
try {
setPermissionState('prompt');
setPermissionMessage(null);
const stream = await navigator.mediaDevices.getUserMedia(createConstraint(preferences.facingMode));
stopStream();
streamRef.current = stream;
attachStreamToVideo(stream);
setPermissionState('granted');
} catch (error: 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, task, t]);
useEffect(() => {
if (!task || loadingTask) return;
startCamera();
return () => {
stopStream();
};
}, [task, loadingTask, startCamera, stopStream, preferences.facingMode]);
// Countdown live region updates
useEffect(() => {
if (!liveRegionRef.current) return;
if (mode === 'countdown') {
liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`);
} else if (mode === 'review') {
liveRegionRef.current.textContent = t('upload.review.readyAnnouncement');
} else if (mode === 'uploading') {
liveRegionRef.current.textContent = t('upload.status.uploading');
} else {
liveRegionRef.current.textContent = '';
}
}, [mode, countdownValue, t]);
const dismissPrimer = useCallback(() => {
setShowPrimer(false);
if (typeof window !== 'undefined') {
window.localStorage.setItem(primerStorageKey, '1');
}
}, [primerStorageKey]);
const handleToggleGrid = useCallback(() => {
setPreferences((prev) => ({ ...prev, gridEnabled: !prev.gridEnabled }));
}, []);
const handleToggleMirror = useCallback(() => {
setPreferences((prev) => ({ ...prev, mirrorFrontPreview: !prev.mirrorFrontPreview }));
}, []);
const handleToggleCountdown = useCallback(() => {
setPreferences((prev) => ({ ...prev, countdownEnabled: !prev.countdownEnabled }));
}, []);
const handleSwitchCamera = useCallback(() => {
setPreferences((prev) => ({
...prev,
facingMode: prev.facingMode === 'user' ? 'environment' : 'user',
}));
}, []);
const handleToggleFlashPreference = useCallback(() => {
setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred }));
}, []);
const resetCountdownTimer = useCallback(() => {
if (countdownTimerRef.current) {
window.clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, []);
const performCapture = useCallback(() => {
if (!videoRef.current || !canvasRef.current) {
setUploadError(t('upload.captureError'));
setMode('preview');
return;
}
const video = videoRef.current;
const canvas = canvasRef.current;
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
setUploadError(t('upload.feedError'));
setMode('preview');
startCamera();
return;
}
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
setUploadError(t('upload.canvasError'));
setMode('preview');
return;
}
context.save();
const shouldMirror = preferences.facingMode === 'user' && preferences.mirrorFrontPreview;
if (shouldMirror) {
context.scale(-1, 1);
context.drawImage(video, -width, 0, width, height);
} else {
context.drawImage(video, 0, 0, width, height);
}
context.restore();
canvas.toBlob(
(blob) => {
if (!blob) {
setUploadError(t('upload.captureError'));
setMode('preview');
return;
}
const timestamp = Date.now();
const fileName = `photo-${timestamp}.jpg`;
const file = new File([blob], fileName, { type: 'image/jpeg', lastModified: timestamp });
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
setReviewPhoto({ dataUrl, file });
setMode('review');
},
'image/jpeg',
0.92
);
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]);
const beginCapture = useCallback(() => {
setUploadError(null);
if (preferences.countdownEnabled && preferences.countdownSeconds > 0) {
setMode('countdown');
setCountdownValue(preferences.countdownSeconds);
resetCountdownTimer();
countdownTimerRef.current = window.setInterval(() => {
setCountdownValue((prev) => {
if (prev <= 1) {
resetCountdownTimer();
performCapture();
return preferences.countdownSeconds;
}
return prev - 1;
});
}, 1000);
} else {
performCapture();
}
}, [performCapture, preferences.countdownEnabled, preferences.countdownSeconds, resetCountdownTimer]);
const handleRetake = useCallback(() => {
setReviewPhoto(null);
setUploadProgress(0);
setUploadError(null);
setMode('preview');
}, []);
const navigateAfterUpload = useCallback(
(photoId: number | undefined) => {
if (!eventKey || !task) return;
const params = new URLSearchParams();
params.set('uploaded', 'true');
if (task.id) params.set('task', String(task.id));
if (photoId) params.set('photo', String(photoId));
if (emotionSlug) params.set('emotion', emotionSlug);
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`);
},
[emotionSlug, navigate, eventKey, task]
);
const handleUsePhoto = useCallback(async () => {
if (!eventKey || !reviewPhoto || !task || !canUpload) return;
setMode('uploading');
setUploadProgress(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,
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'));
markCompleted(task.id);
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]);
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
const file = event.target.files?.[0];
if (!file) return;
setUploadError(null);
const reader = new FileReader();
reader.onload = () => {
setReviewPhoto({ dataUrl: reader.result as string, file });
setMode('review');
};
reader.onerror = () => {
setUploadError(t('upload.galleryPickError'));
};
reader.readAsDataURL(file);
}, [canUpload, t]);
const 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]);
const handlePrimaryAction = useCallback(() => {
if (!isCameraActive) {
startCamera();
return;
}
beginCapture();
}, [beginCapture, isCameraActive, startCamera]);
const taskFloatingCard = showTaskOverlay && task ? (
<button
type="button"
onClick={() => setTaskDetailsExpanded((prev) => !prev)}
className="absolute left-6 right-6 top-0 z-30 -translate-y-1/2 rounded-3xl border border-white/40 bg-black/70 p-4 text-left text-white shadow-2xl backdrop-blur transition hover:bg-black/80 focus:outline-none focus:ring-2 focus:ring-white/60"
>
<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 limitStatusSection = limitCards.length > 0 ? (
<section className="space-y-4 rounded-[28px] border border-white/20 bg-white/80 p-5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-slate-900/60">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-white/60">
{t('upload.limitSummary.title')}
</p>
<p className="text-base font-semibold text-slate-900 dark:text-white">
{t('upload.limitSummary.subtitle')}
</p>
</div>
<Badge variant="secondary" className="rounded-full bg-black/5 text-xs text-slate-700 dark:bg-white/10 dark:text-white">
{t('upload.limitSummary.badgeLabel')}
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{limitCards.map((card) => {
const styles = LIMIT_CARD_STYLES[card.tone];
return (
<div
key={card.id}
className={cn(
'rounded-2xl border p-4 shadow-sm transition-colors',
styles.card
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide opacity-70">
{card.label}
</p>
<p className="text-lg font-semibold">{card.valueLabel}</p>
</div>
<Badge className={cn('text-[10px] font-semibold uppercase tracking-wide', styles.badge)}>
{card.badgeLabel}
</Badge>
</div>
{card.progress !== null && (
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/60 dark:bg-white/10">
<div
className={cn('h-full rounded-full transition-all', styles.bar)}
style={{ width: `${card.progress}%` }}
/>
</div>
)}
<p className="mt-3 text-sm opacity-80">{card.description}</p>
</div>
);
})}
</div>
</section>
) : 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(
<>
{limitStatusSection}
<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">
<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">
{t('upload.buttons.startCamera')}
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
{t('upload.galleryButton')}
</Button>
</div>
</div>
);
};
return renderWithDialog(
<>
<div className="relative pt-8">
{taskFloatingCard}
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
<div className="relative aspect-[3/4] sm:aspect-video">
<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>
)}
{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">
{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>
)}
<div className="flex flex-wrap items-center justify-center gap-2 text-xs font-medium uppercase tracking-wide text-white/80">
<Button
size="sm"
variant={preferences.gridEnabled ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.gridEnabled && 'bg-white text-black'
)}
onClick={handleToggleGrid}
>
<Grid3X3 className="mr-1 h-3.5 w-3.5" />
{t('upload.controls.toggleGrid')}
</Button>
<Button
size="sm"
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.countdownEnabled && 'bg-white text-black'
)}
onClick={handleToggleCountdown}
>
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</Button>
{preferences.facingMode === 'user' && (
<Button
size="sm"
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.mirrorFrontPreview && 'bg-white text-black'
)}
onClick={handleToggleMirror}
>
{t('upload.controls.toggleMirror')}
</Button>
)}
<Button
size="sm"
variant={preferences.flashPreferred ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.flashPreferred && 'bg-white text-black'
)}
onClick={handleToggleFlashPreference}
disabled={preferences.facingMode !== 'environment'}
>
{preferences.flashPreferred ? <Zap className="mr-1 h-3.5 w-3.5 text-yellow-300" /> : <ZapOff className="mr-1 h-3.5 w-3.5" />}
{t('upload.controls.toggleFlash')}
</Button>
</div>
<div className="flex items-center justify-center gap-6">
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
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" onClick={handleUsePhoto}>
{t('upload.review.keep')}
</Button>
</div>
) : (
<Button
size="lg"
className="flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 bg-white text-black shadow-2xl"
onClick={handlePrimaryAction}
disabled={mode === 'countdown' || mode === 'uploading'}
>
<Camera className="h-8 w-8" />
<span className="sr-only">
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
</span>
</Button>
)}
<Button
variant="ghost"
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
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()}
{limitStatusSection}
{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 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}`);
}