Enhance upload functionality in UploadStore and UI: implement retry mechanism for failed uploads, allowing users to manually retry uploads from the UploadSnackbar. Update translations to include retry options and improve error handling for transient issues during media uploads.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, X } from 'lucide-react'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, RotateCw, X } from 'lucide-react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { type UploadItem, useUploadStore } from './upload-store'
|
||||
|
||||
@@ -25,7 +25,7 @@ function barWidth(item: UploadItem): number {
|
||||
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
|
||||
export function UploadSnackbar() {
|
||||
const { t } = useTranslation()
|
||||
const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll } =
|
||||
const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll, retry } =
|
||||
useUploadStore()
|
||||
|
||||
if (items.length === 0 && skipped === 0) return null
|
||||
@@ -103,6 +103,16 @@ export function UploadSnackbar() {
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto shrink-0 opacity-70 hover:opacity-100"
|
||||
title={t('common.retry')}
|
||||
onClick={() => retry(item.id)}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { HttpError, refreshAccessToken } from '@/shared/api/client'
|
||||
import { queryClient } from '@/shared/api/query-client'
|
||||
import { addEpisode } from '@/features/admin/shows/api'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
@@ -19,6 +20,7 @@ type UploadStore = {
|
||||
enqueue: (files: File[], options?: EnqueueOptions) => Promise<void>
|
||||
cancel: (id: string) => void
|
||||
cancelAll: () => void
|
||||
retry: (id: string) => void
|
||||
toggleMinimize: () => void
|
||||
dismiss: () => void
|
||||
}
|
||||
@@ -28,10 +30,27 @@ export type EnqueueOptions = { showId?: string }
|
||||
|
||||
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
||||
let counter = 0
|
||||
const queue: { id: string; file: File; showId?: string }[] = []
|
||||
type Job = { id: string; file: File; showId?: string }
|
||||
const queue: Job[] = []
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const failed = new Map<string, Job>() // упавшие — для ручного повтора
|
||||
let running = false
|
||||
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRY_DELAY_MS = 1500
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** Временная ли ошибка (стоит ретраить): 5xx/сеть — да; 4xx (формат/дубликат) — нет. */
|
||||
function isTransient(error: unknown): boolean {
|
||||
if (error instanceof HttpError) return error.status >= 500 || error.status === 0
|
||||
return true
|
||||
}
|
||||
|
||||
function isAbort(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
const patch = (id: string, changes: Partial<UploadItem>) =>
|
||||
useUploadStore.setState((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
|
||||
@@ -46,13 +65,35 @@ async function pump() {
|
||||
const job = queue.shift()!
|
||||
const controller = new AbortController()
|
||||
controllers.set(job.id, controller)
|
||||
patch(job.id, { status: 'uploading', percent: 0 })
|
||||
try {
|
||||
const created = await uploadMedia(
|
||||
job.file,
|
||||
(percent) => patch(job.id, { percent }),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
let created: { id: string } | null = null
|
||||
let lastError: unknown = null
|
||||
// Ретраим временные сбои (например, 502 от прокси) — до MAX_ATTEMPTS попыток.
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
patch(job.id, { status: 'uploading', percent: 0 })
|
||||
try {
|
||||
created = await uploadMedia(
|
||||
job.file,
|
||||
(percent) => patch(job.id, { percent }),
|
||||
controller.signal,
|
||||
)
|
||||
lastError = null
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (isAbort(error) || controller.signal.aborted) break
|
||||
// Истёк access-токен (XHR идёт мимо авто-refresh) — обновляем и повторяем сразу.
|
||||
if (error instanceof HttpError && error.status === 401 && attempt < MAX_ATTEMPTS) {
|
||||
if (await refreshAccessToken()) continue
|
||||
break
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS && isTransient(error)) await delay(RETRY_DELAY_MS)
|
||||
else break
|
||||
}
|
||||
}
|
||||
controllers.delete(job.id)
|
||||
|
||||
if (created) {
|
||||
patch(job.id, { status: 'done', percent: 100 })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||
|
||||
@@ -65,12 +106,11 @@ async function pump() {
|
||||
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой.
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError'))
|
||||
patch(job.id, { status: 'error' })
|
||||
} finally {
|
||||
controllers.delete(job.id)
|
||||
} else if (isAbort(lastError) || controller.signal.aborted) {
|
||||
// Отмена — тихо: элемент уже убран из списка.
|
||||
} else {
|
||||
failed.set(job.id, job) // сохраняем для ручного повтора
|
||||
patch(job.id, { status: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +166,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
|
||||
const queuedIndex = queue.findIndex((j) => j.id === id)
|
||||
if (queuedIndex !== -1) queue.splice(queuedIndex, 1)
|
||||
controllers.get(id)?.abort()
|
||||
failed.delete(id)
|
||||
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
|
||||
},
|
||||
|
||||
@@ -138,6 +179,18 @@ export const useUploadStore = create<UploadStore>((set) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
retry: (id) => {
|
||||
const job = failed.get(id)
|
||||
if (!job) return
|
||||
failed.delete(id)
|
||||
queue.push(job)
|
||||
patch(id, { status: 'queued', percent: 0 })
|
||||
void pump()
|
||||
},
|
||||
|
||||
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
|
||||
dismiss: () => set({ items: [], skipped: 0 }),
|
||||
dismiss: () => {
|
||||
failed.clear()
|
||||
set({ items: [], skipped: 0 })
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -24,7 +24,7 @@ type RequestOptions = {
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
export async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ const resources = {
|
||||
common: {
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
retry: 'Повторить',
|
||||
delete: 'Удалить',
|
||||
create: 'Создать',
|
||||
loading: 'Загрузка…',
|
||||
@@ -279,6 +280,7 @@ const resources = {
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
retry: 'Retry',
|
||||
delete: 'Delete',
|
||||
create: 'Create',
|
||||
loading: 'Loading…',
|
||||
|
||||
Reference in New Issue
Block a user