Add lightbox retries and queue removal
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 17:42:44 +01:00
parent 4e0d156065
commit 5f75c7ca6a
10 changed files with 282 additions and 49 deletions

View File

@@ -9,5 +9,15 @@
"qrLoading": "QR wird erstellt...",
"qrRetry": "Erneut versuchen"
}
},
"upload": {
"review": {
"retakeGallery": "Ein anderes Foto auswählen"
}
},
"uploadQueue": {
"actions": {
"removeFailed": "Entfernen"
}
}
}

View File

@@ -9,5 +9,15 @@
"qrLoading": "Generating QR...",
"qrRetry": "Retry"
}
},
"upload": {
"review": {
"retakeGallery": "Choose another photo"
}
},
"uploadQueue": {
"actions": {
"removeFailed": "Remove"
}
}
}

View File

@@ -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: () => <span>share</span>,
ChevronLeft: () => <span>left</span>,
ChevronRight: () => <span>right</span>,
Loader2: () => <span>loader</span>,
X: () => <span>x</span>,
}));
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(<GalleryScreen />);
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(<GalleryScreen />);
await Promise.resolve();
await vi.runAllTimersAsync();
expect(fetchGalleryMock).toHaveBeenCalled();
expect(fetchPhotoMock).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(setSearchParamsMock).not.toHaveBeenCalled();
expect(pushGuestToastMock).not.toHaveBeenCalled();
});
});

View File

@@ -34,6 +34,7 @@ vi.mock('../services/uploadApi', () => ({
retryAll: vi.fn(),
clearFinished: vi.fn(),
refresh: vi.fn(),
remove: vi.fn(),
}),
}));

View File

@@ -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<Set<number>>(new Set());
const touchStartX = React.useRef<number | null>(null);
const fallbackAttemptedRef = React.useRef(false);
const pendingNotFoundRef = React.useRef(false);
const photosRef = React.useRef<GalleryTile[]>([]);
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<string, unknown>);
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<string, unknown>);
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() {
</Button>
</YStack>
) : (
<Text fontSize="$2" color="$color" opacity={0.7}>
{lightboxLoading ? t('galleryPage.loading', 'Loading…') : t('lightbox.errors.notFound', 'Photo not found')}
</Text>
<YStack alignItems="center" gap="$2">
{lightboxLoading ? (
<>
<Loader2 size={24} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('galleryPage.loading', 'Loading…')}
</Text>
</>
) : (
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('lightbox.errors.notFound', 'Photo not found')}
</Text>
)}
</YStack>
)}
</YStack>
<XStack

View File

@@ -18,7 +18,7 @@ export default function UploadQueueScreen() {
const { t } = useTranslation();
const { locale } = useLocale();
const { token } = useEventData();
const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue();
const { items, loading, retryAll, clearFinished, refresh, remove } = useUploadQueue();
const [progress, setProgress] = React.useState<ProgressMap>({});
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() {
: ''}
</Text>
</YStack>
<YStack minWidth={72} alignItems="flex-end">
<Text fontSize="$2" color={mutedText}>
{pct !== undefined
? t('uploadQueue.progress', { progress: pct }, '{progress}%')
: item.status === 'done'
? t('uploadQueue.progress', { progress: 100 }, '{progress}%')
: ''}
</Text>
</YStack>
<XStack alignItems="center" gap="$2">
<YStack minWidth={72} alignItems="flex-end">
<Text fontSize="$2" color={mutedText}>
{pct !== undefined
? t('uploadQueue.progress', { progress: pct }, '{progress}%')
: item.status === 'done'
? t('uploadQueue.progress', { progress: 100 }, '{progress}%')
: ''}
</Text>
</YStack>
{item.status === 'error' && typeof item.id === 'number' ? (
<Button
size="$2"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={() => remove(item.id!)}
aria-label={t('uploadQueue.actions.removeFailed', 'Remove failed upload')}
>
<XStack alignItems="center" justifyContent="center" gap="$2">
<Trash2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadQueue.actions.removeFailed', 'Remove')}
</Text>
</XStack>
</Button>
) : null}
</XStack>
</XStack>
<YStack
height={6}

View File

@@ -16,6 +16,8 @@ import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/up
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { pushGuestToast } from '../lib/toast';
import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';
import { compressPhoto, formatBytes } from '@/guest/lib/image';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
@@ -78,6 +80,8 @@ export default function UploadScreen() {
const [pendingItems, setPendingItems] = React.useState<PendingUpload[]>([]);
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<string, unknown> };
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() {
>
<X size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.retake', 'Nochmal aufnehmen')}
{t('upload.review.retakeGallery', 'Ein anderes Foto auswählen')}
</Text>
</Button>
<Button

View File

@@ -549,6 +549,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
actions: {
retryAll: 'Alle erneut versuchen',
clearFinished: 'Erledigte löschen',
removeFailed: 'Entfernen',
},
status: {
uploaded: 'Hochgeladen',
@@ -721,6 +722,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
review: {
retake: 'Nochmal aufnehmen',
retakeGallery: 'Ein anderes Foto auswählen',
keep: 'Foto verwenden',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
},
@@ -1462,6 +1464,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
actions: {
retryAll: 'Retry all',
clearFinished: 'Clear finished',
removeFailed: 'Remove',
},
status: {
uploaded: 'Uploaded',
@@ -1634,6 +1637,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
review: {
retake: 'Retake photo',
retakeGallery: 'Choose another photo',
keep: 'Use this photo',
readyAnnouncement: 'Photo captured. Please review the preview.',
},

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { enqueue, list, processQueue, clearDone, type QueueItem } from './queue';
import { enqueue, list, processQueue, clearDone, remove, type QueueItem } from './queue';
export function useUploadQueue() {
const [items, setItems] = React.useState<QueueItem[]>([]);
@@ -28,6 +28,14 @@ export function useUploadQueue() {
await refresh();
}, [refresh]);
const removeItem = React.useCallback(
async (id: number) => {
await remove(id);
await refresh();
},
[refresh]
);
React.useEffect(() => {
refresh();
const online = () => processQueue().then(refresh);
@@ -35,6 +43,5 @@ export function useUploadQueue() {
return () => window.removeEventListener('online', online);
}, [refresh]);
return { items, loading, refresh, add, retryAll, clearFinished } as const;
return { items, loading, refresh, add, retryAll, clearFinished, remove: removeItem } as const;
}

View File

@@ -57,6 +57,12 @@ export async function clearDone() {
});
}
export async function remove(id: number) {
await withStore('readwrite', (store) => {
store.delete(id);
});
}
export async function processQueue() {
if (processing) return; processing = true;
try {