Enhance media upload functionality: add skipped file tracking in upload store, update UploadSnackbar to display skipped duplicates, and improve UI responsiveness for upload status indicators.

This commit is contained in:
Leonid Pershin
2026-07-24 22:35:09 +03:00
parent 8f8ce5122a
commit 65f2a1d4ec
2 changed files with 66 additions and 41 deletions
@@ -6,46 +6,71 @@ import { type UploadItem, useUploadStore } from './upload-store'
function StatusIcon({ status }: { status: UploadItem['status'] }) { function StatusIcon({ status }: { status: UploadItem['status'] }) {
switch (status) { switch (status) {
case 'done': case 'done':
return <Check className="h-3.5 w-3.5 text-primary" /> return <Check className="h-3.5 w-3.5 shrink-0 text-primary" />
case 'error': case 'error':
return <AlertCircle className="h-3.5 w-3.5 text-red-500" /> return <AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
case 'uploading': case 'uploading':
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" /> return <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
default: default:
return <Clock className="h-3.5 w-3.5 text-muted-foreground" /> return <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
} }
} }
function barWidth(item: UploadItem): number {
if (item.status === 'done' || item.status === 'error') return 100
if (item.status === 'uploading') return item.percent
return 0
}
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */ /** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
export function UploadSnackbar() { export function UploadSnackbar() {
const { t } = useTranslation() const { t } = useTranslation()
const { items, active, minimized, toggleMinimize, dismiss } = useUploadStore() const { items, active, minimized, skipped, toggleMinimize, dismiss } = useUploadStore()
if (items.length === 0) return null if (items.length === 0 && skipped === 0) return null
const done = items.filter((i) => i.status === 'done').length const done = items.filter((i) => i.status === 'done').length
const header = active const hasItems = items.length > 0
? t('admin.media.uploadingCount', { done, total: items.length }) const header = hasItems
: t('admin.media.uploadedCount', { count: done }) ? active
? t('admin.media.uploadingCount', { done, total: items.length })
: t('admin.media.uploadedCount', { count: done })
: t('admin.media.skippedDuplicates', { count: skipped })
return ( 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="crt-panel fixed bottom-4 right-4 z-50 w-96 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"> <div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm">
<span className="truncate font-medium">{header}</span> <span className="truncate font-medium">{header}</span>
<button type="button" className="ml-auto opacity-70 hover:opacity-100" onClick={toggleMinimize}> {hasItems && (
{minimized ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} <button
</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 && ( {!active && (
<button type="button" className="opacity-70 hover:opacity-100" onClick={dismiss}> <button
type="button"
className={cn('opacity-70 hover:opacity-100', !hasItems && 'ml-auto')}
onClick={dismiss}
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>
{!minimized && ( {hasItems && skipped > 0 && (
<ul className="max-h-64 divide-y divide-border overflow-y-auto"> <div className="border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
{t('admin.media.skippedDuplicates', { count: skipped })}
</div>
)}
{hasItems && !minimized && (
<ul className="max-h-72 divide-y divide-border overflow-y-auto">
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="flex flex-col gap-1 px-3 py-2 text-xs"> <li key={item.id} className="flex flex-col gap-1.5 px-3 py-2 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusIcon status={item.status} /> <StatusIcon status={item.status} />
<span className="truncate" title={item.name}> <span className="truncate" title={item.name}>
@@ -55,14 +80,15 @@ export function UploadSnackbar() {
<span className="ml-auto shrink-0 text-muted-foreground">{item.percent}%</span> <span className="ml-auto shrink-0 text-muted-foreground">{item.percent}%</span>
)} )}
</div> </div>
{item.status === 'uploading' && ( <div className="h-1 w-full overflow-hidden rounded bg-muted">
<div className="h-1 w-full overflow-hidden rounded bg-muted"> <div
<div className={cn(
className={cn('h-full rounded bg-primary transition-[width]')} 'h-full rounded transition-[width]',
style={{ width: `${item.percent}%` }} item.status === 'error' ? 'bg-red-500' : 'bg-primary',
/> )}
</div> style={{ width: `${barWidth(item)}%` }}
)} />
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -1,7 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { queryClient } from '@/shared/api/query-client' 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' import { listMedia, uploadMedia } from './api'
export type UploadItem = { export type UploadItem = {
@@ -15,6 +13,7 @@ type UploadStore = {
items: UploadItem[] items: UploadItem[]
active: boolean active: boolean
minimized: boolean minimized: boolean
skipped: number
enqueue: (files: File[]) => Promise<void> enqueue: (files: File[]) => Promise<void>
toggleMinimize: () => void toggleMinimize: () => void
dismiss: () => void dismiss: () => void
@@ -35,14 +34,12 @@ async function pump() {
running = true running = true
useUploadStore.setState({ active: true, minimized: false }) useUploadStore.setState({ active: true, minimized: false })
let uploaded = 0
while (queue.length > 0) { while (queue.length > 0) {
const job = queue.shift()! const job = queue.shift()!
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 }))
patch(job.id, { status: 'done', percent: 100 }) patch(job.id, { status: 'done', percent: 100 })
uploaded++
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
} catch { } catch {
patch(job.id, { status: 'error' }) patch(job.id, { status: 'error' })
@@ -51,13 +48,13 @@ async function pump() {
running = false running = false
useUploadStore.setState({ active: false }) useUploadStore.setState({ active: false })
if (uploaded > 0) toast.success(i18n.t('admin.media.uploadedCount', { count: uploaded }))
} }
export const useUploadStore = create<UploadStore>((set) => ({ export const useUploadStore = create<UploadStore>((set) => ({
items: [], items: [],
active: false, active: false,
minimized: false, minimized: false,
skipped: 0,
enqueue: async (files) => { enqueue: async (files) => {
// Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди. // Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
@@ -82,18 +79,20 @@ export const useUploadStore = create<UploadStore>((set) => ({
const toAdd = files.filter((f) => !existing.has(f.name) && !inFlight.has(f.name)) const toAdd = files.filter((f) => !existing.has(f.name) && !inFlight.has(f.name))
const skipped = files.length - toAdd.length const skipped = files.length - toAdd.length
if (toAdd.length > 0) { const newItems: UploadItem[] = toAdd.map((file) => {
const newItems: UploadItem[] = toAdd.map((file) => { const id = `u${++counter}`
const id = `u${++counter}` queue.push({ id, file })
queue.push({ id, file }) return { id, name: file.name, percent: 0, status: 'queued' }
return { id, name: file.name, percent: 0, status: 'queued' } })
})
set((s) => ({ items: [...s.items, ...newItems], minimized: false })) set((s) => ({
void pump() items: newItems.length ? [...s.items, ...newItems] : s.items,
} skipped: s.skipped + skipped,
if (skipped > 0) toast.message(i18n.t('admin.media.skippedDuplicates', { count: skipped })) minimized: newItems.length ? false : s.minimized,
}))
if (newItems.length) void pump()
}, },
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })), toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
dismiss: () => set({ items: [] }), dismiss: () => set({ items: [], skipped: 0 }),
})) }))