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 }) => (
-
) : isSingle ? (
-
+
openLightbox(displayPhotos[0].id)}>
@@ -1021,8 +1022,8 @@ export default function GalleryScreen() {
) : (
-
-
+
+
{(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 ? (
downloadPhoto(lightboxPhoto)}
aria-label={t('common.actions.download', 'Download')}
>
-
+
) : null}
{lightboxPhoto && hasAiStylingAccess ? (
-
+
) : null}
{lightboxPhoto && canDelete ? (
setDeleteConfirmOpen(true)}
disabled={deleteBusy}
aria-label={t('galleryPage.lightbox.deleteAria', 'Delete photo')}
>
-
+
) : null}
-
-
+
+
{
transitionDirectionRef.current = 'prev';
goPrev();
@@ -1395,12 +1396,12 @@ export default function GalleryScreen() {
disabled={lightboxIndex <= 0}
opacity={lightboxIndex <= 0 ? 0.4 : 1}
>
-
+
{
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}
>
-
+
@@ -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.retakeGallery', 'Ein anderes Foto auswählen')}
+ {selectedPreviews.map((item) => (
+
+
+ removeSelectedPreview(item.id)}
+ aria-label={t('upload.review.removePhoto', 'Remove photo')}
+ >
+
+
+
+ ))}
+
+
+
+ {t('upload.review.count', { count: selectedCount }, '{count} photos selected')}
-
-
-
-
- {t('upload.review.keep', 'Foto verwenden')}
-
-
-
+
+
+ {t('upload.review.addMore', 'Add more')}
+
+
+
+
+
+
+
+ {t('upload.review.clearSelection', 'Clear selection')}
+
+
+
+
+
+ {selectedCount > 1
+ ? t('upload.review.uploadMany', { count: selectedCount }, 'Upload {count} photos')
+ : t('upload.review.keep', 'Foto verwenden')}
+
+
+
+
) : 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: {