mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
fix(journey): resilient per-file photo upload with retry and progress
Replace the single combined-FormData POST (which timed out at 8 s on slow connections) with a concurrent per-file upload queue: each file is uploaded individually with timeout disabled, up to 2 retries with backoff (4xx errors are not retried), and a stable idempotency key per file so server-side dedup prevents duplicates on retry. Partial success is preserved — succeeded photos merge into state immediately; failed files stay queued for re-save. Progress is surfaced as "Uploading n/N" in both the entry editor and gallery upload button. Adds translations for two new keys across all 15 locales. Closes #1013
This commit is contained in:
@@ -407,8 +407,20 @@ export const journeyApi = {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
|
||||
@@ -1713,6 +1713,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
'journey.editor.addAnother': 'إضافة آخر',
|
||||
'journey.editor.makeFirst': 'جعله الأول',
|
||||
|
||||
@@ -2084,6 +2084,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos falharam — salve novamente para tentar',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||
'journey.editor.writeStory': 'Escreva sua história...',
|
||||
|
||||
@@ -2089,6 +2089,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.uploadingProgress': 'Nahrávání {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} z {total} fotek selhalo — uložte znovu pro opakování',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
||||
'journey.editor.writeStory': 'Napište svůj příběh...',
|
||||
|
||||
@@ -2092,6 +2092,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.uploadingProgress': 'Hochladen {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} von {total} Fotos fehlgeschlagen — erneut speichern zum Wiederholen',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
||||
|
||||
@@ -2118,6 +2118,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.uploadingProgress': 'Uploading {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
'journey.editor.allPhotosAdded': 'All photos already added',
|
||||
'journey.editor.writeStory': 'Write your story...',
|
||||
|
||||
@@ -2091,6 +2091,8 @@ const es: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||
|
||||
@@ -2085,6 +2085,8 @@ const fr: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Échec du téléversement des photos',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.uploadingProgress': 'Téléversement {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} sur {total} photos ont échoué — sauvegardez à nouveau pour réessayer',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||
|
||||
@@ -2086,6 +2086,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.uploadingProgress': 'Feltöltés {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} / {total} fotó sikertelen — mentsd el újra a próbálkozáshoz',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||
'journey.editor.writeStory': 'Írd meg a történeted...',
|
||||
|
||||
@@ -2101,6 +2101,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'journey.editor.uploading': 'Mengunggah...',
|
||||
'journey.editor.uploadingProgress': 'Mengunggah {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} dari {total} foto gagal — simpan lagi untuk mencoba ulang',
|
||||
'journey.editor.fromGallery': 'Dari Galeri',
|
||||
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
||||
'journey.editor.writeStory': 'Tulis kisahmu...',
|
||||
|
||||
@@ -2086,6 +2086,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.uploadingProgress': 'Caricamento {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} di {total} foto non riuscite — salva di nuovo per riprovare',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
||||
|
||||
@@ -2085,6 +2085,8 @@ const nl: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.uploadingProgress': 'Uploaden {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} van {total} foto\'s mislukt — sla opnieuw op om het opnieuw te proberen',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||
|
||||
@@ -2078,6 +2078,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.uploadingProgress': 'Przesyłanie {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} z {total} zdjęć nie powiodło się — zapisz ponownie, aby spróbować',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||
'journey.editor.writeStory': 'Napisz swoją historię...',
|
||||
|
||||
@@ -2085,6 +2085,8 @@ const ru: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||
|
||||
@@ -2085,6 +2085,8 @@ const zh: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': '照片上传失败',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||
'journey.editor.writeStory': '写下你的故事...',
|
||||
|
||||
@@ -2043,6 +2043,8 @@ const zhTw: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||
'journey.editor.writeStory': '寫下你的故事...',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -748,8 +749,8 @@ export default function JourneyDetailPage() {
|
||||
}
|
||||
return entryId
|
||||
}}
|
||||
onUploadPhotos={async (entryId, formData) => {
|
||||
return await uploadPhotos(entryId, formData)
|
||||
onUploadPhotos={async (entryId, files, cbs) => {
|
||||
return await uploadPhotos(entryId, files, cbs)
|
||||
}}
|
||||
onDone={() => {
|
||||
setEditingEntry(null)
|
||||
@@ -987,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
||||
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const galleryUploading = galleryProgress !== null
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
@@ -1027,18 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const formData = new FormData()
|
||||
for (const f of normalized) formData.append('photos', f)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
}
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||
} finally {
|
||||
setGalleryUploading(false)
|
||||
setGalleryProgress(null)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
@@ -1083,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{galleryUploading ? (
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={12} /> {t('common.upload')}</>
|
||||
)}
|
||||
@@ -2172,7 +2178,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -2195,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
@@ -2248,12 +2254,20 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const formData = new FormData()
|
||||
for (const f of pendingFiles) formData.append('photos', f)
|
||||
const filesToUpload = pendingFiles
|
||||
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||
try {
|
||||
await onUploadPhotos(entryId, formData)
|
||||
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
setPendingFiles(failed)
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
}
|
||||
// link gallery photos that were picked before save
|
||||
@@ -2309,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
disabled={saving}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
{uploadProgress ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||
) : (
|
||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||
)}
|
||||
|
||||
@@ -287,13 +287,61 @@ describe('journeyStore', () => {
|
||||
HttpResponse.json({ photos: [newPhoto] })
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(91);
|
||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(91);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||
const entry = buildEntry({ id: 100, photos: [] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||
expect(result.succeeded).toHaveLength(0);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0]).toBe(file);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||
const entry = buildEntry({ id: 100, photos: [] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return HttpResponse.json({ photos: [photo1] });
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||
// concurrency:1 so order is deterministic
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(1);
|
||||
void photo2; // referenced to avoid lint warning
|
||||
});
|
||||
|
||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-014: deletePhoto removes photo from entry', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
|
||||
export interface Journey {
|
||||
id: number
|
||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
||||
deleteEntry: (entryId: number) => Promise<void>
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||
|
||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
uploadPhotos: async (entryId, formData) => {
|
||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
||||
const photos = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
uploadPhotos: async (entryId, files, cbs) => {
|
||||
return uploadFilesResilient<JourneyPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||
const photos: JourneyPhoto[] = data.photos || []
|
||||
const gallery: GalleryPhoto[] = data.gallery || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...gallery],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||
})
|
||||
return photos
|
||||
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||
return uploadFilesResilient<GalleryPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
|
||||
export interface UploadProgress {
|
||||
done: number
|
||||
total: number
|
||||
failed: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface ResilientResult<T> {
|
||||
succeeded: T[]
|
||||
failed: File[]
|
||||
}
|
||||
|
||||
export interface UploadOpts {
|
||||
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const status = (err as { response?: { status?: number } }).response?.status
|
||||
if (status !== undefined && status >= 400 && status < 500) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function uploadFilesResilient<T>(
|
||||
files: File[],
|
||||
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||
cbs?: {
|
||||
concurrency?: number
|
||||
retries?: number
|
||||
onProgress?: (p: UploadProgress) => void
|
||||
onUploaded?: (items: T[]) => void
|
||||
},
|
||||
): Promise<ResilientResult<T>> {
|
||||
const concurrency = cbs?.concurrency ?? 3
|
||||
const maxRetries = cbs?.retries ?? 2
|
||||
|
||||
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||
const loadedMap = new Map<number, number>()
|
||||
let doneCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
const emitProgress = () => {
|
||||
if (!cbs?.onProgress) return
|
||||
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||
}
|
||||
|
||||
const succeeded: T[] = []
|
||||
const failedFiles: File[] = []
|
||||
|
||||
let idx = 0
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const i = idx++
|
||||
if (i >= files.length) break
|
||||
const file = files[i]
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
loadedMap.set(i, 0)
|
||||
|
||||
let items: T[] | null = null
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) await sleep(400 * attempt)
|
||||
try {
|
||||
items = await uploadOne(file, {
|
||||
idempotencyKey,
|
||||
onUploadProgress: (e) => {
|
||||
loadedMap.set(i, e.loaded)
|
||||
emitProgress()
|
||||
},
|
||||
})
|
||||
break
|
||||
} catch (err) {
|
||||
if (!isRetryable(err) || attempt === maxRetries) {
|
||||
items = null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items !== null) {
|
||||
succeeded.push(...items)
|
||||
cbs?.onUploaded?.(items)
|
||||
loadedMap.set(i, file.size)
|
||||
doneCount++
|
||||
} else {
|
||||
failedFiles.push(file)
|
||||
loadedMap.set(i, 0)
|
||||
failedCount++
|
||||
}
|
||||
emitProgress()
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return { succeeded, failed: failedFiles }
|
||||
}
|
||||
Reference in New Issue
Block a user