void,
+ signal?: AbortSignal,
): Promise {
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))
diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts
index ffbbfb1..d14e63e 100644
--- a/frontend/src/features/admin/media/upload-store.ts
+++ b/frontend/src/features/admin/media/upload-store.ts
@@ -15,6 +15,8 @@ type UploadStore = {
minimized: boolean
skipped: number
enqueue: (files: File[]) => Promise
+ 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()
let running = false
const patch = (id: string, changes: Partial) =>
@@ -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((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 }),
}))
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts
index 54798f4..b205d02 100644
--- a/frontend/src/shared/lib/i18n.ts
+++ b/frontend/src/shared/lib/i18n.ts
@@ -117,6 +117,7 @@ const resources = {
uploaded: 'Файл загружен, идёт обработка',
uploadedCount: 'Загружено файлов: {{count}}',
uploadingCount: 'Загрузка {{done}}/{{total}}',
+ cancelAll: 'Отменить все загрузки',
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
filterActive: 'Активные',
filterAll: 'Все',
@@ -361,6 +362,7 @@ const resources = {
uploaded: 'File uploaded, processing started',
uploadedCount: 'Uploaded files: {{count}}',
uploadingCount: 'Uploading {{done}}/{{total}}',
+ cancelAll: 'Cancel all uploads',
skippedDuplicates: 'Skipped duplicates: {{count}}',
filterActive: 'Active',
filterAll: 'All',