platz zu begrenzt im aufnahmemodus - vollbildmodus möglich? Menü und Kopfleiste ausblenden? Bild aus eigener galerie auswählen - Upload schlägt fehl (zu groß? evtl fehlende Rechte - aber browser hat rechte auf bilder und dateien!) hochgeladene bilder tauchen in der galerie nicht beim filter "Meine Bilder" auf - fotos werden auch nicht gezählt in den stats und achievements zeigen keinen fortschriftt. geteilte fotos: ruft man den Link auf, bekommt man die meldung "Link abgelaufen" der im startbildschirm gewählte name mit Umlauten (Sören) ist nach erneutem aufruf der pwa ohne umlaut (Sren). Aufgabenseite verbessert (Zwischenstand)
1305 lines
47 KiB
TypeScript
1305 lines
47 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,
|
||
ImagePlus,
|
||
Info,
|
||
Loader2,
|
||
RotateCcw,
|
||
Sparkles,
|
||
Zap,
|
||
ZapOff,
|
||
Maximize2,
|
||
Minimize2,
|
||
} 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';
|
||
|
||
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 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 taskIdParam = searchParams.get('task');
|
||
const emotionSlug = searchParams.get('emotion') || '';
|
||
|
||
const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed';
|
||
const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs';
|
||
|
||
const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||
|
||
const [task, setTask] = useState<Task | null>(null);
|
||
const [loadingTask, setLoadingTask] = useState(true);
|
||
|
||
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
|
||
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
|
||
|
||
const [preferences, setPreferences] = useState<CameraPreferences>(DEFAULT_PREFS);
|
||
const [mode, setMode] = useState<CameraMode>('preview');
|
||
const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds);
|
||
const [statusMessage, setStatusMessage] = useState<string>('');
|
||
|
||
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||
const [immersiveMode, setImmersiveMode] = useState(false);
|
||
|
||
const [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 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) 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 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);
|
||
}
|
||
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);
|
||
}
|
||
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]);
|
||
|
||
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]);
|
||
|
||
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"
|
||
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>
|
||
);
|
||
};
|
||
|
||
return renderWithDialog(
|
||
<>
|
||
<div className="relative pt-8" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||
{taskFloatingCard}
|
||
<section
|
||
className="relative overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
|
||
style={{ borderRadius: radius }}
|
||
>
|
||
<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"
|
||
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>
|
||
)}
|
||
|
||
<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>
|
||
<Button
|
||
size="sm"
|
||
variant={immersiveMode ? 'default' : 'secondary'}
|
||
className={cn(
|
||
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
||
immersiveMode && 'bg-white text-black'
|
||
)}
|
||
onClick={() => setImmersiveMode((prev) => !prev)}
|
||
>
|
||
{immersiveMode ? <Minimize2 className="mr-1 h-3.5 w-3.5" /> : <Maximize2 className="mr-1 h-3.5 w-3.5" />}
|
||
{immersiveMode
|
||
? t('upload.controls.exitFullscreen', 'Menü einblenden')
|
||
: t('upload.controls.enterFullscreen', 'Vollbild')}
|
||
</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 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}`);
|
||
}
|