diff --git a/frontend/src/features/admin/media/UploadSnackbar.tsx b/frontend/src/features/admin/media/UploadSnackbar.tsx
index 965fa62..eeea8d2 100644
--- a/frontend/src/features/admin/media/UploadSnackbar.tsx
+++ b/frontend/src/features/admin/media/UploadSnackbar.tsx
@@ -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() {
)}
+ {item.status === 'error' && (
+
+ )}
Promise
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()
+const failed = new Map() // упавшие — для ручного повтора
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) =>
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((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((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 })
+ },
}))
diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts
index 6593b50..76f2eec 100644
--- a/frontend/src/shared/api/client.ts
+++ b/frontend/src/shared/api/client.ts
@@ -24,7 +24,7 @@ type RequestOptions = {
skipRefresh?: boolean
}
-async function refreshAccessToken(): Promise {
+export async function refreshAccessToken(): Promise {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts
index f1ac733..2e4813c 100644
--- a/frontend/src/shared/lib/i18n.ts
+++ b/frontend/src/shared/lib/i18n.ts
@@ -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…',