Files
pipu-music/apps/web/src/pages/uploads/MyUploadsPage.tsx
T
mrleo1nidandCursor 72567acd28
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
feat(web): polish catalog motion, empty states, and mobile now-playing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00

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>
);
}