891 lines
31 KiB
TypeScript
891 lines
31 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import Header from '../components/Header';
|
|
import BottomNav from '../components/BottomNav';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { uploadPhoto } from '../services/photosApi';
|
|
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
AlertTriangle,
|
|
Camera,
|
|
Grid3X3,
|
|
ImagePlus,
|
|
Info,
|
|
Loader2,
|
|
RotateCcw,
|
|
Sparkles,
|
|
Zap,
|
|
ZapOff,
|
|
} from 'lucide-react';
|
|
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
|
import { useTranslation } from '../i18n/useTranslation';
|
|
|
|
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;
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string | undefined {
|
|
if (error instanceof Error && typeof error.message === 'string') {
|
|
return error.message;
|
|
}
|
|
|
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
const message = (error as { message?: unknown }).message;
|
|
return typeof message === 'string' ? message : undefined;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
const DEFAULT_PREFS: CameraPreferences = {
|
|
facingMode: 'environment',
|
|
countdownSeconds: 3,
|
|
countdownEnabled: true,
|
|
gridEnabled: true,
|
|
mirrorFrontPreview: true,
|
|
flashPreferred: false,
|
|
};
|
|
|
|
export default function UploadPage() {
|
|
const { token } = useParams<{ token: string }>();
|
|
const eventKey = token ?? '';
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const { markCompleted } = useGuestTaskProgress(token);
|
|
const { t } = useTranslation();
|
|
|
|
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 [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
|
const [canUpload, setCanUpload] = useState(true);
|
|
|
|
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`);
|
|
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]);
|
|
|
|
// Check upload limits
|
|
useEffect(() => {
|
|
if (!eventKey || !task) return;
|
|
|
|
const checkLimits = async () => {
|
|
try {
|
|
const pkg = await getEventPackage(eventKey);
|
|
setEventPackage(pkg);
|
|
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
|
setCanUpload(false);
|
|
const maxLabel = pkg.package.max_photos == null
|
|
? t('upload.limitUnlimited')
|
|
: `${pkg.package.max_photos}`;
|
|
setUploadError(
|
|
t('upload.limitReached')
|
|
.replace('{used}', `${pkg.used_photos}`)
|
|
.replace('{max}', maxLabel)
|
|
);
|
|
} else {
|
|
setCanUpload(true);
|
|
setUploadError(null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to check package limits', err);
|
|
setCanUpload(false);
|
|
setUploadError(t('upload.limitCheckError'));
|
|
}
|
|
};
|
|
|
|
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);
|
|
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
|
|
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 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';
|
|
|
|
|
|
useEffect(() => () => {
|
|
resetCountdownTimer();
|
|
if (uploadProgressTimerRef.current) {
|
|
window.clearInterval(uploadProgressTimerRef.current);
|
|
}
|
|
}, [resetCountdownTimer]);
|
|
|
|
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
|
<div className="pb-16">
|
|
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
|
<main className={mainClassName}>{content}</main>
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
|
|
if (!supportsCamera && !task) {
|
|
return renderPage(
|
|
<Alert>
|
|
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (loadingTask) {
|
|
return renderPage(
|
|
<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 renderPage(
|
|
<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="mx-4 mt-3 rounded-xl border border-pink-200 bg-white/90 p-4 text-sm text-pink-900 shadow">
|
|
<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;
|
|
if (permissionState === 'unsupported') {
|
|
return (
|
|
<Alert className="mx-4">
|
|
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
if (permissionState === 'denied' || permissionState === 'error') {
|
|
return (
|
|
<Alert variant="destructive" className="mx-4">
|
|
<AlertDescription className="space-y-3">
|
|
<div>{permissionMessage}</div>
|
|
<Button size="sm" variant="outline" onClick={startCamera}>
|
|
{t('upload.buttons.tryAgain')}
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
return (
|
|
<Alert className="mx-4">
|
|
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
};
|
|
|
|
return renderPage(
|
|
<>
|
|
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
|
{renderPrimer()}
|
|
</div>
|
|
<div className="pt-32" />
|
|
{permissionState !== 'granted' && renderPermissionNotice()}
|
|
|
|
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
|
<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 inset-0 z-20 flex flex-col items-center justify-center bg-black/70 text-center text-sm">
|
|
<Camera className="mb-3 h-8 w-8 text-pink-400" />
|
|
<p className="max-w-xs text-white/90">
|
|
{t('upload.cameraInactive').replace(
|
|
'{hint}',
|
|
(permissionMessage ?? t('upload.cameraInactiveHint').replace('{label}', t('upload.buttons.startCamera')))
|
|
)}
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button size="sm" onClick={startCamera}>
|
|
{t('upload.buttons.startCamera')}
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
|
|
{t('upload.galleryButton')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showTaskOverlay && task && (
|
|
<div className="absolute left-3 right-3 top-3 z-30 flex flex-col gap-2 rounded-xl border border-white/15 bg-black/40 p-3 backdrop-blur-sm">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Badge variant="secondary" className="flex items-center gap-2 text-xs">
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
|
|
</Badge>
|
|
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
|
|
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-semibold leading-tight">{task.title}</h1>
|
|
<p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/70">
|
|
{task.instructions && (
|
|
<span>
|
|
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
|
|
</span>
|
|
)}
|
|
{emotionSlug && (
|
|
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
|
{t('upload.taskInfo.emotion').replace('{value}', `${task.emotion?.name || emotionSlug}`)}
|
|
</span>
|
|
)}
|
|
{preferences.countdownEnabled && (
|
|
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
|
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</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-3 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
|
|
{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 justify-between gap-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="icon"
|
|
variant={preferences.gridEnabled ? 'default' : 'secondary'}
|
|
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
onClick={handleToggleGrid}
|
|
>
|
|
<Grid3X3 className="h-5 w-5" />
|
|
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
|
|
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
onClick={handleToggleCountdown}
|
|
>
|
|
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
|
|
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
|
|
</Button>
|
|
{preferences.facingMode === 'user' && (
|
|
<Button
|
|
size="icon"
|
|
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
|
|
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
onClick={handleToggleMirror}
|
|
>
|
|
<span className="text-sm font-semibold">?</span>
|
|
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="icon"
|
|
variant={preferences.flashPreferred ? 'default' : 'secondary'}
|
|
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
onClick={handleToggleFlashPreference}
|
|
disabled={preferences.facingMode !== 'environment'}
|
|
>
|
|
{preferences.flashPreferred ? <Zap className="h-5 w-5 text-yellow-300" /> : <ZapOff className="h-5 w-5" />}
|
|
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="rounded-full border-white/30 bg-white/10 text-white"
|
|
onClick={handleSwitchCamera}
|
|
>
|
|
<RotateCcw className="mr-1 h-4 w-4" />
|
|
{t('upload.switchCamera')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="rounded-full border-white/30 bg-white/10 text-white"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<ImagePlus className="mr-1 h-4 w-4" />
|
|
{t('upload.galleryButton')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center">
|
|
{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="h-16 w-16 rounded-full border-4 border-white/40 bg-white/90 text-black shadow-xl"
|
|
onClick={beginCapture}
|
|
disabled={!isCameraActive || mode === 'countdown'}
|
|
>
|
|
<Camera className="h-7 w-7" />
|
|
<span className="sr-only">{t('upload.captureButton')}</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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" />
|
|
</>
|
|
,
|
|
'relative flex flex-col gap-4 pb-4'
|
|
);
|
|
}
|