Refine guest gallery UI and add multi-photo upload flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

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 { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams('notice=network-retry')],
}));
vi.mock('@tamagui/stacks', () => ({ vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -69,5 +73,8 @@ describe('UploadQueueScreen', () => {
render(<UploadQueueScreen />); render(<UploadQueueScreen />);
expect(screen.getByText('Uploads')).toBeInTheDocument(); 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 React from 'react';
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext'; import { EventDataProvider } from '../context/EventDataContext';
const navigateMock = vi.fn();
const uploadPhotoMock = vi.fn();
const addToQueueMock = vi.fn();
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(), useNavigate: () => navigateMock,
useSearchParams: () => [new URLSearchParams('taskId=12')], useSearchParams: () => [new URLSearchParams('taskId=12')],
})); }));
@@ -18,8 +22,8 @@ vi.mock('@tamagui/text', () => ({
})); }));
vi.mock('@tamagui/button', () => ({ vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => ( Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" {...rest}> <button type="button" onClick={onPress} {...rest}>
{children} {children}
</button> </button>
), ),
@@ -30,8 +34,8 @@ vi.mock('../components/AppShell', () => ({
})); }));
vi.mock('../services/uploadApi', () => ({ vi.mock('../services/uploadApi', () => ({
uploadPhoto: vi.fn(), uploadPhoto: (...args: unknown[]) => uploadPhotoMock(...args),
useUploadQueue: () => ({ items: [], add: vi.fn() }), useUploadQueue: () => ({ items: [], add: addToQueueMock }),
})); }));
vi.mock('../services/tasksApi', () => ({ vi.mock('../services/tasksApi', () => ({
@@ -64,7 +68,22 @@ vi.mock('@/hooks/use-appearance', () => ({
import UploadScreen from '../screens/UploadScreen'; 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', () => { describe('UploadScreen', () => {
beforeEach(() => {
navigateMock.mockReset();
uploadPhotoMock.mockReset();
addToQueueMock.mockReset();
});
it('renders queue entry point', () => { it('renders queue entry point', () => {
render( render(
<EventDataProvider token="token"> <EventDataProvider token="token">
@@ -84,4 +103,48 @@ describe('UploadScreen', () => {
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument(); 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 <YStack
height={height} height={height}
borderRadius={borderRadius} borderRadius={borderRadius}
padding={6} overflow="hidden"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(15, 23, 42, 0.04)'} position="relative"
borderWidth={1} backgroundColor="$muted"
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
style={{ 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 {shimmer ? (
flex={1} <YStack
borderRadius={borderRadius} position="absolute"
backgroundColor="$muted" top={-40}
borderWidth={1} bottom={-40}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.1)'} left="-60%"
overflow="hidden" width="60%"
position="relative" backgroundColor="transparent"
style={{ style={{
boxShadow: isDark backgroundImage:
? 'inset 0 0 0 1px rgba(255, 255, 255, 0.06)' 'linear-gradient(120deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0))',
: 'inset 0 0 0 1px rgba(15, 23, 42, 0.04)', animation: 'guestNightShimmer 4.6s ease-in-out infinite',
}} animationDelay: `${shimmerDelayMs}ms`,
> }}
{shimmer ? ( />
<YStack ) : null}
position="absolute" <YStack position="relative" zIndex={1} flex={1}>
top={-40} {children}
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> </YStack>
</YStack> </YStack>
); );

View File

@@ -176,7 +176,7 @@ export default function TaskHeroCard({
borderColor="rgba(255, 255, 255, 0.2)" borderColor="rgba(255, 255, 255, 0.2)"
borderBottomColor="rgba(255, 255, 255, 0.35)" borderBottomColor="rgba(255, 255, 255, 0.35)"
style={{ 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, boxShadow: bentoSurface.shadow,
overflow: 'hidden', overflow: 'hidden',
}} }}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/shared/guest/i18n/LocaleContext'; import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useEventData } from '../context/EventDataContext'; import { useEventData } from '../context/EventDataContext';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi'; import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi';
import { useSearchParams } from 'react-router-dom';
type ProgressMap = Record<number, number>; type ProgressMap = Record<number, number>;
@@ -18,6 +19,7 @@ export default function UploadQueueScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { locale } = useLocale(); const { locale } = useLocale();
const { token } = useEventData(); const { token } = useEventData();
const [searchParams] = useSearchParams();
const { items, loading, retryAll, clearFinished, refresh, remove } = useUploadQueue(); const { items, loading, retryAll, clearFinished, refresh, remove } = useUploadQueue();
const [progress, setProgress] = React.useState<ProgressMap>({}); const [progress, setProgress] = React.useState<ProgressMap>({});
const { isDark } = useGuestThemeVariant(); const { isDark } = useGuestThemeVariant();
@@ -80,10 +82,21 @@ export default function UploadQueueScreen() {
const activeCount = items.filter((item) => item.status !== 'done').length; const activeCount = items.filter((item) => item.status !== 'done').length;
const failedCount = items.filter((item) => item.status === 'error').length; const failedCount = items.filter((item) => item.status === 'error').length;
const showNetworkRetryNotice = searchParams.get('notice') === 'network-retry';
return ( return (
<AppShell> <AppShell>
<YStack gap="$4"> <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> <SurfaceCard glow>
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2"> <XStack alignItems="center" gap="$2">

View File

@@ -19,6 +19,12 @@ import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes'; import { buildEventPath } from '../lib/routes';
import { compressPhoto, formatBytes } from '@/shared/guest/lib/image'; import { compressPhoto, formatBytes } from '@/shared/guest/lib/image';
type SelectedPreview = {
id: string;
file: File;
url: string;
};
function getTaskValue(task: TaskItem, key: string): string | undefined { function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem]; const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value; if (typeof value === 'string' && value.trim() !== '') return value;
@@ -43,6 +49,7 @@ export default function UploadScreen() {
const videoRef = React.useRef<HTMLVideoElement | null>(null); const videoRef = React.useRef<HTMLVideoElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null); const streamRef = React.useRef<MediaStream | null>(null);
const mockPreviewTimerRef = React.useRef<number | 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 [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [uploadDialog, setUploadDialog] = React.useState<UploadErrorDialog | 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 [facingMode, setFacingMode] = React.useState<'user' | 'environment'>('environment');
const [mirror, setMirror] = React.useState(true); const [mirror, setMirror] = React.useState(true);
const [flashPreferred, setFlashPreferred] = React.useState(false); const [flashPreferred, setFlashPreferred] = React.useState(false);
const [previewFile, setPreviewFile] = React.useState<File | null>(null); const [selectedPreviews, setSelectedPreviews] = React.useState<SelectedPreview[]>([]);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const { isDark } = useGuestThemeVariant(); const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark); const bentoSurface = getBentoSurfaceTokens(isDark);
const cardBorder = bentoSurface.borderColor; 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 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 autoApprove = event?.guest_upload_visibility === 'immediate';
const isExpanded = cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview'; 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 queueCount = items.filter((item) => item.status !== 'done').length;
const sendingCount = items.filter((item) => item.status === 'uploading').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( const uploadFiles = React.useCallback(
async (files: File[]) => { async (files: File[]) => {
if (!token || files.length === 0) return; if (!token || files.length === 0) {
if (files.length === 0) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return; return;
} }
setError(null); setError(null);
setUploadDialog(null); setUploadDialog(null);
let redirectPhotoId: number | null = null; let redirectPhotoId: number | null = null;
let hadNetworkQueue = false;
let uploadedCount = 0;
let queuedCount = 0;
let failedCount = 0;
for (const file of files) { for (const file of files) {
const preparedFile = await prepareUploadFile(file); const preparedFile = await prepareUploadFile(file);
if (!navigator.onLine) { if (!navigator.onLine) {
await enqueueFile(preparedFile); await enqueueFile(preparedFile);
pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' }); queuedCount += 1;
hadNetworkQueue = true;
continue; continue;
} }
@@ -272,8 +300,7 @@ export default function UploadScreen() {
if (autoApprove) { if (autoApprove) {
void triggerConfetti(); void triggerConfetti();
} }
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' }); uploadedCount += 1;
void loadPending();
persistMyPhotoId(photoId); persistMyPhotoId(photoId);
if (autoApprove && photoId) { if (autoApprove && photoId) {
redirectPhotoId = photoId; redirectPhotoId = photoId;
@@ -283,12 +310,49 @@ export default function UploadScreen() {
console.error('Upload failed, enqueueing', err); console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t)); setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
await enqueueFile(preparedFile); await enqueueFile(preparedFile);
queuedCount += 1;
if (uploadErr?.code === 'network_error') {
hadNetworkQueue = true;
} else {
failedCount += 1;
}
} finally { } finally {
setUploading(null); 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}`)); navigate(buildEventPath(token, `/gallery?photo=${redirectPhotoId}`));
} }
}, },
@@ -311,20 +375,29 @@ export default function UploadScreen() {
async (fileList: FileList | null) => { async (fileList: FileList | null) => {
if (!fileList) return; if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/')); const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
const next = files[0]; if (files.length === 0) {
if (!next) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.')); setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return; return;
} }
if (previewUrl) { setError(null);
URL.revokeObjectURL(previewUrl); setUploadDialog(null);
} setSelectedPreviews((prev) => {
const url = URL.createObjectURL(next); const existingKeys = new Set(prev.map((item) => `${item.file.name}-${item.file.size}-${item.file.lastModified}`));
setPreviewFile(next); const additions = files
setPreviewUrl(url); .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'); setCameraState('preview');
}, },
[previewUrl, t] [t]
); );
const handlePick = React.useCallback(() => { 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)); const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) return; if (!blob) return;
const file = new File([blob], `mock-${Date.now()}.jpg`, { type: blob.type }); const file = new File([blob], `mock-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file); setSelectedPreviews((prev) => [
setPreviewFile(file); ...prev,
setPreviewUrl(url); {
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
},
]);
stopCamera(); stopCamera();
setCameraState('preview'); setCameraState('preview');
return; return;
@@ -472,9 +550,14 @@ export default function UploadScreen() {
return; return;
} }
const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type }); const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file); setSelectedPreviews((prev) => [
setPreviewFile(file); ...prev,
setPreviewUrl(url); {
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
},
]);
stopCamera(); stopCamera();
setCameraState('preview'); setCameraState('preview');
}, [cameraState, facingMode, mirror, mockPreviewEnabled, startCamera, stopCamera, t]); }, [cameraState, facingMode, mirror, mockPreviewEnabled, startCamera, stopCamera, t]);
@@ -491,35 +574,37 @@ export default function UploadScreen() {
}, [mockPreviewEnabled, startCamera]); }, [mockPreviewEnabled, startCamera]);
const handleRetake = React.useCallback(async () => { const handleRetake = React.useCallback(async () => {
if (previewUrl) { clearSelectedPreviews();
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
if (cameraState === 'preview') { if (cameraState === 'preview') {
await startCamera(); 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 () => { const handleUseImage = React.useCallback(async () => {
if (!previewFile) return; if (selectedPreviews.length === 0) return;
await uploadFiles([previewFile]); await uploadFiles(selectedPreviews.map((item) => item.file));
if (previewUrl) { clearSelectedPreviews();
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
setCameraState('idle'); setCameraState('idle');
}, [previewFile, previewUrl, uploadFiles]); }, [clearSelectedPreviews, selectedPreviews, uploadFiles]);
const handleAbortCamera = React.useCallback(() => { const handleAbortCamera = React.useCallback(() => {
if (previewUrl) { clearSelectedPreviews();
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
stopCamera(); stopCamera();
}, [previewUrl, stopCamera]); }, [clearSelectedPreviews, stopCamera]);
React.useEffect(() => { React.useEffect(() => {
return () => stopCamera(); return () => stopCamera();
@@ -717,51 +802,90 @@ export default function UploadScreen() {
</Button> </Button>
) : null} ) : null}
{cameraState === 'preview' ? ( {cameraState === 'preview' ? (
<XStack <YStack position="absolute" bottom="$4" left="$4" right="$4" gap="$2.5" zIndex={5}>
position="absolute" <XStack
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" gap="$2"
style={{ 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" /> {selectedPreviews.map((item) => (
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF"> <YStack key={item.id} width={70} height={70} borderRadius={14} overflow="hidden" position="relative">
{t('upload.review.retakeGallery', 'Ein anderes Foto auswählen')} <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> </Text>
</Button> <Button
<Button size="$2"
flex={1} borderRadius="$pill"
height={76} backgroundColor="rgba(15, 23, 42, 0.62)"
borderRadius={24} borderWidth={1}
backgroundColor="#22C55E" borderColor="rgba(255,255,255,0.35)"
onPress={handleUseImage} onPress={handlePick}
alignItems="center" >
justifyContent="center" <Text fontSize="$2" color="#FFFFFF" fontWeight="$7">
gap="$2" {t('upload.review.addMore', 'Add more')}
style={{ </Text>
boxShadow: '0 10px 0 rgba(21, 128, 61, 0.9), 0 26px 40px rgba(22, 101, 52, 0.35)', </Button>
}} </XStack>
> <XStack gap="$3">
<ArrowRight size={22} color="#FFFFFF" /> <Button
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF"> flex={1}
{t('upload.review.keep', 'Foto verwenden')} height={76}
</Text> borderRadius={24}
</Button> backgroundColor="#F43F5E"
</XStack> 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} ) : null}
{cameraState !== 'ready' && cameraState !== 'preview' ? ( {cameraState !== 'ready' && cameraState !== 'preview' ? (
<YStack alignItems="center" gap="$2" padding="$4"> <YStack alignItems="center" gap="$2" padding="$4">

View File

@@ -550,6 +550,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
uploadQueue: { uploadQueue: {
title: 'Uploads', title: 'Uploads',
description: 'Warteschlange mit Fortschritt und erneuten Versuchen.', 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', summary: '{waiting} wartend · {failed} fehlgeschlagen',
emptyTitle: 'Keine Uploads in der Warteschlange', emptyTitle: 'Keine Uploads in der Warteschlange',
emptyDescription: 'Sobald Fotos in der Warteschlange sind, erscheinen sie hier.', emptyDescription: 'Sobald Fotos in der Warteschlange sind, erscheinen sie hier.',
@@ -732,6 +733,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retake: 'Nochmal aufnehmen', retake: 'Nochmal aufnehmen',
retakeGallery: 'Ein anderes Foto auswählen', retakeGallery: 'Ein anderes Foto auswählen',
keep: 'Foto verwenden', 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.', readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
}, },
liveShow: { liveShow: {
@@ -1473,6 +1482,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
uploadQueue: { uploadQueue: {
title: 'Uploads', title: 'Uploads',
description: 'Queue with progress and retries.', 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', summary: '{waiting} waiting · {failed} failed',
emptyTitle: 'No queued uploads', emptyTitle: 'No queued uploads',
emptyDescription: 'Once photos are queued, they will appear here.', emptyDescription: 'Once photos are queued, they will appear here.',
@@ -1655,6 +1665,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retake: 'Retake photo', retake: 'Retake photo',
retakeGallery: 'Choose another photo', retakeGallery: 'Choose another photo',
keep: 'Use this 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.', readyAnnouncement: 'Photo captured. Please review the preview.',
}, },
liveShow: { liveShow: {