import { unwrap } from '@pipu/core'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; import { useErrorText } from '../../shared/lib/errors'; import { formatBytes, formatDateTime } from '../../shared/lib/format'; import { addIcon } from '../../shared/ui/icons'; import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui'; import s from '../page.module.css'; import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common'; import u from './uploads.module.css'; const FILTERS = { all: undefined, pending: ['uploading', 'processing', 'pending_review', 'approved', 'importing', 'import_failed'], imported: ['imported'], declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'], } satisfies Record; type Filter = keyof typeof FILTERS; const WITHDRAWABLE: UploadStatus[] = ['uploading', 'processing', 'pending_review']; /** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */ export function MyUploadsPage() { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); const errorText = useErrorText(); const [filter, setFilter] = useState('all'); useEffect(() => { void session.client.POST('/api/v1/uploads/seen').then(() => { void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] }); }); }, [session, queryClient]); const list = useInfiniteQuery({ queryKey: ['my-uploads', filter], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam }) => unwrap( await session.client.GET('/api/v1/uploads/mine', { params: { query: { status: FILTERS[filter] ? [...FILTERS[filter]] : undefined, cursor: pageParam, limit: 50 } }, }), ), getNextPageParam: (p) => p.next_cursor ?? undefined, // Обработка и импорт идут в фоне — пока есть такие файлы, обновляем список. refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false, }); const withdraw = useMutation({ mutationFn: async (id: string) => unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } })), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['my-uploads'] }); await queryClient.invalidateQueries({ queryKey: ['upload-quota'] }); }, }); const items = list.data?.pages.flatMap((p) => p.items) ?? []; return (

{t('uploads.mine_title')}

{t('uploads.mine_subtitle')}

{(Object.keys(FILTERS) as Filter[]).map((f) => ( ))}
{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null} {list.isSuccess && items.length === 0 && ( {t('uploads.suggest_title')}}> {t('uploads.mine_empty')} )} {items.length > 0 && (
{items.map((f) => ( withdraw.mutate(f.id)} /> ))}
{t('uploads.col_file')} {t('uploads.col_status')} {t('uploads.col_size')} {t('uploads.col_date')}
)} {list.hasNextPage && ( )}
); } function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolean; onWithdraw: () => void }) { const { t, i18n } = useTranslation('admin'); // Технические подробности ошибки импорта — для админа; автору достаточно причины. const raw = f.status === 'import_failed' ? (f.status_reason?.split(':')[0] ?? null) : f.status_reason; const reason = useReasonText()(raw); const m = f.metadata; return (
{m ? `${m.artist} — ${m.title}` : f.original_name} {f.client_rel_path ?? f.original_name} {f.status === 'imported' && f.target_rel_path && {f.target_rel_path}}
{f.status === 'uploading' && ( {t('uploads.received', { pct: Math.round((f.received_bytes / Math.max(f.size_bytes, 1)) * 100) })} )} {f.status === 'duplicate' && {t('uploads.dup_found')}} {reason && f.status !== 'pending_review' && {reason}}
{formatBytes(f.size_bytes, i18n.language)} {formatDateTime(f.created_at, i18n.language)} {WITHDRAWABLE.includes(f.status) && ( )} ); }