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

1157 lines
41 KiB
TypeScript

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';
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() {
const currentTaskId = 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(5);
setUploadError(null);
setStatusMessage(t('upload.status.preparing'));
if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current);
}
uploadProgressTimerRef.current = window.setInterval(() => {
setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev));
}, 400);
try {
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
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.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 {
if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current);
uploadProgressTimerRef.current = null;
}
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}`);
}