Refine guest gallery UI and add multi-photo upload flow
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -698,7 +698,7 @@ export default function HomeScreen() {
|
||||
</Button>
|
||||
</XStack>
|
||||
<XStack
|
||||
gap="$2"
|
||||
gap="$1"
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user