Files
fotospiel-app/resources/js/guest-v2/screens/UploadScreen.tsx
Codex Agent fa630e335d
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest PWA v2 UI and likes
2026-02-05 15:09:19 +01:00

921 lines
34 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 { ArrowRight, Camera, FlipHorizontal, Image, ListVideo, RefreshCcw, Sparkles, UploadCloud, X, Zap, ZapOff } 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 { pushGuestToast } from '../lib/toast';
import { getBentoSurfaceTokens } from '../lib/bento';
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 mockPreviewEnabled = import.meta.env.DEV && ['1', 'true', 'yes'].includes((searchParams.get('mockPreview') ?? '').toLowerCase());
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 mockPreviewTimerRef = React.useRef<number | 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 [flashPreferred, setFlashPreferred] = React.useState(false);
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark);
const cardBorder = bentoSurface.borderColor;
const cardShadow = bentoSurface.shadow;
const hardShadow = isDark
? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)'
: '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)';
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 fabShadow = isDark
? '0 20px 40px rgba(255, 79, 216, 0.38), 0 0 0 8px rgba(255, 79, 216, 0.16)'
: '0 18px 32px rgba(15, 23, 42, 0.2), 0 0 0 8px rgba(255, 255, 255, 0.7)';
const accessoryShadow = isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)';
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 (mockPreviewTimerRef.current) {
window.clearTimeout(mockPreviewTimerRef.current);
mockPreviewTimerRef.current = null;
}
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 (mockPreviewEnabled) {
const mode = modeOverride ?? facingMode;
if (mockPreviewTimerRef.current) {
window.clearTimeout(mockPreviewTimerRef.current);
}
setFacingMode(mode);
setCameraState('starting');
mockPreviewTimerRef.current = window.setTimeout(() => {
setCameraState('ready');
}, 420);
return;
}
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, mockPreviewEnabled]
);
const handleSwitchCamera = React.useCallback(async () => {
const nextMode = facingMode === 'user' ? 'environment' : 'user';
stopCamera();
await startCamera(nextMode);
}, [facingMode, startCamera, stopCamera]);
const handleToggleFlash = React.useCallback(() => {
setFlashPreferred((prev) => !prev);
}, []);
const handleCapture = React.useCallback(async () => {
if (mockPreviewEnabled) {
const canvas = document.createElement('canvas');
const width = 1400;
const height = 1000;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#ff6ad5');
gradient.addColorStop(0.5, '#8b5cf6');
gradient.addColorStop(1, '#38bdf8');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(80, 80, width - 160, height - 160);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 6;
ctx.strokeRect(80, 80, width - 160, height - 160);
ctx.fillStyle = 'rgba(15, 23, 42, 0.7)';
ctx.font = '700 64px system-ui, sans-serif';
ctx.fillText('Mock Capture', 120, height - 140);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) return;
const file = new File([blob], `mock-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
stopCamera();
setCameraState('preview');
return;
}
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, mockPreviewEnabled, startCamera, stopCamera, t]);
React.useEffect(() => {
if (!mockPreviewEnabled) return;
void startCamera();
return () => {
if (mockPreviewTimerRef.current) {
window.clearTimeout(mockPreviewTimerRef.current);
mockPreviewTimerRef.current = null;
}
};
}, [mockPreviewEnabled, startCamera]);
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 ? (
<YStack
padding="$3"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: hardShadow,
}}
>
<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}
</YStack>
) : null}
<YStack
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
overflow="hidden"
style={{
backgroundImage: isDark
? 'radial-gradient(120% 120% at 12% 15%, rgba(56, 189, 248, 0.18), transparent 55%), radial-gradient(130% 130% at 88% 10%, rgba(251, 113, 133, 0.2), transparent 60%)'
: 'radial-gradient(120% 120% at 12% 15%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 18%, white), transparent 55%), radial-gradient(130% 130% at 88% 10%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 18%, white), transparent 60%)',
boxShadow: hardShadow,
}}
>
<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 ? (
<YStack
position="absolute"
bottom="$4"
left="$4"
right="$4"
alignItems="center"
>
<XStack
gap="$2"
alignItems="center"
justifyContent="center"
padding="$2"
borderRadius={999}
borderWidth={1}
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.14)'}
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.6)' : 'rgba(255, 255, 255, 0.8)'}
style={{ boxShadow: accessoryShadow }}
>
<Button
size="$3"
circular
backgroundColor={flashPreferred ? '$primary' : mutedButton}
borderWidth={1}
borderColor={flashPreferred ? 'rgba(255,255,255,0.25)' : mutedButtonBorder}
onPress={handleToggleFlash}
disabled={facingMode !== 'environment'}
aria-label={t('upload.controls.toggleFlash', 'Toggle flash')}
>
{flashPreferred ? (
<Zap size={20} color="#FFFFFF" />
) : (
<ZapOff size={20} color={iconColor} />
)}
</Button>
<Button
size="$5"
circular
backgroundColor="$primary"
onPress={handleCapture}
width={112}
height={112}
minWidth={112}
minHeight={112}
padding={0}
borderRadius={999}
shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'}
shadowOpacity={0.5}
shadowRadius={26}
shadowOffset={{ width: 0, height: 12 }}
style={{ boxShadow: fabShadow }}
aria-label={t('upload.captureButton', 'Capture')}
>
<Camera size={44} color="#FFFFFF" />
</Button>
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleSwitchCamera}
aria-label={t('upload.controls.switchCamera', 'Switch camera')}
>
<RefreshCcw size={20} color={iconColor} />
</Button>
</XStack>
</YStack>
) : 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 === 'preview' ? (
<XStack
position="absolute"
bottom="$4"
left="$4"
right="$4"
gap="$3"
zIndex={5}
>
<Button
flex={1}
height={76}
borderRadius={24}
backgroundColor="#F43F5E"
onPress={handleRetake}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(159, 18, 57, 0.9), 0 26px 40px rgba(127, 29, 29, 0.35)',
}}
>
<X size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.retake', 'Nochmal aufnehmen')}
</Text>
</Button>
<Button
flex={1}
height={76}
borderRadius={24}
backgroundColor="#22C55E"
onPress={handleUseImage}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(21, 128, 61, 0.9), 0 26px 40px rgba(22, 101, 52, 0.35)',
}}
>
<ArrowRight size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.keep', 'Foto verwenden')}
</Text>
</Button>
</XStack>
) : 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>
</YStack>
{cameraState === 'preview' ? null : (
<XStack gap="$2">
<Button
flex={1}
height={64}
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
onPress={() => {
if (cameraState === 'ready') {
void handleCapture();
return;
}
void startCamera();
}}
disabled={cameraState === 'starting' || cameraState === 'blocked' || cameraState === 'unsupported'}
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" gap="$2">
<Camera size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{cameraState === 'ready'
? t('upload.captureButton', 'Foto aufnehmen')
: cameraState === 'starting'
? t('upload.buttons.starting', 'Kamera startet…')
: t('upload.buttons.startCamera', 'Kamera starten')}
</Text>
</XStack>
</Button>
<Button
flex={1}
height={64}
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
onPress={handlePick}
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" gap="$2">
<Image size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{t('uploadV2.galleryCta', 'Aus Galerie')}
</Text>
</XStack>
</Button>
{facingMode === 'user' ? (
<Button
size="$3"
circular
backgroundColor={mirror ? '$primary' : mutedButton}
borderWidth={1}
borderBottomWidth={3}
borderColor={mutedButtonBorder}
borderBottomColor={mutedButtonBorder}
onPress={() => setMirror((prev) => !prev)}
>
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
</Button>
) : null}
</XStack>
)}
<YStack
padding="$4"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: hardShadow,
}}
>
<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>
);
}