Refine guest gallery UI and add multi-photo upload flow
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run

This commit is contained in:
Codex Agent
2026-02-09 18:01:01 +01:00
parent e3bb1642db
commit 1f9a43806a
9 changed files with 369 additions and 159 deletions

View File

@@ -2,6 +2,10 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams('notice=network-retry')],
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -69,5 +73,8 @@ describe('UploadQueueScreen', () => {
render(<UploadQueueScreen />);
expect(screen.getByText('Uploads')).toBeInTheDocument();
expect(
screen.getByText('Upload paused due to network connection. Your image will upload automatically as soon as you are back online.')
).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,14 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
const navigateMock = vi.fn();
const uploadPhotoMock = vi.fn();
const addToQueueMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useNavigate: () => navigateMock,
useSearchParams: () => [new URLSearchParams('taskId=12')],
}));
@@ -18,8 +22,8 @@ vi.mock('@tamagui/text', () => ({
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...rest}>
{children}
</button>
),
@@ -30,8 +34,8 @@ vi.mock('../components/AppShell', () => ({
}));
vi.mock('../services/uploadApi', () => ({
uploadPhoto: vi.fn(),
useUploadQueue: () => ({ items: [], add: vi.fn() }),
uploadPhoto: (...args: unknown[]) => uploadPhotoMock(...args),
useUploadQueue: () => ({ items: [], add: addToQueueMock }),
}));
vi.mock('../services/tasksApi', () => ({
@@ -64,7 +68,22 @@ vi.mock('@/hooks/use-appearance', () => ({
import UploadScreen from '../screens/UploadScreen';
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: vi.fn(() => 'blob:preview'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
writable: true,
value: vi.fn(),
});
describe('UploadScreen', () => {
beforeEach(() => {
navigateMock.mockReset();
uploadPhotoMock.mockReset();
addToQueueMock.mockReset();
});
it('renders queue entry point', () => {
render(
<EventDataProvider token="token">
@@ -84,4 +103,48 @@ describe('UploadScreen', () => {
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
it('redirects to queue with notice when upload fails because of network', async () => {
uploadPhotoMock.mockRejectedValueOnce({ code: 'network_error' });
addToQueueMock.mockResolvedValueOnce(undefined);
const { container } = render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['demo'], 'demo.jpg', { type: 'image/jpeg' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(await screen.findByText('Foto verwenden'));
await waitFor(() => {
expect(addToQueueMock).toHaveBeenCalled();
expect(navigateMock).toHaveBeenCalledWith('../queue?notice=network-retry');
});
});
it('uploads all selected photos when multiple files are picked', async () => {
uploadPhotoMock.mockResolvedValueOnce(101).mockResolvedValueOnce(102);
const { container } = render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const first = new File(['one'], 'one.jpg', { type: 'image/jpeg' });
const second = new File(['two'], 'two.jpg', { type: 'image/jpeg' });
fireEvent.change(input, { target: { files: [first, second] } });
fireEvent.click(await screen.findByText('Upload {count} photos'));
await waitFor(() => {
expect(uploadPhotoMock).toHaveBeenCalledTimes(2);
});
expect(addToQueueMock).not.toHaveBeenCalled();
});
});

View File

@@ -23,47 +23,31 @@ export default function PhotoFrameTile({
<YStack
height={height}
borderRadius={borderRadius}
padding={6}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(15, 23, 42, 0.04)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
overflow="hidden"
position="relative"
backgroundColor="$muted"
style={{
boxShadow: isDark ? '0 18px 32px rgba(2, 6, 23, 0.4)' : '0 16px 28px rgba(15, 23, 42, 0.12)',
boxShadow: isDark ? '0 10px 18px rgba(2, 6, 23, 0.36)' : '0 8px 14px rgba(15, 23, 42, 0.16)',
}}
>
<YStack
flex={1}
borderRadius={borderRadius}
backgroundColor="$muted"
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.1)'}
overflow="hidden"
position="relative"
style={{
boxShadow: isDark
? 'inset 0 0 0 1px rgba(255, 255, 255, 0.06)'
: 'inset 0 0 0 1px rgba(15, 23, 42, 0.04)',
}}
>
{shimmer ? (
<YStack
position="absolute"
top={-40}
bottom={-40}
left="-60%"
width="60%"
backgroundColor="transparent"
style={{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0))',
animation: 'guestNightShimmer 4.6s ease-in-out infinite',
animationDelay: `${shimmerDelayMs}ms`,
}}
/>
) : null}
<YStack position="relative" zIndex={1} flex={1}>
{children}
</YStack>
{shimmer ? (
<YStack
position="absolute"
top={-40}
bottom={-40}
left="-60%"
width="60%"
backgroundColor="transparent"
style={{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0))',
animation: 'guestNightShimmer 4.6s ease-in-out infinite',
animationDelay: `${shimmerDelayMs}ms`,
}}
/>
) : null}
<YStack position="relative" zIndex={1} flex={1}>
{children}
</YStack>
</YStack>
);

View File

@@ -176,7 +176,7 @@ export default function TaskHeroCard({
borderColor="rgba(255, 255, 255, 0.2)"
borderBottomColor="rgba(255, 255, 255, 0.35)"
style={{
backgroundImage: theme.gradientBackground,
backgroundImage: `linear-gradient(155deg, rgba(15, 23, 42, 0.42) 0%, rgba(15, 23, 42, 0.3) 46%, rgba(15, 23, 42, 0.22) 100%), ${theme.gradientBackground}`,
boxShadow: bentoSurface.shadow,
overflow: 'hidden',
}}

View File

@@ -14,6 +14,7 @@ import { useGuestThemeVariant } from '../lib/guestTheme';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { createPortal } from 'react-dom';
import { buildEventPath } from '../lib/routes';
import { getBentoSurfaceTokens } from '../lib/bento';
import { usePollStats } from '../hooks/usePollStats';
@@ -982,7 +983,7 @@ export default function GalleryScreen() {
</Button>
</YStack>
) : isSingle ? (
<YStack gap="$3">
<YStack gap="$1.5">
<Button unstyled onPress={() => openLightbox(displayPhotos[0].id)}>
<PhotoFrameTile height={360} borderRadius="$bento">
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
@@ -1021,8 +1022,8 @@ export default function GalleryScreen() {
</Button>
</YStack>
) : (
<XStack gap="$3">
<YStack flex={1} gap="$3">
<XStack gap="$1">
<YStack flex={1} gap="$1">
{(loading ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map((tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
@@ -1049,7 +1050,7 @@ export default function GalleryScreen() {
);
})}
</YStack>
<YStack flex={1} gap="$3">
<YStack flex={1} gap="$1">
{(loading ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map((tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
@@ -1105,7 +1106,7 @@ export default function GalleryScreen() {
)}
</YStack>
{lightboxOpen || lightboxMounted ? (
{typeof document !== 'undefined' && (lightboxOpen || lightboxMounted) ? createPortal((
<YStack
position="fixed"
top={0}
@@ -1328,8 +1329,8 @@ export default function GalleryScreen() {
) : null}
</XStack>
<XStack
gap="$1"
padding="$1"
gap="$1.5"
padding="$1.5"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
@@ -1339,55 +1340,55 @@ export default function GalleryScreen() {
{lightboxPhoto ? (
<Button
unstyled
paddingHorizontal="$2.5"
paddingVertical="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
onPress={handleLike}
aria-label={t('galleryPage.photo.likeAria', 'Like')}
>
<Heart size={16} color={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : (isDark ? '#F8FAFF' : '#0F172A')} />
<Heart size={18} color={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : (isDark ? '#F8FAFF' : '#0F172A')} />
</Button>
) : null}
{lightboxPhoto ? (
<Button
unstyled
paddingHorizontal="$2"
paddingVertical="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
onPress={() => downloadPhoto(lightboxPhoto)}
aria-label={t('common.actions.download', 'Download')}
>
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Download size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
{lightboxPhoto && hasAiStylingAccess ? (
<Button
unstyled
paddingHorizontal="$2"
paddingVertical="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
onPress={openAiMagicEdit}
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
>
<Sparkles size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
{lightboxPhoto && canDelete ? (
<Button
unstyled
paddingHorizontal="$2"
paddingVertical="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
onPress={() => setDeleteConfirmOpen(true)}
disabled={deleteBusy}
aria-label={t('galleryPage.lightbox.deleteAria', 'Delete photo')}
>
<Trash2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Trash2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
<Button unstyled paddingHorizontal="$2" paddingVertical="$1.5" onPress={openShareSheet}>
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Button unstyled paddingHorizontal="$3" paddingVertical="$2" onPress={openShareSheet}>
<Share2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Button
unstyled
paddingHorizontal="$2.5"
paddingVertical="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
onPress={() => {
transitionDirectionRef.current = 'prev';
goPrev();
@@ -1395,12 +1396,12 @@ export default function GalleryScreen() {
disabled={lightboxIndex <= 0}
opacity={lightboxIndex <= 0 ? 0.4 : 1}
>
<ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<ChevronLeft size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Button
unstyled
paddingHorizontal="$2.5"
paddingVertical="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
onPress={() => {
transitionDirectionRef.current = 'next';
goNext();
@@ -1408,7 +1409,7 @@ export default function GalleryScreen() {
disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1}
opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1}
>
<ChevronRight size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<ChevronRight size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
</XStack>
@@ -1522,7 +1523,7 @@ export default function GalleryScreen() {
</YStack>
</YStack>
</YStack>
) : null}
), document.body) : null}
</AppShell>
);
}

View File

@@ -698,7 +698,7 @@ export default function HomeScreen() {
</Button>
</XStack>
<XStack
gap="$2"
gap="$1"
style={{
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',

View File

@@ -11,6 +11,7 @@ import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useEventData } from '../context/EventDataContext';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi';
import { useSearchParams } from 'react-router-dom';
type ProgressMap = Record<number, number>;
@@ -18,6 +19,7 @@ export default function UploadQueueScreen() {
const { t } = useTranslation();
const { locale } = useLocale();
const { token } = useEventData();
const [searchParams] = useSearchParams();
const { items, loading, retryAll, clearFinished, refresh, remove } = useUploadQueue();
const [progress, setProgress] = React.useState<ProgressMap>({});
const { isDark } = useGuestThemeVariant();
@@ -80,10 +82,21 @@ export default function UploadQueueScreen() {
const activeCount = items.filter((item) => item.status !== 'done').length;
const failedCount = items.filter((item) => item.status === 'error').length;
const showNetworkRetryNotice = searchParams.get('notice') === 'network-retry';
return (
<AppShell>
<YStack gap="$4">
{showNetworkRetryNotice ? (
<SurfaceCard backgroundColor={isDark ? 'rgba(56, 189, 248, 0.14)' : 'rgba(14, 165, 233, 0.12)'}>
<Text fontSize="$3" fontWeight="$7">
{t(
'uploadQueue.networkRetryNotice',
'Upload paused due to network connection. Your image will upload automatically as soon as you are back online.'
)}
</Text>
</SurfaceCard>
) : null}
<SurfaceCard glow>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">

View File

@@ -19,6 +19,12 @@ import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';
import { compressPhoto, formatBytes } from '@/shared/guest/lib/image';
type SelectedPreview = {
id: string;
file: File;
url: string;
};
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value;
@@ -43,6 +49,7 @@ export default function UploadScreen() {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const mockPreviewTimerRef = React.useRef<number | null>(null);
const selectedPreviewsRef = React.useRef<SelectedPreview[]>([]);
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);
@@ -50,8 +57,7 @@ export default function UploadScreen() {
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 [selectedPreviews, setSelectedPreviews] = React.useState<SelectedPreview[]>([]);
const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark);
const cardBorder = bentoSurface.borderColor;
@@ -68,6 +74,8 @@ export default function UploadScreen() {
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 selectedCount = selectedPreviews.length;
const previewUrl = selectedPreviews[0]?.url ?? null;
const queueCount = items.filter((item) => item.status !== 'done').length;
const sendingCount = items.filter((item) => item.status === 'uploading').length;
@@ -237,23 +245,43 @@ export default function UploadScreen() {
}
}, []);
const clearSelectedPreviews = React.useCallback(() => {
setSelectedPreviews((prev) => {
prev.forEach((item) => URL.revokeObjectURL(item.url));
return [];
});
}, []);
React.useEffect(() => {
selectedPreviewsRef.current = selectedPreviews;
}, [selectedPreviews]);
React.useEffect(() => {
return () => {
selectedPreviewsRef.current.forEach((item) => URL.revokeObjectURL(item.url));
};
}, []);
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.'));
if (!token || files.length === 0) {
return;
}
setError(null);
setUploadDialog(null);
let redirectPhotoId: number | null = null;
let hadNetworkQueue = false;
let uploadedCount = 0;
let queuedCount = 0;
let failedCount = 0;
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' });
queuedCount += 1;
hadNetworkQueue = true;
continue;
}
@@ -272,8 +300,7 @@ export default function UploadScreen() {
if (autoApprove) {
void triggerConfetti();
}
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
void loadPending();
uploadedCount += 1;
persistMyPhotoId(photoId);
if (autoApprove && photoId) {
redirectPhotoId = photoId;
@@ -283,12 +310,49 @@ export default function UploadScreen() {
console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
await enqueueFile(preparedFile);
queuedCount += 1;
if (uploadErr?.code === 'network_error') {
hadNetworkQueue = true;
} else {
failedCount += 1;
}
} finally {
setUploading(null);
}
}
if (autoApprove && redirectPhotoId) {
if (uploadedCount > 0) {
pushGuestToast({
text:
uploadedCount > 1
? t('upload.review.uploadedMany', { count: uploadedCount }, '{count} photos uploaded.')
: t('uploadV2.toast.uploaded', 'Upload complete.'),
type: 'success',
});
}
if (queuedCount > 0 && !hadNetworkQueue) {
pushGuestToast({
text: t('upload.review.queuedMany', { count: queuedCount }, '{count} photos were added to the queue.'),
type: 'info',
});
}
if (failedCount > 0) {
pushGuestToast({
text: t('upload.review.failedSome', { count: failedCount }, '{count} uploads failed and were queued for retry.'),
type: 'info',
});
}
void loadPending();
if (hadNetworkQueue) {
navigate('../queue?notice=network-retry');
return;
}
if (autoApprove && redirectPhotoId && files.length === 1) {
navigate(buildEventPath(token, `/gallery?photo=${redirectPhotoId}`));
}
},
@@ -311,20 +375,29 @@ export default function UploadScreen() {
async (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
const next = files[0];
if (!next) {
if (files.length === 0) {
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);
setError(null);
setUploadDialog(null);
setSelectedPreviews((prev) => {
const existingKeys = new Set(prev.map((item) => `${item.file.name}-${item.file.size}-${item.file.lastModified}`));
const additions = files
.filter((file) => {
const key = `${file.name}-${file.size}-${file.lastModified}`;
return !existingKeys.has(key);
})
.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
}));
return [...prev, ...additions];
});
setCameraState('preview');
},
[previewUrl, t]
[t]
);
const handlePick = React.useCallback(() => {
@@ -432,9 +505,14 @@ export default function UploadScreen() {
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);
setSelectedPreviews((prev) => [
...prev,
{
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
},
]);
stopCamera();
setCameraState('preview');
return;
@@ -472,9 +550,14 @@ export default function UploadScreen() {
return;
}
const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
setSelectedPreviews((prev) => [
...prev,
{
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
},
]);
stopCamera();
setCameraState('preview');
}, [cameraState, facingMode, mirror, mockPreviewEnabled, startCamera, stopCamera, t]);
@@ -491,35 +574,37 @@ export default function UploadScreen() {
}, [mockPreviewEnabled, startCamera]);
const handleRetake = React.useCallback(async () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
clearSelectedPreviews();
if (cameraState === 'preview') {
await startCamera();
}
}, [cameraState, previewUrl, startCamera]);
}, [cameraState, clearSelectedPreviews, startCamera]);
const removeSelectedPreview = React.useCallback((id: string) => {
setSelectedPreviews((prev) => {
const item = prev.find((candidate) => candidate.id === id);
if (item) {
URL.revokeObjectURL(item.url);
}
const next = prev.filter((candidate) => candidate.id !== id);
if (next.length === 0) {
setCameraState('idle');
}
return next;
});
}, []);
const handleUseImage = React.useCallback(async () => {
if (!previewFile) return;
await uploadFiles([previewFile]);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
if (selectedPreviews.length === 0) return;
await uploadFiles(selectedPreviews.map((item) => item.file));
clearSelectedPreviews();
setCameraState('idle');
}, [previewFile, previewUrl, uploadFiles]);
}, [clearSelectedPreviews, selectedPreviews, uploadFiles]);
const handleAbortCamera = React.useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
clearSelectedPreviews();
stopCamera();
}, [previewUrl, stopCamera]);
}, [clearSelectedPreviews, stopCamera]);
React.useEffect(() => {
return () => stopCamera();
@@ -717,51 +802,90 @@ export default function UploadScreen() {
</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"
<YStack position="absolute" bottom="$4" left="$4" right="$4" gap="$2.5" zIndex={5}>
<XStack
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(159, 18, 57, 0.9), 0 26px 40px rgba(127, 29, 29, 0.35)',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<X size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.retakeGallery', 'Ein anderes Foto auswählen')}
{selectedPreviews.map((item) => (
<YStack key={item.id} width={70} height={70} borderRadius={14} overflow="hidden" position="relative">
<img src={item.url} alt={item.file.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<Button
size="$1"
circular
position="absolute"
top={4}
right={4}
backgroundColor="rgba(15, 23, 42, 0.72)"
onPress={() => removeSelectedPreview(item.id)}
aria-label={t('upload.review.removePhoto', 'Remove photo')}
>
<X size={12} color="#FFFFFF" />
</Button>
</YStack>
))}
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="#FFFFFF" fontWeight="$7">
{t('upload.review.count', { count: selectedCount }, '{count} photos selected')}
</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>
<Button
size="$2"
borderRadius="$pill"
backgroundColor="rgba(15, 23, 42, 0.62)"
borderWidth={1}
borderColor="rgba(255,255,255,0.35)"
onPress={handlePick}
>
<Text fontSize="$2" color="#FFFFFF" fontWeight="$7">
{t('upload.review.addMore', 'Add more')}
</Text>
</Button>
</XStack>
<XStack gap="$3">
<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.clearSelection', 'Clear selection')}
</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">
{selectedCount > 1
? t('upload.review.uploadMany', { count: selectedCount }, 'Upload {count} photos')
: t('upload.review.keep', 'Foto verwenden')}
</Text>
</Button>
</XStack>
</YStack>
) : null}
{cameraState !== 'ready' && cameraState !== 'preview' ? (
<YStack alignItems="center" gap="$2" padding="$4">

View File

@@ -550,6 +550,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
uploadQueue: {
title: 'Uploads',
description: 'Warteschlange mit Fortschritt und erneuten Versuchen.',
networkRetryNotice: 'Upload pausiert wegen fehlender Verbindung. Dein Bild wird automatisch hochgeladen, sobald du wieder online bist.',
summary: '{waiting} wartend · {failed} fehlgeschlagen',
emptyTitle: 'Keine Uploads in der Warteschlange',
emptyDescription: 'Sobald Fotos in der Warteschlange sind, erscheinen sie hier.',
@@ -732,6 +733,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retake: 'Nochmal aufnehmen',
retakeGallery: 'Ein anderes Foto auswählen',
keep: 'Foto verwenden',
count: '{count} Fotos ausgewählt',
addMore: 'Mehr hinzufügen',
clearSelection: 'Auswahl löschen',
uploadMany: '{count} Fotos hochladen',
uploadedMany: '{count} Fotos hochgeladen.',
queuedMany: '{count} Fotos zur Warteschlange hinzugefügt.',
failedSome: '{count} Uploads fehlgeschlagen und zur Warteschlange hinzugefügt.',
removePhoto: 'Foto entfernen',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
},
liveShow: {
@@ -1473,6 +1482,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
uploadQueue: {
title: 'Uploads',
description: 'Queue with progress and retries.',
networkRetryNotice: 'Upload paused due to network connection. Your image will upload automatically as soon as you are back online.',
summary: '{waiting} waiting · {failed} failed',
emptyTitle: 'No queued uploads',
emptyDescription: 'Once photos are queued, they will appear here.',
@@ -1655,6 +1665,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retake: 'Retake photo',
retakeGallery: 'Choose another photo',
keep: 'Use this photo',
count: '{count} photos selected',
addMore: 'Add more',
clearSelection: 'Clear selection',
uploadMany: 'Upload {count} photos',
uploadedMany: '{count} photos uploaded.',
queuedMany: '{count} photos were added to the queue.',
failedSome: '{count} uploads failed and were queued for retry.',
removePhoto: 'Remove photo',
readyAnnouncement: 'Photo captured. Please review the preview.',
},
liveShow: {