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')}