Enhance EntryTraceDialog component by refactoring data display logic into dedicated summary functions for improved readability and maintainability. Update GridTab to streamline checkbox state management with a new toggle function. Refactor RulesCard to simplify window removal logic. Adjust CollectionsPanel, GenresPanel, GroupsPanel, RolesPanel, ShowsPanel, and UsersPanel to import sorting utilities from a centralized location, enhancing code organization. Update ThemeProvider to utilize a shared theme context for better consistency across the application.
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 50s
ci / tests (push) Successful in 2m34s
ci / sonar (push) Successful in 6m13s

This commit is contained in:
Leonid Pershin
2026-07-27 00:25:11 +03:00
parent f4926848d7
commit 419aff54fa
26 changed files with 355 additions and 251 deletions
@@ -5,6 +5,7 @@ import { queryClient } from '@/shared/api/query-client'
import { importInterstitials } from '@/features/admin/interstitials/api'
import { addEpisode } from '@/features/admin/shows/api'
import { toast } from '@/shared/ui/toast-store'
import type { CreatedIdResponse } from '@/shared/api/types'
import { listMedia, uploadMedia } from './api'
export type UploadItem = {
@@ -67,71 +68,81 @@ const patch = (id: string, changes: Partial<UploadItem>) =>
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
}))
/**
* Стоит ли повторить попытку: отмена — нет; истёкший access-токен (XHR идёт мимо авто-refresh) —
* обновляем и повторяем сразу; прочие временные сбои — после паузы.
*/
async function shouldRetry(error: unknown, attempt: number, signal: AbortSignal): Promise<boolean> {
if (isAbort(error) || signal.aborted) return false
if (attempt >= MAX_ATTEMPTS) return false
if (error instanceof HttpError && error.status === 401) return await refreshAccessToken()
if (!isTransient(error)) return false
await delay(RETRY_DELAY_MS)
return true
}
/** Аплоад с ретраями временных сбоев (например, 502 от прокси) — до MAX_ATTEMPTS попыток. */
async function uploadWithRetries(
job: Job,
signal: AbortSignal,
): Promise<{ created: CreatedIdResponse | null; lastError: unknown }> {
let lastError: unknown = null
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
patch(job.id, { status: 'uploading', percent: 0 })
try {
const created = await uploadMedia(job.file, (percent) => patch(job.id, { percent }), signal)
return { created, lastError: null }
} catch (error) {
lastError = error
if (!(await shouldRetry(error, attempt, signal))) break
}
}
return { created: null, lastError }
}
/** Что делаем со свежим ассетом: привязка к шоу серией (порядок — как в очереди) либо ролик. */
async function linkUploaded(job: Job, assetId: string) {
if (job.showId) {
try {
await addEpisode(job.showId, assetId)
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`)
}
} else if (job.interstitial) {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try {
await importInterstitials([assetId])
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
} catch {
toast.error(`${job.file.name}: не удалось завести ролик`)
}
}
}
async function runJob(job: Job) {
const controller = new AbortController()
controllers.set(job.id, controller)
const { created, lastError } = await uploadWithRetries(job, controller.signal)
controllers.delete(job.id)
if (created) {
patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: qk.media.all })
await linkUploaded(job, created.id)
} else if (!isAbort(lastError) && !controller.signal.aborted) {
// Отмена — тихо: элемент уже убран из списка; всё остальное оставляем для ручного повтора.
failed.set(job.id, job)
patch(job.id, { status: 'error' })
}
}
async function pump() {
if (running) return
running = true
useUploadStore.setState({ active: true, minimized: false })
while (queue.length > 0) {
const job = queue.shift()!
const controller = new AbortController()
controllers.set(job.id, controller)
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: qk.media.all })
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
if (job.showId) {
try {
await addEpisode(job.showId, created.id)
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`)
}
} else if (job.interstitial) {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try {
await importInterstitials([created.id])
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
} catch {
toast.error(`${job.file.name}: не удалось завести ролик`)
}
}
} else if (isAbort(lastError) || controller.signal.aborted) {
// Отмена — тихо: элемент уже убран из списка.
} else {
failed.set(job.id, job) // сохраняем для ручного повтора
patch(job.id, { status: 'error' })
}
}
while (queue.length > 0) await runJob(queue.shift()!)
running = false
useUploadStore.setState({ active: false })