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:
@@ -25,7 +25,8 @@ function barWidth(item: UploadItem): number {
|
|||||||
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
|
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
|
||||||
export function UploadSnackbar() {
|
export function UploadSnackbar() {
|
||||||
const { t } = useTranslation()
|
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
|
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" />}
|
{minimized ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
</button>
|
</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 && (
|
{!active && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -79,6 +90,19 @@ export function UploadSnackbar() {
|
|||||||
{item.status === 'uploading' && (
|
{item.status === 'uploading' && (
|
||||||
<span className="ml-auto shrink-0 text-muted-foreground">{item.percent}%</span>
|
<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>
|
||||||
<div className="h-1 w-full overflow-hidden rounded bg-muted">
|
<div className="h-1 w-full overflow-hidden rounded bg-muted">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -33,8 +33,14 @@ export function deleteMedia(id: string) {
|
|||||||
export function uploadMedia(
|
export function uploadMedia(
|
||||||
file: File,
|
file: File,
|
||||||
onProgress?: (percent: number) => void,
|
onProgress?: (percent: number) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<CreatedIdResponse> {
|
): Promise<CreatedIdResponse> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
const query = new URLSearchParams({ fileName: file.name })
|
const query = new URLSearchParams({ fileName: file.name })
|
||||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||||
@@ -42,6 +48,9 @@ export function uploadMedia(
|
|||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', () => xhr.abort())
|
||||||
|
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
|
||||||
xhr.upload.onprogress = (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (event.lengthComputable && onProgress)
|
if (event.lengthComputable && onProgress)
|
||||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type UploadStore = {
|
|||||||
minimized: boolean
|
minimized: boolean
|
||||||
skipped: number
|
skipped: number
|
||||||
enqueue: (files: File[]) => Promise<void>
|
enqueue: (files: File[]) => Promise<void>
|
||||||
|
cancel: (id: string) => void
|
||||||
|
cancelAll: () => void
|
||||||
toggleMinimize: () => void
|
toggleMinimize: () => void
|
||||||
dismiss: () => void
|
dismiss: () => void
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,7 @@ type UploadStore = {
|
|||||||
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
||||||
let counter = 0
|
let counter = 0
|
||||||
const queue: { id: string; file: File }[] = []
|
const queue: { id: string; file: File }[] = []
|
||||||
|
const controllers = new Map<string, AbortController>()
|
||||||
let running = false
|
let running = false
|
||||||
|
|
||||||
const patch = (id: string, changes: Partial<UploadItem>) =>
|
const patch = (id: string, changes: Partial<UploadItem>) =>
|
||||||
@@ -36,13 +39,19 @@ async function pump() {
|
|||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const job = queue.shift()!
|
const job = queue.shift()!
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers.set(job.id, controller)
|
||||||
patch(job.id, { status: 'uploading', percent: 0 })
|
patch(job.id, { status: 'uploading', percent: 0 })
|
||||||
try {
|
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 })
|
patch(job.id, { status: 'done', percent: 100 })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||||
} catch {
|
} catch (error) {
|
||||||
patch(job.id, { status: '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()
|
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 })),
|
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
|
||||||
dismiss: () => set({ items: [], skipped: 0 }),
|
dismiss: () => set({ items: [], skipped: 0 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const resources = {
|
|||||||
uploaded: 'Файл загружен, идёт обработка',
|
uploaded: 'Файл загружен, идёт обработка',
|
||||||
uploadedCount: 'Загружено файлов: {{count}}',
|
uploadedCount: 'Загружено файлов: {{count}}',
|
||||||
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
||||||
|
cancelAll: 'Отменить все загрузки',
|
||||||
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
|
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
|
||||||
filterActive: 'Активные',
|
filterActive: 'Активные',
|
||||||
filterAll: 'Все',
|
filterAll: 'Все',
|
||||||
@@ -361,6 +362,7 @@ const resources = {
|
|||||||
uploaded: 'File uploaded, processing started',
|
uploaded: 'File uploaded, processing started',
|
||||||
uploadedCount: 'Uploaded files: {{count}}',
|
uploadedCount: 'Uploaded files: {{count}}',
|
||||||
uploadingCount: 'Uploading {{done}}/{{total}}',
|
uploadingCount: 'Uploading {{done}}/{{total}}',
|
||||||
|
cancelAll: 'Cancel all uploads',
|
||||||
skippedDuplicates: 'Skipped duplicates: {{count}}',
|
skippedDuplicates: 'Skipped duplicates: {{count}}',
|
||||||
filterActive: 'Active',
|
filterActive: 'Active',
|
||||||
filterAll: 'All',
|
filterAll: 'All',
|
||||||
|
|||||||
Reference in New Issue
Block a user