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:
@@ -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 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user