1010 lines
37 KiB
TypeScript
1010 lines
37 KiB
TypeScript
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';
|
||
import { buildEventPath } from '../lib/routes';
|
||
import { compressPhoto, formatBytes } from '@/guest/lib/image';
|
||
|
||
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, tasksEnabled } = 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 = tasksEnabled && 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 optimizeTargetBytes = 1_500_000;
|
||
const optimizeMaxEdge = 2560;
|
||
|
||
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 || !tasksEnabled) {
|
||
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, tasksEnabled, 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 prepareUploadFile = React.useCallback(
|
||
async (file: File) => {
|
||
const shouldOptimize = file.size > optimizeTargetBytes || file.type !== 'image/jpeg';
|
||
if (!shouldOptimize) {
|
||
return file;
|
||
}
|
||
|
||
try {
|
||
const optimized = await compressPhoto(file, { targetBytes: optimizeTargetBytes, maxEdge: optimizeMaxEdge });
|
||
if (optimized.size >= file.size) {
|
||
return file;
|
||
}
|
||
if (optimized.size < file.size - 50_000) {
|
||
const saved = formatBytes(file.size - optimized.size);
|
||
pushGuestToast({
|
||
text: t(
|
||
'upload.optimizedNotice',
|
||
{ saved },
|
||
'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}'
|
||
),
|
||
type: 'info',
|
||
});
|
||
}
|
||
return optimized;
|
||
} catch (error) {
|
||
console.warn('Image optimization failed, uploading original', error);
|
||
pushGuestToast({
|
||
text: t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.'),
|
||
type: 'info',
|
||
});
|
||
return file;
|
||
}
|
||
},
|
||
[optimizeMaxEdge, optimizeTargetBytes, t]
|
||
);
|
||
|
||
const persistMyPhotoId = React.useCallback((photoId: number) => {
|
||
if (!photoId) return;
|
||
try {
|
||
const raw = localStorage.getItem('my-photo-ids');
|
||
const parsed = raw ? JSON.parse(raw) : [];
|
||
const list = Array.isArray(parsed) ? parsed.filter((value) => Number.isFinite(Number(value))) : [];
|
||
if (!list.includes(photoId)) {
|
||
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...list]));
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to persist my-photo-ids', 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);
|
||
let redirectPhotoId: number | null = null;
|
||
|
||
for (const file of files) {
|
||
const preparedFile = await prepareUploadFile(file);
|
||
if (!navigator.onLine) {
|
||
await enqueueFile(preparedFile);
|
||
pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' });
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
setUploading({ name: file.name, progress: 0 });
|
||
const photoId = await uploadPhoto(token, preparedFile, 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();
|
||
persistMyPhotoId(photoId);
|
||
if (autoApprove && photoId) {
|
||
redirectPhotoId = photoId;
|
||
}
|
||
} 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(preparedFile);
|
||
} finally {
|
||
setUploading(null);
|
||
}
|
||
}
|
||
|
||
if (autoApprove && redirectPhotoId) {
|
||
navigate(buildEventPath(token, `/gallery?photo=${redirectPhotoId}`));
|
||
}
|
||
},
|
||
[
|
||
autoApprove,
|
||
enqueueFile,
|
||
identity?.name,
|
||
loadPending,
|
||
markCompleted,
|
||
navigate,
|
||
prepareUploadFile,
|
||
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/'));
|
||
const next = files[0];
|
||
if (!next) {
|
||
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
|
||
return;
|
||
}
|
||
if (previewUrl) {
|
||
URL.revokeObjectURL(previewUrl);
|
||
}
|
||
const url = URL.createObjectURL(next);
|
||
setPreviewFile(next);
|
||
setPreviewUrl(url);
|
||
setCameraState('preview');
|
||
},
|
||
[previewUrl, t]
|
||
);
|
||
|
||
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);
|
||
if (cameraState === 'preview') {
|
||
await startCamera();
|
||
}
|
||
}, [cameraState, 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.retakeGallery', 'Ein anderes Foto auswählen')}
|
||
</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>
|
||
);
|
||
}
|