diff --git a/public/lang/de/guest.json b/public/lang/de/guest.json
index 6d6de27e..3a33b17b 100644
--- a/public/lang/de/guest.json
+++ b/public/lang/de/guest.json
@@ -9,5 +9,15 @@
"qrLoading": "QR wird erstellt...",
"qrRetry": "Erneut versuchen"
}
+ },
+ "upload": {
+ "review": {
+ "retakeGallery": "Ein anderes Foto auswählen"
+ }
+ },
+ "uploadQueue": {
+ "actions": {
+ "removeFailed": "Entfernen"
+ }
}
}
diff --git a/public/lang/en/guest.json b/public/lang/en/guest.json
index 486d317c..6a2487fd 100644
--- a/public/lang/en/guest.json
+++ b/public/lang/en/guest.json
@@ -9,5 +9,15 @@
"qrLoading": "Generating QR...",
"qrRetry": "Retry"
}
+ },
+ "upload": {
+ "review": {
+ "retakeGallery": "Choose another photo"
+ }
+ },
+ "uploadQueue": {
+ "actions": {
+ "removeFailed": "Remove"
+ }
}
}
diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
index a9311cbc..292be05e 100644
--- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
+++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
const setSearchParamsMock = vi.fn();
@@ -100,22 +100,61 @@ vi.mock('lucide-react', () => ({
Share2: () => share,
ChevronLeft: () => left,
ChevronRight: () => right,
+ Loader2: () => loader,
X: () => x,
}));
import GalleryScreen from '../screens/GalleryScreen';
describe('GalleryScreen', () => {
+ beforeEach(() => {
+ setSearchParamsMock.mockClear();
+ pushGuestToastMock.mockClear();
+ fetchGalleryMock.mockReset();
+ fetchPhotoMock.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it('clears the photo param and shows a warning when lightbox fails to load', async () => {
+ vi.useFakeTimers();
+ fetchGalleryMock.mockResolvedValue({ data: [] });
+ fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render();
- await waitFor(() => {
- expect(pushGuestToastMock).toHaveBeenCalled();
- expect(setSearchParamsMock).toHaveBeenCalled();
- });
+ for (let i = 0; i < 7; i += 1) {
+ await vi.advanceTimersByTimeAsync(1500);
+ await Promise.resolve();
+ }
+ await vi.advanceTimersByTimeAsync(1000);
+ await vi.runAllTimersAsync();
+ await Promise.resolve();
+ expect(pushGuestToastMock).toHaveBeenCalled();
+ expect(setSearchParamsMock).toHaveBeenCalled();
const [params] = setSearchParamsMock.mock.calls.at(-1) ?? [];
const search = params instanceof URLSearchParams ? params : new URLSearchParams(params);
expect(search.get('photo')).toBeNull();
});
+
+ it('keeps lightbox open when a seeded photo exists but fetch fails', async () => {
+ vi.useFakeTimers();
+ fetchGalleryMock.mockResolvedValue({
+ data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
+ });
+ fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
+
+ render();
+
+ await Promise.resolve();
+ await vi.runAllTimersAsync();
+ expect(fetchGalleryMock).toHaveBeenCalled();
+ expect(fetchPhotoMock).toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(setSearchParamsMock).not.toHaveBeenCalled();
+ expect(pushGuestToastMock).not.toHaveBeenCalled();
+ });
});
diff --git a/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx b/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
index bfc53514..df206f46 100644
--- a/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
+++ b/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
@@ -34,6 +34,7 @@ vi.mock('../services/uploadApi', () => ({
retryAll: vi.fn(),
clearFinished: vi.fn(),
refresh: vi.fn(),
+ remove: vi.fn(),
}),
}));
diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx
index 1fe7a53d..ebff547d 100644
--- a/resources/js/guest-v2/screens/GalleryScreen.tsx
+++ b/resources/js/guest-v2/screens/GalleryScreen.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
-import { Camera, ChevronLeft, ChevronRight, Heart, Share2, Sparkles, X } from 'lucide-react';
+import { Camera, ChevronLeft, ChevronRight, Heart, Loader2, Share2, Sparkles, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import ShareSheet from '../components/ShareSheet';
@@ -84,15 +84,20 @@ export default function GalleryScreen() {
const [likedIds, setLikedIds] = React.useState>(new Set());
const touchStartX = React.useRef(null);
const fallbackAttemptedRef = React.useRef(false);
+ const pendingNotFoundRef = React.useRef(false);
+ const photosRef = React.useRef([]);
+ const galleryLoadingRef = React.useRef(Boolean(token));
React.useEffect(() => {
if (!token) {
setPhotos([]);
+ galleryLoadingRef.current = false;
return;
}
let active = true;
setLoading(true);
+ galleryLoadingRef.current = true;
fetchGallery(token, { limit: 18, locale })
.then((response) => {
@@ -133,6 +138,7 @@ export default function GalleryScreen() {
if (active) {
setLoading(false);
}
+ galleryLoadingRef.current = false;
});
return () => {
@@ -140,6 +146,10 @@ export default function GalleryScreen() {
};
}, [token, locale]);
+ React.useEffect(() => {
+ photosRef.current = photos;
+ }, [photos]);
+
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
@@ -295,6 +305,7 @@ export default function GalleryScreen() {
setLightboxPhoto(null);
setLightboxLoading(false);
setLightboxError(null);
+ pendingNotFoundRef.current = false;
return;
}
@@ -304,32 +315,61 @@ export default function GalleryScreen() {
if (seed) {
setLightboxPhoto(seed);
}
+ const hasSeed = Boolean(seed);
+
+ if (hasSeed) {
+ setLightboxLoading(false);
+ return;
+ }
let active = true;
+ const maxRetryMs = 10_000;
+ const retryDelayMs = 1500;
+ let timedOut = false;
+ const timeoutId = window.setTimeout(() => {
+ timedOut = true;
+ }, maxRetryMs);
setLightboxLoading(true);
setLightboxError(null);
- fetchPhoto(selectedPhotoId, locale)
- .then((photo) => {
- if (!active || !photo) return;
- const mapped = mapFullPhoto(photo as Record);
- if (mapped) {
- setLightboxPhoto(mapped);
- setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes }));
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+ const loadPhoto = async () => {
+ let lastError: unknown = null;
+
+ while (active && !timedOut) {
+ try {
+ const photo = await fetchPhoto(selectedPhotoId, locale);
+ if (!active) return;
+ if (photo) {
+ const mapped = mapFullPhoto(photo as Record);
+ if (mapped) {
+ setLightboxPhoto(mapped);
+ setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes }));
+ setLightboxLoading(false);
+ return;
+ }
+ }
+ lastError = { status: 404 };
+ } catch (error) {
+ console.error('Lightbox photo load failed', error);
+ lastError = error;
}
- })
- .catch((error) => {
- console.error('Lightbox photo load failed', error);
- if (!active) return;
- setLightboxError(error?.status === 404 ? 'notFound' : 'loadFailed');
- })
- .finally(() => {
- if (active) {
- setLightboxLoading(false);
- }
- });
+
+ if (!active || timedOut) break;
+ await sleep(retryDelayMs);
+ }
+
+ if (!active) return;
+ const status = (lastError as { status?: number } | null)?.status;
+ setLightboxError(status === 404 ? 'notFound' : 'loadFailed');
+ setLightboxLoading(false);
+ };
+
+ void loadPhoto();
return () => {
active = false;
+ window.clearTimeout(timeoutId);
};
}, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]);
@@ -351,6 +391,17 @@ export default function GalleryScreen() {
};
}, [lightboxOpen]);
+ React.useEffect(() => {
+ if (!pendingNotFoundRef.current) return;
+ if (loading || galleryLoadingRef.current) return;
+ if (lightboxSelected || photosRef.current.some((photo) => photo.id === selectedPhotoId)) {
+ pendingNotFoundRef.current = false;
+ return;
+ }
+ pendingNotFoundRef.current = false;
+ setLightboxError('notFound');
+ }, [lightboxSelected, loading]);
+
React.useEffect(() => {
if (!lightboxOpen || !lightboxError) {
return;
@@ -897,9 +948,20 @@ export default function GalleryScreen() {
) : (
-
- {lightboxLoading ? t('galleryPage.loading', 'Loading…') : t('lightbox.errors.notFound', 'Photo not found')}
-
+
+ {lightboxLoading ? (
+ <>
+
+
+ {t('galleryPage.loading', 'Loading…')}
+
+ >
+ ) : (
+
+ {t('lightbox.errors.notFound', 'Photo not found')}
+
+ )}
+
)}
({});
const { isDark } = useGuestThemeVariant();
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
@@ -204,15 +204,35 @@ export default function UploadQueueScreen() {
: ''}
-
-
- {pct !== undefined
- ? t('uploadQueue.progress', { progress: pct }, '{progress}%')
- : item.status === 'done'
- ? t('uploadQueue.progress', { progress: 100 }, '{progress}%')
- : ''}
-
-
+
+
+
+ {pct !== undefined
+ ? t('uploadQueue.progress', { progress: pct }, '{progress}%')
+ : item.status === 'done'
+ ? t('uploadQueue.progress', { progress: 100 }, '{progress}%')
+ : ''}
+
+
+ {item.status === 'error' && typeof item.id === 'number' ? (
+
+ ) : null}
+
([]);
const [pendingCount, setPendingCount] = React.useState(0);
const [pendingLoading, setPendingLoading] = React.useState(false);
+ const optimizeTargetBytes = 1_500_000;
+ const optimizeMaxEdge = 2560;
const previewQueueItems = React.useMemo(() => items.filter((item) => item.status !== 'done').slice(0, 3), [items]);
const previewQueueUrls = React.useMemo(() => {
@@ -183,6 +187,42 @@ export default function UploadScreen() {
}
}, []);
+ const prepareUploadFile = React.useCallback(
+ async (file: File) => {
+ const shouldOptimize = file.size > optimizeTargetBytes || file.type !== 'image/jpeg';
+ if (!shouldOptimize) {
+ return file;
+ }
+
+ try {
+ const optimized = await compressPhoto(file, { targetBytes: optimizeTargetBytes, maxEdge: optimizeMaxEdge });
+ if (optimized.size >= file.size) {
+ return file;
+ }
+ if (optimized.size < file.size - 50_000) {
+ const saved = formatBytes(file.size - optimized.size);
+ pushGuestToast({
+ text: t(
+ 'upload.optimizedNotice',
+ { saved },
+ 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}'
+ ),
+ type: 'info',
+ });
+ }
+ return optimized;
+ } catch (error) {
+ console.warn('Image optimization failed, uploading original', error);
+ pushGuestToast({
+ text: t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.'),
+ type: 'info',
+ });
+ return file;
+ }
+ },
+ [optimizeMaxEdge, optimizeTargetBytes, t]
+ );
+
const uploadFiles = React.useCallback(
async (files: File[]) => {
if (!token || files.length === 0) return;
@@ -193,17 +233,19 @@ export default function UploadScreen() {
setError(null);
setUploadDialog(null);
+ let redirectPhotoId: number | null = null;
for (const file of files) {
+ const preparedFile = await prepareUploadFile(file);
if (!navigator.onLine) {
- await enqueueFile(file);
+ await enqueueFile(preparedFile);
pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' });
continue;
}
try {
setUploading({ name: file.name, progress: 0 });
- await uploadPhoto(token, file, taskId, undefined, {
+ const photoId = await uploadPhoto(token, preparedFile, taskId, undefined, {
guestName: identity?.name ?? undefined,
onProgress: (percent) => {
setUploading((prev) => (prev ? { ...prev, progress: percent } : prev));
@@ -218,26 +260,56 @@ export default function UploadScreen() {
}
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
void loadPending();
+ if (autoApprove && photoId) {
+ redirectPhotoId = photoId;
+ }
} catch (err) {
const uploadErr = err as { code?: string; meta?: Record };
console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
- await enqueueFile(file);
+ await enqueueFile(preparedFile);
} finally {
setUploading(null);
}
}
+
+ if (autoApprove && redirectPhotoId) {
+ navigate(buildEventPath(token, `/gallery?photo=${redirectPhotoId}`));
+ }
},
- [autoApprove, enqueueFile, identity?.name, loadPending, markCompleted, t, taskId, token, triggerConfetti]
+ [
+ autoApprove,
+ enqueueFile,
+ identity?.name,
+ loadPending,
+ markCompleted,
+ navigate,
+ prepareUploadFile,
+ t,
+ taskId,
+ token,
+ triggerConfetti,
+ ]
);
const handleFiles = React.useCallback(
async (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
- await uploadFiles(files);
+ const next = files[0];
+ if (!next) {
+ 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);
+ setCameraState('preview');
},
- [uploadFiles]
+ [previewUrl, t]
);
const handlePick = React.useCallback(() => {
@@ -409,8 +481,10 @@ export default function UploadScreen() {
}
setPreviewFile(null);
setPreviewUrl(null);
- await startCamera();
- }, [previewUrl, startCamera]);
+ if (cameraState === 'preview') {
+ await startCamera();
+ }
+ }, [cameraState, previewUrl, startCamera]);
const handleUseImage = React.useCallback(async () => {
if (!previewFile) return;
@@ -651,7 +725,7 @@ export default function UploadScreen() {
>
- {t('upload.review.retake', 'Nochmal aufnehmen')}
+ {t('upload.review.retakeGallery', 'Ein anderes Foto auswählen')}