Files
fotospiel-app/resources/js/guest-v2/screens/UploadScreen.tsx
Codex Agent 298a8375b6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 branding and theming
2026-02-03 15:18:44 +01:00

758 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, FlipHorizontal, Image, ListVideo, RefreshCcw, Sparkles, UploadCloud, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import SurfaceCard from '../components/SurfaceCard';
import { pushGuestToast } from '../lib/toast';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value;
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const candidate = Object.values(obj).find((item) => typeof item === 'string' && item.trim() !== '');
if (typeof candidate === 'string') return candidate;
}
return undefined;
}
export default function UploadScreen() {
const { token, event } = useEventData();
const identity = useOptionalGuestIdentity();
const { items, add } = useUploadQueue();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t, locale } = useTranslation();
const { markCompleted } = useGuestTaskProgress(token ?? undefined);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [uploadDialog, setUploadDialog] = React.useState<UploadErrorDialog | null>(null);
const [cameraState, setCameraState] = React.useState<'idle' | 'starting' | 'ready' | 'denied' | 'blocked' | 'unsupported' | 'error' | 'preview'>('idle');
const [facingMode, setFacingMode] = React.useState<'user' | 'environment'>('environment');
const [mirror, setMirror] = React.useState(true);
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const { isDark } = useGuestThemeVariant();
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const autoApprove = event?.guest_upload_visibility === 'immediate';
const isExpanded = cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview';
const queueCount = items.filter((item) => item.status !== 'done').length;
const sendingCount = items.filter((item) => item.status === 'uploading').length;
const taskIdParam = searchParams.get('taskId');
const parsedTaskId = taskIdParam ? Number(taskIdParam) : NaN;
const taskId = Number.isFinite(parsedTaskId) ? parsedTaskId : undefined;
const [task, setTask] = React.useState<TaskItem | null>(null);
const [taskLoading, setTaskLoading] = React.useState(false);
const [taskError, setTaskError] = React.useState<string | null>(null);
const [pendingItems, setPendingItems] = React.useState<PendingUpload[]>([]);
const [pendingCount, setPendingCount] = React.useState(0);
const [pendingLoading, setPendingLoading] = React.useState(false);
const previewQueueItems = React.useMemo(() => items.filter((item) => item.status !== 'done').slice(0, 3), [items]);
const previewQueueUrls = React.useMemo(() => {
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return [];
}
return previewQueueItems.map((item) => ({
key: item.id ?? item.fileName,
url: URL.createObjectURL(item.blob),
}));
}, [previewQueueItems]);
React.useEffect(() => {
return () => {
previewQueueUrls.forEach((item) => URL.revokeObjectURL(item.url));
};
}, [previewQueueUrls]);
const loadPending = React.useCallback(async () => {
if (!token) {
setPendingItems([]);
setPendingCount(0);
return;
}
setPendingLoading(true);
try {
const result = await fetchPendingUploadsSummary(token, 3);
setPendingItems(result.items);
setPendingCount(result.totalCount);
} catch (err) {
console.error('Pending uploads load failed', err);
setPendingItems([]);
setPendingCount(0);
} finally {
setPendingLoading(false);
}
}, [token]);
React.useEffect(() => {
void loadPending();
}, [loadPending]);
React.useEffect(() => {
let active = true;
if (!token || !taskId) {
setTask(null);
setTaskLoading(false);
setTaskError(null);
return;
}
setTaskLoading(true);
setTaskError(null);
fetchTasks(token, { locale })
.then((tasks) => {
if (!active) return;
const match = tasks.find((item) => {
const id = item?.id ?? item?.task_id ?? item?.slug;
return String(id) === String(taskId);
}) ?? null;
setTask(match);
setTaskLoading(false);
})
.catch((err) => {
if (!active) return;
setTaskError(err instanceof Error ? err.message : t('tasks.error', 'Tasks could not be loaded.'));
setTask(null);
setTaskLoading(false);
});
return () => {
active = false;
};
}, [locale, t, taskId, token]);
const enqueueFile = React.useCallback(
async (file: File) => {
if (!token) return;
await add({ eventToken: token, fileName: file.name, blob: file, task_id: taskId ?? null });
},
[add, taskId, token]
);
const triggerConfetti = React.useCallback(async () => {
if (typeof window === 'undefined') return;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (prefersReducedMotion) return;
try {
const { default: confetti } = await import('canvas-confetti');
confetti({
particleCount: 70,
spread: 65,
origin: { x: 0.5, y: 0.35 },
ticks: 160,
scalar: 0.9,
});
} catch (error) {
console.warn('Confetti could not start', error);
}
}, []);
const uploadFiles = React.useCallback(
async (files: File[]) => {
if (!token || files.length === 0) return;
if (files.length === 0) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
setError(null);
setUploadDialog(null);
for (const file of files) {
if (!navigator.onLine) {
await enqueueFile(file);
pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' });
continue;
}
try {
setUploading({ name: file.name, progress: 0 });
await uploadPhoto(token, file, taskId, undefined, {
guestName: identity?.name ?? undefined,
onProgress: (percent) => {
setUploading((prev) => (prev ? { ...prev, progress: percent } : prev));
},
maxRetries: 1,
});
if (taskId) {
markCompleted(taskId);
}
if (autoApprove) {
void triggerConfetti();
}
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
void loadPending();
} catch (err) {
const uploadErr = err as { code?: string; meta?: Record<string, unknown> };
console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
await enqueueFile(file);
} finally {
setUploading(null);
}
}
},
[autoApprove, enqueueFile, identity?.name, loadPending, markCompleted, t, taskId, token, triggerConfetti]
);
const handleFiles = React.useCallback(
async (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
await uploadFiles(files);
},
[uploadFiles]
);
const handlePick = React.useCallback(() => {
inputRef.current?.click();
}, []);
const stopCamera = React.useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setCameraState('idle');
}, []);
const startCamera = React.useCallback(
async (modeOverride?: 'user' | 'environment') => {
if (!navigator.mediaDevices?.getUserMedia) {
setCameraState('unsupported');
return;
}
const mode = modeOverride ?? facingMode;
setCameraState('starting');
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: mode },
audio: false,
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setFacingMode(mode);
setCameraState('ready');
} catch (err) {
const error = err as { name?: string };
if (error?.name === 'NotAllowedError') {
setCameraState('denied');
} else if (error?.name === 'SecurityError') {
setCameraState('blocked');
} else if (error?.name === 'NotFoundError') {
setCameraState('error');
} else {
setCameraState('error');
}
}
},
[facingMode]
);
const handleSwitchCamera = React.useCallback(async () => {
const nextMode = facingMode === 'user' ? 'environment' : 'user';
stopCamera();
await startCamera(nextMode);
}, [facingMode, startCamera, stopCamera]);
const handleCapture = React.useCallback(async () => {
const video = videoRef.current;
if (!video) return;
if (cameraState !== 'ready') {
await startCamera();
return;
}
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
setError(t('upload.cameraError.explanation', 'Camera could not be started.'));
return;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (mirror && facingMode === 'user') {
ctx.translate(width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(video, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
stopCamera();
setCameraState('preview');
}, [cameraState, facingMode, mirror, startCamera, stopCamera, t]);
const handleRetake = React.useCallback(async () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
await startCamera();
}, [previewUrl, startCamera]);
const handleUseImage = React.useCallback(async () => {
if (!previewFile) return;
await uploadFiles([previewFile]);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
setCameraState('idle');
}, [previewFile, previewUrl, uploadFiles]);
const handleAbortCamera = React.useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
stopCamera();
}, [previewUrl, stopCamera]);
React.useEffect(() => {
return () => stopCamera();
}, [stopCamera]);
if (!token) {
return (
<AppShell>
<YStack padding="$4" borderRadius="$card" backgroundColor="$surface">
<Text fontSize="$4" fontWeight="$7">
{t('uploadV2.errors.eventMissing', 'Event not found')}
</Text>
</YStack>
</AppShell>
);
}
return (
<AppShell>
<YStack gap="$4">
{taskId ? (
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.title', 'Your next task')}
</Text>
</XStack>
{taskLoading ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{t('tasks.loading', 'Loading tasks...')}
</Text>
) : task ? (
<>
<Text fontSize="$4" fontWeight="$7" marginTop="$3">
{getTaskValue(task, 'title') ?? getTaskValue(task, 'name') ?? t('tasks.page.title', 'Task')}
</Text>
{getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt') ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt')}
</Text>
) : null}
</>
) : taskError ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{taskError}
</Text>
) : null}
</SurfaceCard>
) : null}
<YStack
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(15, 23, 42, 0.7), rgba(8, 12, 24, 0.9)), radial-gradient(circle at 20% 20%, rgba(255, 79, 216, 0.2), transparent 50%)'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 255, 0.82)), radial-gradient(circle at 20% 20%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), transparent 60%)',
boxShadow: isExpanded
? isDark
? '0 28px 60px rgba(2, 6, 23, 0.55)'
: '0 22px 44px rgba(15, 23, 42, 0.16)'
: isDark
? '0 22px 40px rgba(2, 6, 23, 0.5)'
: '0 18px 32px rgba(15, 23, 42, 0.12)',
borderRadius: isExpanded ? 28 : undefined,
transition: 'box-shadow 360ms ease, border-radius 360ms ease',
}}
>
<YStack
position="relative"
alignItems="center"
justifyContent="center"
style={{
height: isExpanded ? 'min(84vh, 720px)' : 320,
transition: 'height 360ms cubic-bezier(0.22, 0.61, 0.36, 1)',
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt={t('upload.preview.imageAlt', 'Captured photo')}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<video
ref={videoRef}
playsInline
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: cameraState === 'ready' ? 1 : 0,
transform: mirror && facingMode === 'user' ? 'scaleX(-1)' : 'none',
transition: 'opacity 200ms ease',
}}
/>
)}
{cameraState === 'ready' && !previewUrl ? (
<Button
size="$4"
circular
position="absolute"
bottom="$4"
backgroundColor="$primary"
onPress={handleCapture}
aria-label={t('upload.captureButton', 'Capture')}
>
<Camera size={20} color="#FFFFFF" />
</Button>
) : null}
{(cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview') ? (
<Button
size="$3"
circular
position="absolute"
top="$3"
right="$3"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.8)'}
borderWidth={1}
borderColor={cardBorder}
onPress={handleAbortCamera}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={iconColor} />
</Button>
) : null}
{cameraState !== 'ready' && cameraState !== 'preview' ? (
<YStack alignItems="center" gap="$2" padding="$4">
<Camera size={32} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7" textAlign="center">
{cameraState === 'unsupported'
? t('upload.cameraUnsupported.title', 'Camera not available')
: cameraState === 'blocked'
? t('upload.cameraBlocked.title', 'Camera blocked')
: cameraState === 'denied'
? t('upload.cameraDenied.title', 'Camera access denied')
: t('upload.cameraTitle', 'Camera')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
{cameraState === 'unsupported'
? t('upload.cameraUnsupported.message', 'Your device does not support live camera preview in this browser.')
: cameraState === 'blocked'
? t('upload.cameraBlocked.message', 'Camera access is blocked by the site security policy.')
: cameraState === 'denied'
? t('upload.cameraDenied.prompt', 'We need access to your camera. Allow the request or pick a photo from your gallery.')
: t('uploadV2.preview.subtitle', 'Stay in the flow keep the camera ready.')}
</Text>
<Button
size="$3"
borderRadius="$pill"
backgroundColor="$primary"
onPress={() => startCamera()}
disabled={cameraState === 'unsupported' || cameraState === 'blocked'}
>
{t('upload.buttons.startCamera', 'Start camera')}
</Button>
</YStack>
) : null}
</YStack>
<XStack
gap="$2"
padding={isExpanded ? '$2' : '$3'}
alignItems="center"
justifyContent="space-between"
borderTopWidth={1}
borderColor={cardBorder}
backgroundColor={isDark ? 'rgba(10, 14, 28, 0.7)' : 'rgba(255, 255, 255, 0.75)'}
>
{cameraState === 'preview' ? (
<XStack gap="$2" flex={1} justifyContent="space-between">
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleRetake}
flex={1}
justifyContent="center"
>
<Text fontSize="$2" fontWeight="$6">
{t('upload.review.retake', 'Retake')}
</Text>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor="$primary"
onPress={handleUseImage}
flex={1}
justifyContent="center"
>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('upload.review.keep', 'Use image')}
</Text>
</Button>
</XStack>
) : (
<>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handlePick}
paddingHorizontal="$4"
gap="$2"
alignSelf="center"
flexShrink={0}
justifyContent="center"
>
<Image size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.galleryCta', 'Upload from gallery')}
</Text>
</Button>
<XStack gap="$2">
<XStack alignItems="center" gap="$2">
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleSwitchCamera}
disabled={cameraState === 'unsupported' || cameraState === 'blocked'}
>
<RefreshCcw size={16} color={iconColor} />
</Button>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('upload.controls.switchCamera', 'Switch camera')}
</Text>
</XStack>
{facingMode === 'user' ? (
<Button
size="$3"
circular
backgroundColor={mirror ? '$primary' : mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => setMirror((prev) => !prev)}
>
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
</Button>
) : null}
</XStack>
</>
)}
</XStack>
</YStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$4" fontWeight="$7">
{t('uploadQueue.title', 'Uploads')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('uploadV2.queue.summary', { waiting: queueCount, sending: sendingCount }, '{waiting} waiting, {sending} sending')}
</Text>
{uploading ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{t(
'uploadV2.queue.uploading',
{ name: uploading.name, progress: uploading.progress },
'Uploading {name} · {progress}%'
)}
</Text>
) : null}
{uploadDialog ? (
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7" color={uploadDialog.tone === 'danger' ? '#FCA5A5' : '$color'}>
{uploadDialog.title}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{uploadDialog.description}
</Text>
{uploadDialog.hint ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{uploadDialog.hint}
</Text>
) : null}
</YStack>
) : null}
{error ? (
<Text fontSize="$2" color="#FCA5A5">
{error}
</Text>
) : null}
<XStack gap="$2">
{previewQueueUrls.length > 0 ? (
previewQueueUrls.map((item) => (
<YStack
key={item.key}
flex={1}
height={72}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
>
<img src={item.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</YStack>
))
) : (
<YStack flex={1} height={72} borderRadius="$tile" backgroundColor="$muted" borderWidth={1} borderColor={cardBorder} />
)}
</XStack>
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.title', 'Pending uploads')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{pendingLoading
? t('pendingUploads.loading', 'Loading uploads...')
: t('pendingUploads.subtitle', 'Your photos are waiting for approval.')}
</Text>
{pendingItems.length > 0 ? (
<XStack gap="$2">
{pendingItems.map((photo) => (
<YStack
key={photo.id}
flex={1}
height={72}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
alignItems="center"
justifyContent="center"
>
{photo.thumbnail_url ? (
<img src={photo.thumbnail_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<UploadCloud size={20} color={iconColor} />
)}
</YStack>
))}
</XStack>
) : (
<Text fontSize="$2" color="$color" opacity={0.6}>
{t('pendingUploads.emptyTitle', 'No pending uploads')}
</Text>
)}
</YStack>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate('../queue')}
alignSelf="flex-start"
width="auto"
paddingHorizontal="$3"
gap="$2"
justifyContent="flex-start"
>
<ListVideo size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.queue.button', 'Queue')}
</Text>
</Button>
</YStack>
</YStack>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={(event) => {
handleFiles(event.target.files);
if (event.target) {
event.target.value = '';
}
}}
/>
</AppShell>
);
}