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:
@@ -8,7 +8,8 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
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
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -42,10 +43,8 @@ export function MediaPanel() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'media', filter],
|
queryKey: ['admin', 'media', filter],
|
||||||
@@ -63,45 +62,6 @@ export function MediaPanel() {
|
|||||||
|
|
||||||
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
@@ -120,11 +80,6 @@ export function MediaPanel() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{upload != null && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{upload.current}/{upload.total} · {upload.percent}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -133,10 +88,11 @@ export function MediaPanel() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const files = e.target.files
|
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" />
|
<Upload className="h-4 w-4" />
|
||||||
{t('admin.media.upload')}
|
{t('admin.media.upload')}
|
||||||
</Button>
|
</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: [] }),
|
||||||
|
}))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import './shared/lib/i18n'
|
import './shared/lib/i18n'
|
||||||
@@ -8,6 +8,7 @@ import { ThemeProvider } from './theme/ThemeProvider'
|
|||||||
import { ToastProvider } from './shared/ui/toast-store'
|
import { ToastProvider } from './shared/ui/toast-store'
|
||||||
import { Toaster } from './shared/ui/toaster'
|
import { Toaster } from './shared/ui/toaster'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
import { queryClient } from './shared/api/query-client'
|
||||||
import { setUnauthorizedHandler } from './shared/api/client'
|
import { setUnauthorizedHandler } from './shared/api/client'
|
||||||
import { clearSession } from './features/auth/api'
|
import { clearSession } from './features/auth/api'
|
||||||
|
|
||||||
@@ -15,8 +16,6 @@ import { clearSession } from './features/auth/api'
|
|||||||
// useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login.
|
// useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login.
|
||||||
setUnauthorizedHandler(clearSession)
|
setUnauthorizedHandler(clearSession)
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { bootstrapSession, logout, clearSession } from '@/features/auth/api'
|
|||||||
import { useTheme } from '@/theme/ThemeProvider'
|
import { useTheme } from '@/theme/ThemeProvider'
|
||||||
import { setLanguage } from '@/shared/lib/i18n'
|
import { setLanguage } from '@/shared/lib/i18n'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { UploadSnackbar } from '@/features/admin/media/UploadSnackbar'
|
||||||
|
|
||||||
export const Route = createRootRoute({ component: RootLayout })
|
export const Route = createRootRoute({ component: RootLayout })
|
||||||
|
|
||||||
@@ -131,6 +132,8 @@ function RootLayout() {
|
|||||||
<main className={cn('mx-auto w-full max-w-5xl flex-1 px-4 py-8')}>
|
<main className={cn('mx-auto w-full max-w-5xl flex-1 px-4 py-8')}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<UploadSnackbar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
/** Единый экземпляр — доступен и провайдеру, и коду вне React (напр. фоновому загрузчику медиа). */
|
||||||
|
export const queryClient = new QueryClient()
|
||||||
@@ -107,6 +107,7 @@ const resources = {
|
|||||||
upload: 'Загрузить',
|
upload: 'Загрузить',
|
||||||
uploaded: 'Файл загружен, идёт обработка',
|
uploaded: 'Файл загружен, идёт обработка',
|
||||||
uploadedCount: 'Загружено файлов: {{count}}',
|
uploadedCount: 'Загружено файлов: {{count}}',
|
||||||
|
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
||||||
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
|
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
|
||||||
filterActive: 'Активные',
|
filterActive: 'Активные',
|
||||||
filterAll: 'Все',
|
filterAll: 'Все',
|
||||||
@@ -302,6 +303,7 @@ const resources = {
|
|||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
uploaded: 'File uploaded, processing started',
|
uploaded: 'File uploaded, processing started',
|
||||||
uploadedCount: 'Uploaded files: {{count}}',
|
uploadedCount: 'Uploaded files: {{count}}',
|
||||||
|
uploadingCount: 'Uploading {{done}}/{{total}}',
|
||||||
skippedDuplicates: 'Skipped duplicates: {{count}}',
|
skippedDuplicates: 'Skipped duplicates: {{count}}',
|
||||||
filterActive: 'Active',
|
filterActive: 'Active',
|
||||||
filterAll: 'All',
|
filterAll: 'All',
|
||||||
|
|||||||
Reference in New Issue
Block a user