Files
fotospiel-app/resources/js/guest-v2/screens/UploadScreen.tsx
2026-02-03 22:10:35 +01:00

886 lines
33 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 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 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 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 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 ? (
<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 ? (
<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>
<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' ? null : (
<>
<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>
{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>
</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>
);
}