diff --git a/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx b/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx index 7437beb9..4ebf1719 100644 --- a/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx @@ -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 }) =>
{children}
, XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -69,5 +73,8 @@ describe('UploadQueueScreen', () => { render(); 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(); }); }); diff --git a/resources/js/guest-v2/__tests__/UploadScreen.test.tsx b/resources/js/guest-v2/__tests__/UploadScreen.test.tsx index b3f0f4db..5a3bdce8 100644 --- a/resources/js/guest-v2/__tests__/UploadScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/UploadScreen.test.tsx @@ -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 }) => ( - ), @@ -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( @@ -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( + + + + ); + + 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( + + + + ); + + 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(); + }); }); diff --git a/resources/js/guest-v2/components/PhotoFrameTile.tsx b/resources/js/guest-v2/components/PhotoFrameTile.tsx index 0bcc7145..4a2ba93d 100644 --- a/resources/js/guest-v2/components/PhotoFrameTile.tsx +++ b/resources/js/guest-v2/components/PhotoFrameTile.tsx @@ -23,47 +23,31 @@ export default function PhotoFrameTile({ - - {shimmer ? ( - - ) : null} - - {children} - + {shimmer ? ( + + ) : null} + + {children} ); diff --git a/resources/js/guest-v2/components/TaskHeroCard.tsx b/resources/js/guest-v2/components/TaskHeroCard.tsx index 06369cae..4cd7f400 100644 --- a/resources/js/guest-v2/components/TaskHeroCard.tsx +++ b/resources/js/guest-v2/components/TaskHeroCard.tsx @@ -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', }} diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index 12cc0767..243f82e1 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -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() { ) : isSingle ? ( - + ) : ( - - + + {(loading ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map((tile, index) => { if (typeof tile === 'number') { return ; @@ -1049,7 +1050,7 @@ export default function GalleryScreen() { ); })} - + {(loading ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map((tile, index) => { if (typeof tile === 'number') { return ; @@ -1105,7 +1106,7 @@ export default function GalleryScreen() { )} - {lightboxOpen || lightboxMounted ? ( + {typeof document !== 'undefined' && (lightboxOpen || lightboxMounted) ? createPortal(( - + ) : null} {lightboxPhoto ? ( ) : null} {lightboxPhoto && hasAiStylingAccess ? ( ) : null} {lightboxPhoto && canDelete ? ( ) : null} - @@ -1522,7 +1523,7 @@ export default function GalleryScreen() { - ) : null} + ), document.body) : null} ); } diff --git a/resources/js/guest-v2/screens/HomeScreen.tsx b/resources/js/guest-v2/screens/HomeScreen.tsx index 83294497..732a5d18 100644 --- a/resources/js/guest-v2/screens/HomeScreen.tsx +++ b/resources/js/guest-v2/screens/HomeScreen.tsx @@ -698,7 +698,7 @@ export default function HomeScreen() { ; @@ -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({}); 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 ( + {showNetworkRetryNotice ? ( + + + {t( + 'uploadQueue.networkRetryNotice', + 'Upload paused due to network connection. Your image will upload automatically as soon as you are back online.' + )} + + + ) : null} diff --git a/resources/js/guest-v2/screens/UploadScreen.tsx b/resources/js/guest-v2/screens/UploadScreen.tsx index ed6933ac..5e5fbfb8 100644 --- a/resources/js/guest-v2/screens/UploadScreen.tsx +++ b/resources/js/guest-v2/screens/UploadScreen.tsx @@ -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(null); const streamRef = React.useRef(null); const mockPreviewTimerRef = React.useRef(null); + const selectedPreviewsRef = React.useRef([]); const [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null); const [error, setError] = React.useState(null); const [uploadDialog, setUploadDialog] = React.useState(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(null); - const [previewUrl, setPreviewUrl] = React.useState(null); + const [selectedPreviews, setSelectedPreviews] = React.useState([]); 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((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() { ) : null} {cameraState === 'preview' ? ( - - + + ))} + + + + {t('upload.review.count', { count: selectedCount }, '{count} photos selected')} - - - + + + + + + + ) : null} {cameraState !== 'ready' && cameraState !== 'preview' ? ( diff --git a/resources/js/shared/guest/i18n/messages.ts b/resources/js/shared/guest/i18n/messages.ts index 22204266..b84da1d5 100644 --- a/resources/js/shared/guest/i18n/messages.ts +++ b/resources/js/shared/guest/i18n/messages.ts @@ -550,6 +550,7 @@ export const messages: Record = { 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 = { 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 = { 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 = { 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: {