feat(web): suggest tracks, my uploads and admin upload queue screens (M5.5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m4s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s

- Suggest tracks: drag-and-drop of files and folders with relative
  paths, streaming SHA-256 in a Web Worker (hash-wasm), duplicate
  precheck, batch reservation and tus-js-client upload (8 MB chunks,
  4 in parallel, token refresh on 401, resume of failed files).
- My uploads: statuses, rejection reasons, withdraw, quota meter.
- Upload queue (admin): status tabs, possible-duplicate filter, batches,
  metadata and storage editing, bulk approve / reject with a reason,
  import retry, and the queue settings form.
- Sidebar section for moderators and admins; ru / en strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 17:46:18 +03:00
1 parent ca106e8eb1
commit a3f212d253
19 files changed
+2144 -14

No files matched your search

@@ -0,0 +1,156 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { 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, 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<string, UploadStatus[] | undefined>;
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<Filter>('all');
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 (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.mine_title')}</h1>
<p className={s.subtitle}>{t('uploads.mine_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(FILTERS) as Filter[]).map((f) => (
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} onClick={() => setFilter(f)}>
{t(`uploads.filter_${f}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<Link to="/uploads/new">
<Button variant="primary" icon={addIcon}>
{t('uploads.suggest_title')}
</Button>
</Link>
</div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
{items.length > 0 && (
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('uploads.col_file')}</th>
<th>{t('uploads.col_status')}</th>
<th>{t('uploads.col_size')}</th>
<th>{t('uploads.col_date')}</th>
<th />
</tr>
</thead>
<tbody>
{items.map((f) => (
<MineRow
key={f.id}
file={f}
busy={withdraw.isPending && withdraw.variables === f.id}
onWithdraw={() => withdraw.mutate(f.id)}
/>
))}
</tbody>
</table>
</div>
)}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
</div>
);
}
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 (
<tr>
<td className={u.trackCell}>
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
<span className={cx(u.sub, u.ellipsis)} title={f.client_rel_path ?? f.original_name}>
{f.client_rel_path ?? f.original_name}
</span>
{f.status === 'imported' && f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
</div>
</td>
<td>
<div className={u.meta}>
<UploadStatusBadge status={f.status} />
{f.status === 'uploading' && (
<span className={u.sub}>{t('uploads.received', { pct: Math.round((f.received_bytes / Math.max(f.size_bytes, 1)) * 100) })}</span>
)}
{f.status === 'duplicate' && <span className={u.sub}>{t('uploads.dup_found')}</span>}
{reason && f.status !== 'pending_review' && <span className={u.bad}>{reason}</span>}
</div>
</td>
<td className={s.muted}>{formatBytes(f.size_bytes, i18n.language)}</td>
<td className={s.muted}>{formatDateTime(f.created_at, i18n.language)}</td>
<td>
{WITHDRAWABLE.includes(f.status) && (
<Button variant="ghost" busy={busy} onClick={onWithdraw}>
{t('uploads.withdraw')}
</Button>
)}
</td>
</tr>
);
}