Add lightbox retries and queue removal
This commit is contained in:
@@ -9,5 +9,15 @@
|
||||
"qrLoading": "QR wird erstellt...",
|
||||
"qrRetry": "Erneut versuchen"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"review": {
|
||||
"retakeGallery": "Ein anderes Foto auswählen"
|
||||
}
|
||||
},
|
||||
"uploadQueue": {
|
||||
"actions": {
|
||||
"removeFailed": "Entfernen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,15 @@
|
||||
"qrLoading": "Generating QR...",
|
||||
"qrRetry": "Retry"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"review": {
|
||||
"retakeGallery": "Choose another photo"
|
||||
}
|
||||
},
|
||||
"uploadQueue": {
|
||||
"actions": {
|
||||
"removeFailed": "Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock('../services/uploadApi', () => ({
|
||||
retryAll: vi.fn(),
|
||||
clearFinished: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user