release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
6.8 KiB
TypeScript
167 lines
6.8 KiB
TypeScript
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<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');
|
|
|
|
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 (
|
|
<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 && (
|
|
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
|
|
{t('uploads.mine_empty')}
|
|
</EmptyState>
|
|
)}
|
|
{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>
|
|
);
|
|
}
|