Refactor media upload handling in MediaPanel: remove direct upload logic and integrate useUploadStore for file enqueueing. Update translations for upload status and add UploadSnackbar component to display upload notifications in the UI.

This commit is contained in:
Leonid Pershin
2026-07-24 22:25:01 +03:00
parent 2523808e3b
commit 8f8ce5122a
7 changed files with 188 additions and 53 deletions
@@ -8,7 +8,8 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia, uploadMedia } from './api'
import { deleteMedia, listMedia } from './api'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
@@ -42,10 +43,8 @@ export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>(
null,
)
const [filter, setFilter] = useState<MediaFilter>('active')
const enqueue = useUploadStore((s) => s.enqueue)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', filter],
@@ -63,45 +62,6 @@ export function MediaPanel() {
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
// Массовая загрузка: файлы отправляются по одному (обработка всё равно в очереди по одному),
// прогресс — «текущий/всего · %». Дубликаты по имени пропускаются заранее (сервер тоже отклонит).
const handleFiles = async (files: FileList) => {
const list = Array.from(files)
let existing = new Set<string>()
try {
const all = await listMedia({
page: 1,
pageSize: 1000,
statuses: ['Pending', 'Processing', 'Ready'],
})
existing = new Set(all.items.map((a) => a.originalFileName))
} catch {
// Не удалось получить список — положимся на серверную проверку дубликатов.
}
const toUpload = list.filter((f) => !existing.has(f.name))
const skipped = list.length - toUpload.length
let uploaded = 0
for (let i = 0; i < toUpload.length; i++) {
setUpload({ current: i + 1, total: toUpload.length, percent: 0 })
try {
await uploadMedia(toUpload[i], (percent) =>
setUpload({ current: i + 1, total: toUpload.length, percent }),
)
uploaded++
invalidate()
} catch (error) {
onError(error)
}
}
setUpload(null)
if (fileInput.current) fileInput.current.value = ''
if (uploaded > 0) toast.success(t('admin.media.uploadedCount', { count: uploaded }))
if (skipped > 0) toast.message(t('admin.media.skippedDuplicates', { count: skipped }))
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -120,11 +80,6 @@ export function MediaPanel() {
</Select>
</div>
<div className="flex items-center gap-3">
{upload != null && (
<span className="text-sm text-muted-foreground">
{upload.current}/{upload.total} · {upload.percent}%
</span>
)}
<input
ref={fileInput}
type="file"
@@ -133,10 +88,11 @@ export function MediaPanel() {
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) void handleFiles(files)
if (files && files.length > 0) void enqueue(Array.from(files))
e.target.value = ''
}}
/>
<Button size="sm" disabled={upload != null} onClick={() => fileInput.current?.click()}>
<Button size="sm" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
</Button>
@@ -0,0 +1,72 @@
import { useTranslation } from 'react-i18next'
import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, X } from 'lucide-react'
import { cn } from '@/shared/lib/cn'
import { type UploadItem, useUploadStore } from './upload-store'
function StatusIcon({ status }: { status: UploadItem['status'] }) {
switch (status) {
case 'done':
return <Check className="h-3.5 w-3.5 text-primary" />
case 'error':
return <AlertCircle className="h-3.5 w-3.5 text-red-500" />
case 'uploading':
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
default:
return <Clock className="h-3.5 w-3.5 text-muted-foreground" />
}
}
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
export function UploadSnackbar() {
const { t } = useTranslation()
const { items, active, minimized, toggleMinimize, dismiss } = useUploadStore()
if (items.length === 0) return null
const done = items.filter((i) => i.status === 'done').length
const header = active
? t('admin.media.uploadingCount', { done, total: items.length })
: t('admin.media.uploadedCount', { count: done })
return (
<div className="crt-panel fixed bottom-4 right-4 z-50 w-80 max-w-[calc(100vw-2rem)] rounded-md shadow-lg">
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm">
<span className="truncate font-medium">{header}</span>
<button type="button" className="ml-auto opacity-70 hover:opacity-100" onClick={toggleMinimize}>
{minimized ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{!active && (
<button type="button" className="opacity-70 hover:opacity-100" onClick={dismiss}>
<X className="h-4 w-4" />
</button>
)}
</div>
{!minimized && (
<ul className="max-h-64 divide-y divide-border overflow-y-auto">
{items.map((item) => (
<li key={item.id} className="flex flex-col gap-1 px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<StatusIcon status={item.status} />
<span className="truncate" title={item.name}>
{item.name}
</span>
{item.status === 'uploading' && (
<span className="ml-auto shrink-0 text-muted-foreground">{item.percent}%</span>
)}
</div>
{item.status === 'uploading' && (
<div className="h-1 w-full overflow-hidden rounded bg-muted">
<div
className={cn('h-full rounded bg-primary transition-[width]')}
style={{ width: `${item.percent}%` }}
/>
</div>
)}
</li>
))}
</ul>
)}
</div>
)
}
@@ -0,0 +1,99 @@
import { create } from 'zustand'
import { queryClient } from '@/shared/api/query-client'
import i18n from '@/shared/lib/i18n'
import { toast } from '@/shared/ui/toast-store'
import { listMedia, uploadMedia } from './api'
export type UploadItem = {
id: string
name: string
percent: number
status: 'queued' | 'uploading' | 'done' | 'error'
}
type UploadStore = {
items: UploadItem[]
active: boolean
minimized: boolean
enqueue: (files: File[]) => Promise<void>
toggleMinimize: () => void
dismiss: () => void
}
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0
const queue: { id: string; file: File }[] = []
let running = false
const patch = (id: string, changes: Partial<UploadItem>) =>
useUploadStore.setState((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
}))
async function pump() {
if (running) return
running = true
useUploadStore.setState({ active: true, minimized: false })
let uploaded = 0
while (queue.length > 0) {
const job = queue.shift()!
patch(job.id, { status: 'uploading', percent: 0 })
try {
await uploadMedia(job.file, (percent) => patch(job.id, { percent }))
patch(job.id, { status: 'done', percent: 100 })
uploaded++
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
} catch {
patch(job.id, { status: 'error' })
}
}
running = false
useUploadStore.setState({ active: false })
if (uploaded > 0) toast.success(i18n.t('admin.media.uploadedCount', { count: uploaded }))
}
export const useUploadStore = create<UploadStore>((set) => ({
items: [],
active: false,
minimized: false,
enqueue: async (files) => {
// Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
let existing = new Set<string>()
try {
const all = await listMedia({
page: 1,
pageSize: 1000,
statuses: ['Pending', 'Processing', 'Ready'],
})
existing = new Set(all.items.map((a) => a.originalFileName))
} catch {
// положимся на серверную проверку
}
const inFlight = new Set(
useUploadStore
.getState()
.items.filter((i) => i.status !== 'error')
.map((i) => i.name),
)
const toAdd = files.filter((f) => !existing.has(f.name) && !inFlight.has(f.name))
const skipped = files.length - toAdd.length
if (toAdd.length > 0) {
const newItems: UploadItem[] = toAdd.map((file) => {
const id = `u${++counter}`
queue.push({ id, file })
return { id, name: file.name, percent: 0, status: 'queued' }
})
set((s) => ({ items: [...s.items, ...newItems], minimized: false }))
void pump()
}
if (skipped > 0) toast.message(i18n.t('admin.media.skippedDuplicates', { count: skipped }))
},
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
dismiss: () => set({ items: [] }),
}))