Enhance media upload functionality: add abort signal support for uploadMedia, enabling cancellation of ongoing uploads. Implement cancel and cancelAll methods in upload store to manage queued and active uploads. Update UploadSnackbar to include cancel buttons for individual and all uploads. Enhance i18n with new cancellation strings.

This commit is contained in:
Leonid Pershin
2026-07-25 08:06:54 +03:00
parent ca907762aa
commit 4bebe64ff0
4 changed files with 65 additions and 4 deletions
@@ -25,7 +25,8 @@ function barWidth(item: UploadItem): number {
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
export function UploadSnackbar() {
const { t } = useTranslation()
const { items, active, minimized, skipped, toggleMinimize, dismiss } = useUploadStore()
const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll } =
useUploadStore()
if (items.length === 0 && skipped === 0) return null
@@ -50,6 +51,16 @@ export function UploadSnackbar() {
{minimized ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
)}
{active && (
<button
type="button"
className="opacity-70 hover:opacity-100"
title={t('admin.media.cancelAll')}
onClick={cancelAll}
>
<X className="h-4 w-4" />
</button>
)}
{!active && (
<button
type="button"
@@ -79,6 +90,19 @@ export function UploadSnackbar() {
{item.status === 'uploading' && (
<span className="ml-auto shrink-0 text-muted-foreground">{item.percent}%</span>
)}
{(item.status === 'queued' || item.status === 'uploading') && (
<button
type="button"
className={cn(
'shrink-0 opacity-70 hover:opacity-100',
item.status === 'queued' && 'ml-auto',
)}
title={t('common.cancel')}
onClick={() => cancel(item.id)}
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="h-1 w-full overflow-hidden rounded bg-muted">
<div
+9
View File
@@ -33,8 +33,14 @@ export function deleteMedia(id: string) {
export function uploadMedia(
file: File,
onProgress?: (percent: number) => void,
signal?: AbortSignal,
): Promise<CreatedIdResponse> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError'))
return
}
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open('POST', `/api/admin/media?${query.toString()}`)
@@ -42,6 +48,9 @@ export function uploadMedia(
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
signal?.addEventListener('abort', () => xhr.abort())
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress)
onProgress(Math.round((event.loaded / event.total) * 100))
@@ -15,6 +15,8 @@ type UploadStore = {
minimized: boolean
skipped: number
enqueue: (files: File[]) => Promise<void>
cancel: (id: string) => void
cancelAll: () => void
toggleMinimize: () => void
dismiss: () => void
}
@@ -22,6 +24,7 @@ type UploadStore = {
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0
const queue: { id: string; file: File }[] = []
const controllers = new Map<string, AbortController>()
let running = false
const patch = (id: string, changes: Partial<UploadItem>) =>
@@ -36,13 +39,19 @@ async function pump() {
while (queue.length > 0) {
const job = queue.shift()!
const controller = new AbortController()
controllers.set(job.id, controller)
patch(job.id, { status: 'uploading', percent: 0 })
try {
await uploadMedia(job.file, (percent) => patch(job.id, { percent }))
await uploadMedia(job.file, (percent) => patch(job.id, { percent }), controller.signal)
patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
} catch {
patch(job.id, { status: 'error' })
} catch (error) {
// Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой.
if (!(error instanceof DOMException && error.name === 'AbortError'))
patch(job.id, { status: 'error' })
} finally {
controllers.delete(job.id)
}
}
@@ -93,6 +102,23 @@ export const useUploadStore = create<UploadStore>((set) => ({
if (newItems.length) void pump()
},
cancel: (id) => {
// Из очереди — если ещё не стартовал; активную загрузку прерываем.
const queuedIndex = queue.findIndex((j) => j.id === id)
if (queuedIndex !== -1) queue.splice(queuedIndex, 1)
controllers.get(id)?.abort()
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
},
cancelAll: () => {
queue.length = 0
controllers.forEach((controller) => controller.abort())
// Оставляем уже завершённые/ошибочные, убираем очередь и текущую загрузку.
set((s) => ({
items: s.items.filter((i) => i.status === 'done' || i.status === 'error'),
}))
},
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
dismiss: () => set({ items: [], skipped: 0 }),
}))