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:
Leonid Pershin
2026-07-25 08:38:12 +03:00
parent de7e27c80f
commit ba023bc416
4 changed files with 83 additions and 18 deletions
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next' 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 { cn } from '@/shared/lib/cn'
import { type UploadItem, useUploadStore } from './upload-store' import { type UploadItem, useUploadStore } from './upload-store'
@@ -25,7 +25,7 @@ function barWidth(item: UploadItem): number {
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */ /** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
export function UploadSnackbar() { export function UploadSnackbar() {
const { t } = useTranslation() const { t } = useTranslation()
const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll } = const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll, retry } =
useUploadStore() useUploadStore()
if (items.length === 0 && skipped === 0) return null if (items.length === 0 && skipped === 0) return null
@@ -103,6 +103,16 @@ export function UploadSnackbar() {
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </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>
<div className="h-1 w-full overflow-hidden rounded bg-muted"> <div className="h-1 w-full overflow-hidden rounded bg-muted">
<div <div
@@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { HttpError, refreshAccessToken } from '@/shared/api/client'
import { queryClient } from '@/shared/api/query-client' import { queryClient } from '@/shared/api/query-client'
import { addEpisode } from '@/features/admin/shows/api' import { addEpisode } from '@/features/admin/shows/api'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
@@ -19,6 +20,7 @@ type UploadStore = {
enqueue: (files: File[], options?: EnqueueOptions) => Promise<void> enqueue: (files: File[], options?: EnqueueOptions) => Promise<void>
cancel: (id: string) => void cancel: (id: string) => void
cancelAll: () => void cancelAll: () => void
retry: (id: string) => void
toggleMinimize: () => void toggleMinimize: () => void
dismiss: () => void dismiss: () => void
} }
@@ -28,10 +30,27 @@ export type EnqueueOptions = { showId?: string }
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации. // Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0 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 controllers = new Map<string, AbortController>()
const failed = new Map<string, Job>() // упавшие — для ручного повтора
let running = false 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>) => const patch = (id: string, changes: Partial<UploadItem>) =>
useUploadStore.setState((s) => ({ useUploadStore.setState((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)), items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
@@ -46,13 +65,35 @@ async function pump() {
const job = queue.shift()! const job = queue.shift()!
const controller = new AbortController() const controller = new AbortController()
controllers.set(job.id, controller) controllers.set(job.id, controller)
patch(job.id, { status: 'uploading', percent: 0 })
try { let created: { id: string } | null = null
const created = await uploadMedia( let lastError: unknown = null
job.file, // Ретраим временные сбои (например, 502 от прокси) — до MAX_ATTEMPTS попыток.
(percent) => patch(job.id, { percent }), for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
controller.signal, 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 }) patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
@@ -65,12 +106,11 @@ async function pump() {
toast.error(`${job.file.name}: не удалось добавить в шоу`) toast.error(`${job.file.name}: не удалось добавить в шоу`)
} }
} }
} catch (error) { } else if (isAbort(lastError) || controller.signal.aborted) {
// Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой. // Отмена — тихо: элемент уже убран из списка.
if (!(error instanceof DOMException && error.name === 'AbortError')) } else {
patch(job.id, { status: 'error' }) failed.set(job.id, job) // сохраняем для ручного повтора
} finally { patch(job.id, { status: 'error' })
controllers.delete(job.id)
} }
} }
@@ -126,6 +166,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
const queuedIndex = queue.findIndex((j) => j.id === id) const queuedIndex = queue.findIndex((j) => j.id === id)
if (queuedIndex !== -1) queue.splice(queuedIndex, 1) if (queuedIndex !== -1) queue.splice(queuedIndex, 1)
controllers.get(id)?.abort() controllers.get(id)?.abort()
failed.delete(id)
set((s) => ({ items: s.items.filter((i) => i.id !== 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 })), toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
dismiss: () => set({ items: [], skipped: 0 }), dismiss: () => {
failed.clear()
set({ items: [], skipped: 0 })
},
})) }))
+1 -1
View File
@@ -24,7 +24,7 @@ type RequestOptions = {
skipRefresh?: boolean skipRefresh?: boolean
} }
async function refreshAccessToken(): Promise<boolean> { export async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) { if (!refreshInFlight) {
refreshInFlight = (async () => { refreshInFlight = (async () => {
try { try {
+2
View File
@@ -19,6 +19,7 @@ const resources = {
common: { common: {
save: 'Сохранить', save: 'Сохранить',
cancel: 'Отмена', cancel: 'Отмена',
retry: 'Повторить',
delete: 'Удалить', delete: 'Удалить',
create: 'Создать', create: 'Создать',
loading: 'Загрузка…', loading: 'Загрузка…',
@@ -279,6 +280,7 @@ const resources = {
common: { common: {
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
retry: 'Retry',
delete: 'Delete', delete: 'Delete',
create: 'Create', create: 'Create',
loading: 'Loading…', loading: 'Loading…',