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
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user