diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index b52a534..073a97a 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -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(null) - const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>( - null, - ) const [filter, setFilter] = useState('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() - 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 (
@@ -120,11 +80,6 @@ export function MediaPanel() {
- {upload != null && ( - - {upload.current}/{upload.total} · {upload.percent}% - - )} { 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 = '' }} /> - diff --git a/frontend/src/features/admin/media/UploadSnackbar.tsx b/frontend/src/features/admin/media/UploadSnackbar.tsx new file mode 100644 index 0000000..a97a910 --- /dev/null +++ b/frontend/src/features/admin/media/UploadSnackbar.tsx @@ -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 + case 'error': + return + case 'uploading': + return + default: + return + } +} + +/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */ +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 ( +
+
+ {header} + + {!active && ( + + )} +
+ + {!minimized && ( +
    + {items.map((item) => ( +
  • +
    + + + {item.name} + + {item.status === 'uploading' && ( + {item.percent}% + )} +
    + {item.status === 'uploading' && ( +
    +
    +
    + )} +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts new file mode 100644 index 0000000..518e31f --- /dev/null +++ b/frontend/src/features/admin/media/upload-store.ts @@ -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 + toggleMinimize: () => void + dismiss: () => void +} + +// Очередь и флаг живут вне React — загрузка продолжается при любой навигации. +let counter = 0 +const queue: { id: string; file: File }[] = [] +let running = false + +const patch = (id: string, changes: Partial) => + 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((set) => ({ + items: [], + active: false, + minimized: false, + + enqueue: async (files) => { + // Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди. + let existing = new Set() + 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: [] }), +})) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e61f46a..f2a8659 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' 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 './index.css' import './shared/lib/i18n' @@ -8,6 +8,7 @@ import { ThemeProvider } from './theme/ThemeProvider' import { ToastProvider } from './shared/ui/toast-store' import { Toaster } from './shared/ui/toaster' import { router } from './router' +import { queryClient } from './shared/api/query-client' import { setUnauthorizedHandler } from './shared/api/client' import { clearSession } from './features/auth/api' @@ -15,8 +16,6 @@ import { clearSession } from './features/auth/api' // useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login. setUnauthorizedHandler(clearSession) -const queryClient = new QueryClient() - createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 003dea9..a56692b 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { bootstrapSession, logout, clearSession } from '@/features/auth/api' import { useTheme } from '@/theme/ThemeProvider' import { setLanguage } from '@/shared/lib/i18n' import { cn } from '@/shared/lib/cn' +import { UploadSnackbar } from '@/features/admin/media/UploadSnackbar' export const Route = createRootRoute({ component: RootLayout }) @@ -131,6 +132,8 @@ function RootLayout() {
+ +
) } diff --git a/frontend/src/shared/api/query-client.ts b/frontend/src/shared/api/query-client.ts new file mode 100644 index 0000000..77accdd --- /dev/null +++ b/frontend/src/shared/api/query-client.ts @@ -0,0 +1,4 @@ +import { QueryClient } from '@tanstack/react-query' + +/** Единый экземпляр — доступен и провайдеру, и коду вне React (напр. фоновому загрузчику медиа). */ +export const queryClient = new QueryClient() diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 369219b..ab57f33 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -107,6 +107,7 @@ const resources = { upload: 'Загрузить', uploaded: 'Файл загружен, идёт обработка', uploadedCount: 'Загружено файлов: {{count}}', + uploadingCount: 'Загрузка {{done}}/{{total}}', skippedDuplicates: 'Пропущено дубликатов: {{count}}', filterActive: 'Активные', filterAll: 'Все', @@ -302,6 +303,7 @@ const resources = { upload: 'Upload', uploaded: 'File uploaded, processing started', uploadedCount: 'Uploaded files: {{count}}', + uploadingCount: 'Uploading {{done}}/{{total}}', skippedDuplicates: 'Skipped duplicates: {{count}}', filterActive: 'Active', filterAll: 'All',