From 817b9a5357c7f3879b36cb42c53c42019402e700 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 22 May 2026 15:17:23 +0200 Subject: [PATCH] fix(journey): resilient per-file photo upload with retry and progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/api/client.ts | 16 +++- client/src/i18n/translations/ar.ts | 2 + client/src/i18n/translations/br.ts | 2 + client/src/i18n/translations/cs.ts | 2 + client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + client/src/i18n/translations/es.ts | 2 + client/src/i18n/translations/fr.ts | 2 + client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/id.ts | 2 + client/src/i18n/translations/it.ts | 2 + client/src/i18n/translations/nl.ts | 2 + client/src/i18n/translations/pl.ts | 2 + client/src/i18n/translations/ru.ts | 2 + client/src/i18n/translations/zh.ts | 2 + client/src/i18n/translations/zhTw.ts | 2 + client/src/pages/JourneyDetailPage.tsx | 50 +++++++----- client/src/store/journeyStore.test.ts | 54 ++++++++++++- client/src/store/journeyStore.ts | 70 ++++++++++------ client/src/utils/uploadQueue.ts | 106 +++++++++++++++++++++++++ 20 files changed, 277 insertions(+), 49 deletions(-) create mode 100644 client/src/utils/uploadQueue.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7b2a601d..57c90fbb 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 77e5708b..c9a54cec 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1713,6 +1713,8 @@ const ar: Record = { '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': 'جعله الأول', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 6b559c35..7fa534c1 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -2084,6 +2084,8 @@ const br: Record = { '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...', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 96cd2f4c..92ea45b9 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -2089,6 +2089,8 @@ const cs: Record = { '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...', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 2a97e7d0..a1399ed4 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -2092,6 +2092,8 @@ const de: Record = { '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...', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 5c8cf5a6..a1c514f6 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -2118,6 +2118,8 @@ const en: Record = { '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...', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index f5bc2626..b1865a73 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -2091,6 +2091,8 @@ const es: Record = { '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...', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 2ec4fcfd..8337a67c 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -2085,6 +2085,8 @@ const fr: Record = { '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...', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index ce7f7aee..18450c69 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -2086,6 +2086,8 @@ const hu: Record = { '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...', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index f4916aa9..283c4e88 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -2101,6 +2101,8 @@ const id: Record = { '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...', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index a7c5ee99..3e6aeae7 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -2086,6 +2086,8 @@ const it: Record = { '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...', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0633e820..93694977 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -2085,6 +2085,8 @@ const nl: Record = { '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...', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index d90bb656..205ccaab 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -2078,6 +2078,8 @@ const pl: Record = { '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ę...', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index d85ce0c9..75b2141c 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -2085,6 +2085,8 @@ const ru: Record = { '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': 'Напишите свою историю...', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 857a873e..5c8f4979 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -2085,6 +2085,8 @@ const zh: Record = { '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': '写下你的故事...', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 70778e48..695eb974 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -2043,6 +2043,8 @@ const zhTw: Record = { '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': '寫下你的故事...', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index fcd54545..9f360f2f 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -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(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) => { 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 ? ( - <>
{t('journey.editor.uploading')} + <>
{galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')} ) : ( <> {t('common.upload')} )} @@ -2172,7 +2178,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa galleryPhotos: GalleryPhoto[] onClose: () => void onSave: (data: Record) => Promise - onUploadPhotos: (entryId: number, formData: FormData) => Promise + onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise> onDone: () => void }) { const { t } = useTranslation() @@ -2195,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) const [cons, setCons] = useState(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([]) const [pendingLinkIds, setPendingLinkIds] = useState([]) @@ -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