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

+3 -1
View File
@@ -21,9 +21,11 @@
"@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"hash-wasm": "^4.12.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.3"
"react-i18next": "^15.5.3",
"tus-js-client": "^4.3.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
+22
View File
@@ -11,6 +11,9 @@ import {
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { AlbumPage } from '../pages/AlbumPage';
@@ -205,6 +208,22 @@ const libraryRoute = createRoute({
component: LibraryPage,
});
/** Предложение треков — модераторы и админ (docs/uploads.md#1). */
const staffOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
};
const suggestRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/new', beforeLoad: staffOnly, component: SuggestPage });
const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/mine', beforeLoad: staffOnly, component: MyUploadsPage });
const uploadQueueRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/uploads',
beforeLoad: adminOnly,
component: UploadQueuePage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
@@ -223,6 +242,9 @@ const routeTree = rootRoute.addChildren([
sourcesRoute,
patternsRoute,
libraryRoute,
suggestRoute,
myUploadsRoute,
uploadQueueRoute,
]),
]);
+48
View File
@@ -0,0 +1,48 @@
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
export interface PickedFile {
file: File;
relPath: string; // «Кино/1988 - Группа крови/01 - Группа крови.flac» или просто имя файла
}
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
export function isAudio(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return AUDIO_EXTENSIONS.includes(ext);
}
/** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
export function fromInput(list: FileList | null): PickedFile[] {
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
}
/** Файлы из перетаскивания: папки обходятся рекурсивно (FileSystemEntry API). */
export async function fromDrop(dt: DataTransfer): Promise<PickedFile[]> {
const entries = [...dt.items]
.filter((it) => it.kind === 'file')
.map((it) => it.webkitGetAsEntry?.())
.filter((e): e is FileSystemEntry => !!e);
if (!entries.length) return fromInput(dt.files);
const out: PickedFile[] = [];
for (const e of entries) await walk(e, '', out);
return out;
}
async function walk(entry: FileSystemEntry, prefix: string, out: PickedFile[]) {
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject));
out.push({ file, relPath: path });
return;
}
if (!entry.isDirectory || entry.name.startsWith('.')) return;
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries отдаёт порциями (~100), пока не вернёт пустой массив.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) break;
for (const child of batch) await walk(child, path, out);
}
}
@@ -0,0 +1,40 @@
/// <reference lib="webworker" />
// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ —
// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс.
import { createSHA256 } from 'hash-wasm';
export interface HashRequest {
id: string;
file: File;
}
export type HashResponse =
| { id: string; type: 'progress'; done: number }
| { id: string; type: 'done'; sha256: string }
| { id: string; type: 'error'; message: string };
const ctx = self as unknown as DedicatedWorkerGlobalScope;
ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
const { id, file } = e.data;
try {
const h = await createSHA256();
h.init();
const reader = file.stream().getReader();
let done = 0;
let lastReport = 0;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
h.update(chunk.value);
done += chunk.value.byteLength;
if (done - lastReport > 8 << 20) {
lastReport = done;
ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse);
}
}
ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse);
} catch (err) {
ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse);
}
};
+287
View File
@@ -0,0 +1,287 @@
import { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client';
import { isAudio, type PickedFile } from './files';
import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
// (ничего не резервирует) → POST /uploads (резерв квоты) → tus, до 4 файлов параллельно, куски по 8 МБ.
// Синглтон модуля: загрузка продолжается при переходах по приложению, но не переживает перезагрузку страницы.
export type Stage =
| 'hashing'
| 'checking'
| 'ready'
| 'duplicate'
| 'too_large'
| 'uploading'
| 'done'
| 'rejected'
| 'error';
export interface UploadItem {
id: string;
file: File;
relPath: string;
stage: Stage;
progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string;
trackId?: string | null;
possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
message?: string;
uploadFileId?: string;
tusUrl?: string;
}
export interface UploaderState {
items: UploadItem[];
skipped: number; // не аудио (обложки, .cue, …)
busy: boolean; // идёт загрузка
batchId: string | null;
error: unknown;
}
const PARALLEL = 4;
const CHUNK = 8 << 20;
let seq = 0;
export class Uploader {
private state: UploaderState = { items: [], skipped: 0, busy: false, batchId: null, error: null };
private listeners = new Set<() => void>();
private worker: Worker | null = null;
private hashQueue: string[] = [];
private hashing = false;
private uploads = new Map<string, tus.Upload>();
private refreshing: Promise<unknown> | null = null;
constructor(private readonly session: AuthSession) {}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getState = () => this.state;
private set(patch: Partial<UploaderState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
}
private patch(id: string, p: Partial<UploadItem>) {
this.set({ items: this.state.items.map((it) => (it.id === id ? { ...it, ...p } : it)) });
}
private item(id: string) {
return this.state.items.find((it) => it.id === id);
}
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
add(files: PickedFile[]) {
if (this.state.busy) return;
const fresh: UploadItem[] = [];
let skipped = 0;
for (const f of files) {
if (!isAudio(f.file.name)) {
skipped++;
continue;
}
// Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
}
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id));
void this.hashNext();
}
remove(id: string) {
if (this.state.busy) return;
this.hashQueue = this.hashQueue.filter((x) => x !== id);
this.set({ items: this.state.items.filter((it) => it.id !== id) });
}
/** Очистить список (после завершения или чтобы начать заново). */
clear() {
if (this.state.busy) return;
this.hashQueue = [];
this.set({ items: [], skipped: 0, batchId: null, error: null });
}
private hashWorker(): Worker {
this.worker ??= new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' });
return this.worker;
}
private async hashNext() {
if (this.hashing) return;
this.hashing = true;
try {
while (this.hashQueue.length) {
const id = this.hashQueue.shift()!;
const it = this.item(id);
if (!it) continue;
try {
const sha256 = await this.hash(it);
this.patch(id, { sha256, stage: 'checking', progress: 1 });
} catch (err) {
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
}
}
await this.precheck();
} finally {
this.hashing = false;
}
}
private hash(it: UploadItem): Promise<string> {
const w = this.hashWorker();
return new Promise((resolve, reject) => {
const onMessage = (e: MessageEvent<HashResponse>) => {
const m = e.data;
if (m.id !== it.id) return;
if (m.type === 'progress') {
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
return;
}
w.removeEventListener('message', onMessage);
if (m.type === 'done') resolve(m.sha256);
else reject(new Error(m.message));
};
w.addEventListener('message', onMessage);
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
});
}
/** Предпроверка всех посчитанных файлов: дубли отсеиваются до загрузки. */
private async precheck() {
const pending = this.state.items.filter((it) => it.stage === 'checking' && it.sha256);
if (!pending.length) return;
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads/precheck', {
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
}),
);
for (const r of res.files) {
const stage: Stage = r.result === 'accepted' ? 'ready' : r.result;
this.patch(r.client_id, { stage, trackId: r.track_id, possibleDuplicate: r.possible_duplicate });
}
} catch (err) {
// Предпроверка — подсказка: при сбое файлы всё равно можно отправить, сервер проверит дубли сам.
for (const it of pending) this.patch(it.id, { stage: 'ready' });
this.set({ error: err });
}
}
get ready(): UploadItem[] {
return this.state.items.filter((it) => it.stage === 'ready');
}
/** Создать пачку (резерв квоты) и загрузить принятые файлы. */
async start(comment: string) {
const ready = this.ready;
if (this.state.busy || !ready.length) return;
this.set({ busy: true, error: null });
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads', {
body: {
comment: comment.trim() || undefined,
files: ready.map((it) => ({
client_id: it.id,
name: it.file.name,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.file.size,
sha256: it.sha256!,
})),
},
}),
);
this.set({ batchId: res.batch_id });
const queue: { id: string; url: string }[] = [];
for (const f of res.files) {
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
else if (f.tus_url && f.upload_file_id) {
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 });
queue.push({ id: f.client_id, url: f.tus_url });
}
}
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) {
this.set({ error: err });
} finally {
this.set({ busy: false });
}
}
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
async retryFailed() {
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl);
if (this.state.busy || !failed.length) return;
this.set({ busy: true, error: null });
try {
const queue = failed.map((it) => {
this.patch(it.id, { stage: 'uploading', message: undefined });
return { id: it.id, url: it.tusUrl! };
});
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} finally {
this.set({ busy: false });
}
}
private async drain(queue: { id: string; url: string }[]) {
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url);
}
private upload(id: string, url: string): Promise<void> {
const it = this.item(id);
if (!it) return Promise.resolve();
return new Promise((resolve) => {
const up = new tus.Upload(it.file, {
uploadUrl: url,
chunkSize: CHUNK,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
storeFingerprintForResuming: false,
onBeforeRequest: async (req) => {
await this.refreshing;
const token = this.session.accessToken;
if (token) req.setHeader('Authorization', `Bearer ${token}`);
},
onShouldRetry: (err) => {
const status = (err as tus.DetailedError).originalResponse?.getStatus();
if (status === 401) {
this.refreshing = this.session.refresh();
return true;
}
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит).
return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423;
},
onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
onSuccess: () => {
this.uploads.delete(id);
this.patch(id, { stage: 'done', progress: 1 });
resolve();
},
onError: (err) => {
this.uploads.delete(id);
this.patch(id, { stage: 'error', message: err.message });
resolve();
},
});
this.uploads.set(id, up);
up.start();
});
}
}
let instance: { session: AuthSession; uploader: Uploader } | null = null;
/** Загрузчик текущей сессии (новый при смене пользователя). */
export function uploaderFor(session: AuthSession): Uploader {
if (instance?.session !== session) instance = { session, uploader: new Uploader(session) };
return instance.uploader;
}
@@ -0,0 +1,534 @@
import type { components } from '@pipu/api-client';
import { ApiError, unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
type Settings = components['schemas']['UploadSettings'];
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueuePage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', {
params: {
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
},
}),
),
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 items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
unwrap(
await session.client.POST('/api/v1/admin/uploads/review', {
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
}),
),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
<UploadSettingsSection />
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
params: { path: { id: file.id } },
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
}),
),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useQuery({
queryKey: ['upload-settings'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
});
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return unwrap(
await session.client.PATCH('/api/v1/admin/settings/uploads', {
body: {
queue_limit_bytes: gb(f.queue),
max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null,
},
}),
);
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.grid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label className={s.sessions}>
<span>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
};
}
@@ -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>
);
}
+228
View File
@@ -0,0 +1,228 @@
import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
import { useErrorText } from '../../shared/lib/errors';
import { formatBytes } from '../../shared/lib/format';
import { closeIcon, folderIcon, musicIcon } from '../../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { QuotaMeter, useUploadQuota } from './common';
import u from './uploads.module.css';
const ACCEPT = AUDIO_EXTENSIONS.map((e) => `.${e}`).join(',');
/**
* «Предложить треки» (docs/uploads.md#5): перетащить файлы или папки → SHA-256 в воркере → предпроверка дублей →
* резерв места → загрузка по tus с докачкой. Дубли отсеиваются до загрузки и не тратят квоту.
*/
export function SuggestPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const uploader = uploaderFor(session);
const st = useSyncExternalStore(uploader.subscribe, uploader.getState);
const quota = useUploadQuota();
const [drag, setDrag] = useState(false);
const [comment, setComment] = useState('');
const filesRef = useRef<HTMLInputElement>(null);
const dirRef = useRef<HTMLInputElement>(null);
// webkitdirectory нет в типах React — ставим атрибут напрямую.
useEffect(() => {
dirRef.current?.setAttribute('webkitdirectory', '');
}, []);
const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length;
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
const fmt = (n: number) => formatBytes(n, i18n.language);
const onDrop = async (e: DragEvent) => {
e.preventDefault();
setDrag(false);
uploader.add(await fromDrop(e.dataTransfer));
};
const start = async () => {
await uploader.start(comment);
setComment('');
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
};
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.suggest_title')}</h1>
<p className={s.subtitle}>{t('uploads.suggest_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div
className={cx(u.drop, drag && u.dropActive)}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => void onDrop(e)}
>
<Icon src={musicIcon} size={36} />
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div>
<div className={u.dropActions}>
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
{t('uploads.pick_files')}
</Button>
<Button icon={folderIcon} disabled={st.busy} onClick={() => dirRef.current?.click()}>
{t('uploads.pick_folder')}
</Button>
</div>
<input
ref={filesRef}
className={u.hiddenInput}
type="file"
multiple
accept={ACCEPT}
onChange={(e) => {
uploader.add(fromInput(e.target.files));
e.target.value = '';
}}
/>
<input
ref={dirRef}
className={u.hiddenInput}
type="file"
multiple
onChange={(e) => {
uploader.add(fromInput(e.target.files));
e.target.value = '';
}}
/>
</div>
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
{st.items.length > 0 && (
<div className={u.files} aria-label={t('uploads.files')}>
{st.items.map((it) => (
<FileRow key={it.id} item={it} locked={st.busy || finished} onRemove={() => uploader.remove(it.id)} />
))}
</div>
)}
{st.items.length > 0 && !finished && (
<section className={s.section}>
<label className={s.sessions}>
<span>{t('uploads.comment')}</span>
<textarea
className={u.textarea}
value={comment}
maxLength={1000}
placeholder={t('uploads.comment_hint')}
onChange={(e) => setComment(e.target.value)}
/>
</label>
{free !== null && readyBytes > free && (
<p className={u.warn}>{t('uploads.not_enough_space', { need: fmt(readyBytes), free: fmt(free) })}</p>
)}
<div className={s.toolbar}>
<Button variant="ghost" disabled={st.busy} onClick={() => uploader.clear()}>
{t('uploads.clear')}
</Button>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={st.busy} disabled={hashing || !ready.length} onClick={() => void start()}>
{st.busy
? t('uploads.uploading_now')
: hashing
? t('uploads.hashing_wait')
: t('uploads.send', { count: ready.length, size: fmt(readyBytes) })}
</Button>
</div>
</section>
)}
{finished && (
<section className={s.section} role="status">
{uploaded > 0 && <p className={s.ok}>{t('uploads.sent', { count: uploaded })}</p>}
{failedUploads > 0 && <p className={u.bad}>{t('uploads.failed_some', { count: failedUploads })}</p>}
<div className={s.toolbar}>
{failedUploads > 0 && (
<Button variant="primary" onClick={() => void uploader.retryFailed()}>
{t('uploads.retry_failed')}
</Button>
)}
<Link to="/uploads/mine" className={s.more}>
{t('uploads.go_mine')}
</Link>
<span style={{ flex: 1 }} />
<Button onClick={() => uploader.clear()}>{t('uploads.more')}</Button>
</div>
</section>
)}
<FormError>{st.error ? errorText(st.error) : null}</FormError>
</div>
);
}
function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean; onRemove: () => void }) {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
const showProgress = item.stage === 'hashing' || item.stage === 'uploading';
let note: { text: string; cls?: string } | null = null;
switch (item.stage) {
case 'duplicate':
note = { text: t('uploads.dup_found'), cls: u.sub };
break;
case 'too_large':
note = { text: tr('errors:uploads.too_large'), cls: u.bad };
break;
case 'rejected':
note = { text: tr(`errors:${item.code ?? 'uploads.duplicate'}`), cls: u.bad };
break;
case 'error':
note = { text: t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
break;
case 'ready':
if (item.possibleDuplicate) note = { text: t('uploads.possible_dup'), cls: u.warn };
break;
}
return (
<div className={u.file}>
<div className={u.fileName}>
<span className={u.path} title={item.relPath}>
{item.relPath}
</span>
<span className={u.sub}>
{t(`uploads.stage.${item.stage}`)}
{showProgress && ` · ${Math.round(item.progress * 100)}%`}
</span>
{showProgress && (
<div className={u.progress}>
<span style={{ width: `${item.progress * 100}%` }} />
</div>
)}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
{locked ? (
<span />
) : (
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
<Icon src={closeIcon} size={18} />
</Button>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../page.module.css';
import u from './uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
@@ -0,0 +1,271 @@
.drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 180px;
padding: 24px;
border: 2px dashed var(--line2);
border-radius: var(--radius-lg);
background: var(--surface);
text-align: center;
transition:
border-color 0.15s,
background 0.15s;
}
.dropActive {
border-color: var(--accent);
background: var(--accent-soft);
}
.dropTitle {
font-size: 16px;
font-weight: 600;
}
.dropActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.hiddenInput {
display: none;
}
.files {
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.file:first-child {
border-top: 0;
}
.fileName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.path {
font-family: var(--font-mono, monospace);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 12px;
color: var(--text3);
}
.warn {
font-size: 12px;
color: var(--warn);
}
.bad {
font-size: 12px;
color: var(--danger);
}
.size {
font-size: 13px;
color: var(--text3);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.progress {
height: 4px;
border-radius: 2px;
background: var(--surface3);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--violet));
transition: width 0.3s;
}
.quota {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
}
.quotaText {
font-size: 13px;
color: var(--text2);
}
.tone_ok {
color: var(--ok);
background: var(--ok-soft);
}
.tone_warn {
color: var(--warn);
background: var(--warn-soft);
}
.tone_bad {
color: var(--danger);
background: var(--danger-soft);
}
.tone_info {
color: var(--accent2);
background: var(--accent-soft);
}
.tone_muted {
color: var(--text3);
background: var(--surface3);
}
.batch {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.batchHead {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 12px 14px;
background: var(--surface2);
font-size: 13px;
color: var(--text2);
}
.batchHead b {
color: var(--text);
}
.comment {
flex-basis: 100%;
color: var(--text);
font-style: italic;
}
.queueRow {
display: grid;
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr) auto;
gap: 14px;
align-items: start;
padding: 12px 14px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.metaTitle {
font-weight: 600;
}
.flags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rowActions {
display: flex;
gap: 6px;
align-items: center;
}
.bulk {
position: sticky;
bottom: 12px;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--elev);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.textarea {
width: 100%;
min-height: 72px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
resize: vertical;
}
@media (max-width: 720px) {
.queueRow {
grid-template-columns: auto minmax(0, 1fr);
}
.file {
grid-template-columns: minmax(0, 1fr) auto;
}
}
.target {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text2);
overflow-wrap: anywhere;
}
.trackCell {
min-width: 260px;
max-width: 480px;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+12
View File
@@ -30,3 +30,15 @@ export function formatDuration(ms: number): string {
const s = String(total % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
/** Размер файла в локали интерфейса: 734003 → «717 КБ», 2.5e9 → «2,3 ГБ». */
export function formatBytes(bytes: number, locale: string): string {
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'] as const;
let v = Math.max(0, bytes);
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return new Intl.NumberFormat(locale, { style: 'unit', unit: units[i], unitDisplay: 'short', maximumFractionDigits: v < 10 && i > 0 ? 1 : 0 }).format(v);
}
+6
View File
@@ -57,3 +57,9 @@ export { default as waveHappyIcon } from '@material-symbols/svg-400/rounded/sent
export { default as waveSpaIcon } from '@material-symbols/svg-400/rounded/spa.svg';
export { default as waveSunIcon } from '@material-symbols/svg-400/rounded/wb_sunny.svg';
export { default as waveWeekendIcon } from '@material-symbols/svg-400/rounded/weekend.svg';
export { default as uploadIcon } from '@material-symbols/svg-400/rounded/cloud_upload.svg';
export { default as uploadFillIcon } from '@material-symbols/svg-400/rounded/cloud_upload-fill.svg';
export { default as myUploadsIcon } from '@material-symbols/svg-400/rounded/library_add.svg';
export { default as myUploadsFillIcon } from '@material-symbols/svg-400/rounded/library_add-fill.svg';
export { default as inboxIcon } from '@material-symbols/svg-400/rounded/inbox.svg';
export { default as inboxFillIcon } from '@material-symbols/svg-400/rounded/inbox-fill.svg';
+10 -2
View File
@@ -20,8 +20,7 @@ import {
dislikeIcon,
settingsIcon,
storageFillIcon,
storageIcon,
} from '../shared/ui/icons';
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider } from '../player/context';
@@ -35,6 +34,9 @@ type NavPath =
| '/admin/audit'
| '/admin/sources'
| '/admin/library'
| '/admin/uploads'
| '/uploads/new'
| '/uploads/mine'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
@@ -109,6 +111,12 @@ function Shell() {
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
{staff && (
<>
<div className={s.navSection}>{t('nav.uploads')}</div>
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/uploads" label={t('nav.upload_queue')} icon={inboxIcon} activeIcon={inboxFillIcon} />
)}
<div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
+12 -1
View File
@@ -195,8 +195,19 @@
возвращается в `approved`.
- Очистка: раз в 10 минут `uploading` старше 24 ч → `expired`; при старте — удаление файлов staging без записи в БД.
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); предпроверка; `POST /uploads`; `tus-js-client`
с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон
модуля: переходы по приложению не прерывают загрузку; оборвавшиеся файлы продолжаются кнопкой
«Продолжить загрузку» (место уже зарезервировано).
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота.
- «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка
по пачкам, правка метаданных и хранилища, массовое одобрение / отклонение с причиной, повтор импорта;
настройки очереди — внизу страницы.
- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`),
прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, экраны веба.
прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, чтение тегов на клиенте
(`music-metadata`) для предпроверки метаданных, продолжение загрузки после перезагрузки страницы.
## 11. Очистка
- `uploading` старше 24 ч → `expired`, файлы удаляются.
+144
View File
@@ -183,5 +183,149 @@
"badge": "accepts uploads",
"write_ok": "Write: ok",
"write_fail": "Write failed: {{message}}"
},
"uploads": {
"suggest_title": "Suggest tracks",
"suggest_subtitle": "Files go to the upload queue; an administrator reviews them and places them into a storage",
"drop_title": "Drop music files or folders here",
"drop_hint": "Formats: {{formats}}. Folder structure helps guess the artist and album.",
"pick_files": "Choose files",
"pick_folder": "Choose folder",
"files": "Files to send",
"skipped_one": "{{count}} file skipped — not audio",
"skipped_few": "{{count}} files skipped — not audio",
"skipped_many": "{{count}} files skipped — not audio",
"skipped_other": "{{count}} files skipped — not audio",
"comment": "Comment for the administrator",
"comment_hint": "For example: “2011 remaster”, “replacing broken files”",
"not_enough_space": "The queue is short of space: {{need}} needed, {{free}} free — some files will be refused.",
"clear": "Clear list",
"hashing_wait": "Checking files…",
"send_one": "Send {{count}} file · {{size}}",
"send_few": "Send {{count}} files · {{size}}",
"send_many": "Send {{count}} files · {{size}}",
"send_other": "Send {{count}} files · {{size}}",
"sent_one": "Done: {{count}} file is uploaded and being processed. See statuses in “My uploads”.",
"sent_few": "Done: {{count}} files are uploaded and being processed. See statuses in “My uploads”.",
"sent_many": "Done: {{count}} files are uploaded and being processed. See statuses in “My uploads”.",
"sent_other": "Done: {{count}} files are uploaded and being processed. See statuses in “My uploads”.",
"failed_some_one": "{{count}} file did not upload — its space is reserved, you can continue.",
"failed_some_few": "{{count}} files did not upload — their space is reserved, you can continue.",
"failed_some_many": "{{count}} files did not upload — their space is reserved, you can continue.",
"failed_some_other": "{{count}} files did not upload — their space is reserved, you can continue.",
"retry_failed": "Continue upload",
"uploading_now": "Uploading…",
"go_mine": "My uploads →",
"more": "Upload more",
"dup_found": "This track is already in the library or the queue — no need to upload",
"possible_dup": "A similar track exists (same artist, title and length) — the administrator will decide",
"file_error": "Error: {{message}}",
"remove": "Remove {{name}}",
"quota": "Queue: {{used}} of {{limit}}",
"quota_mine": "yours: {{used}} of {{limit}}",
"mine_title": "My uploads",
"mine_subtitle": "What you uploaded and what the administrator decided",
"mine_empty": "You have not suggested anything yet",
"filter": "Filter",
"filter_all": "All",
"filter_pending": "In progress",
"filter_imported": "Added",
"filter_declined": "Not accepted",
"col_file": "Track",
"col_status": "Status",
"col_size": "Size",
"col_date": "Uploaded",
"received": "{{pct}}% received — open “Suggest tracks” in the same tab to continue",
"withdraw": "Withdraw",
"load_more": "Show more",
"queue_title": "Upload queue",
"queue_subtitle": "Check the metadata and the storage path, then approve or reject",
"queue_empty": "Nothing here",
"tab_review": "Awaiting review",
"tab_failed": "Import errors",
"tab_progress": "In progress",
"tab_imported": "Added",
"tab_declined": "Not accepted",
"only_possible": "Possible duplicates only",
"review_failed_one": "Could not apply the decision to {{count}} file: {{reason}}",
"review_failed_few": "Could not apply the decision to {{count}} files: {{reason}}",
"review_failed_many": "Could not apply the decision to {{count}} files: {{reason}}",
"review_failed_other": "Could not apply the decision to {{count}} files: {{reason}}",
"bulk": "Actions on selected",
"selected_one": "{{count}} file selected",
"selected_few": "{{count}} files selected",
"selected_many": "{{count}} files selected",
"selected_other": "{{count}} files selected",
"approve": "Approve",
"reject": "Reject",
"batch_of": "Batch from {{author}}",
"batch_size_one": "{{count}} file · {{size}}",
"batch_size_few": "{{count}} files · {{size}}",
"batch_size_many": "{{count}} files · {{size}}",
"batch_size_other": "{{count}} files · {{size}}",
"select_batch": "Select the whole batch",
"select_file": "Select {{name}}",
"disc": "disc {{n}}",
"edited": "edited",
"flag_possible": "possible duplicate",
"flag_existing_album": "existing album",
"flag_new_album": "new album",
"dup_level_sha256": "exact copy of a file",
"dup_level_fingerprint": "same track by acoustic fingerprint",
"dup_level_metadata": "metadata matched",
"edit": "Edit {{name}}",
"retry": "Retry",
"edit_title": "Track metadata",
"meta_artist": "Artist",
"meta_albumartist": "Album artist",
"meta_album": "Album",
"meta_title": "Title",
"meta_genre": "Genre",
"meta_year": "Year",
"meta_track": "Track number",
"meta_disc": "Disc",
"target_source": "Storage",
"target_default": "Default",
"tags_note": "Edited fields are written into the file tags on import, so path and tags stay consistent.",
"save": "Save",
"saved": "Saved",
"reject_title_one": "Reject {{count}} file",
"reject_title_few": "Reject {{count}} files",
"reject_title_many": "Reject {{count}} files",
"reject_title_other": "Reject {{count}} files",
"reject_reason": "Reason (the author will see it)",
"settings_title": "Queue settings",
"set_queue": "Queue limit, GB",
"set_per_user": "Limit per moderator, GB",
"set_per_user_hint": "Empty — no limit",
"set_max_file": "Maximum file size, GB",
"set_source": "Default storage",
"set_source_auto": "The only one accepting uploads",
"set_note": "Lowering the limit below the used space deletes nothing: new uploads wait until the queue frees up.",
"status": {
"uploading": "Uploading",
"processing": "Processing",
"pending_review": "Awaiting review",
"approved": "Approved",
"importing": "Importing",
"imported": "Added",
"import_failed": "Import failed",
"duplicate": "Duplicate",
"invalid": "Not audio",
"rejected": "Rejected",
"withdrawn": "Withdrawn",
"expired": "Expired"
},
"stage": {
"hashing": "Computing checksum",
"checking": "Checking for duplicates",
"ready": "Ready to send",
"duplicate": "Already exists",
"too_large": "Too large",
"uploading": "Uploading",
"done": "Uploaded, processing",
"rejected": "Not accepted",
"error": "Error"
}
}
}
+5 -1
View File
@@ -29,7 +29,11 @@
"collection": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes"
"dislikes": "Dislikes",
"uploads": "Uploads",
"suggest": "Suggest tracks",
"my_uploads": "My uploads",
"upload_queue": "Upload queue"
},
"profile": {
"menu": "Profile menu",
+144
View File
@@ -183,5 +183,149 @@
"badge": "принимает загрузки",
"write_ok": "Запись: ок",
"write_fail": "Запись не удалась: {{message}}"
},
"uploads": {
"suggest_title": "Предложить треки",
"suggest_subtitle": "Файлы попадут в очередь на добавление; администратор проверит и разложит их в хранилище",
"drop_title": "Перетащите сюда файлы или папки с музыкой",
"drop_hint": "Форматы: {{formats}}. Структура папок поможет угадать исполнителя и альбом.",
"pick_files": "Выбрать файлы",
"pick_folder": "Выбрать папку",
"files": "Файлы для отправки",
"skipped_one": "Пропущен {{count}} файл — не аудио",
"skipped_few": "Пропущено {{count}} файла — не аудио",
"skipped_many": "Пропущено {{count}} файлов — не аудио",
"skipped_other": "Пропущено {{count}} файла — не аудио",
"comment": "Комментарий для администратора",
"comment_hint": "Например: «ремастер 2011», «замена битых файлов»",
"not_enough_space": "В очереди не хватает места: нужно {{need}}, свободно {{free}} — часть файлов не примут.",
"clear": "Очистить список",
"hashing_wait": "Проверяю файлы…",
"send_one": "Отправить {{count}} файл · {{size}}",
"send_few": "Отправить {{count}} файла · {{size}}",
"send_many": "Отправить {{count}} файлов · {{size}}",
"send_other": "Отправить {{count}} файла · {{size}}",
"sent_one": "Готово: {{count}} файл загружен и обрабатывается. Статусы — в «Моих предложениях».",
"sent_few": "Готово: {{count}} файла загружены и обрабатываются. Статусы — в «Моих предложениях».",
"sent_many": "Готово: {{count}} файлов загружены и обрабатываются. Статусы — в «Моих предложениях».",
"sent_other": "Готово: {{count}} файла загружены и обрабатываются. Статусы — в «Моих предложениях».",
"failed_some_one": "Не загрузился {{count}} файл — место за ним зарезервировано, можно продолжить.",
"failed_some_few": "Не загрузились {{count}} файла — место за ними зарезервировано, можно продолжить.",
"failed_some_many": "Не загрузились {{count}} файлов — место за ними зарезервировано, можно продолжить.",
"failed_some_other": "Не загрузились {{count}} файла — место за ними зарезервировано, можно продолжить.",
"retry_failed": "Продолжить загрузку",
"uploading_now": "Загружаю…",
"go_mine": "Мои предложения →",
"more": "Загрузить ещё",
"dup_found": "Такой трек уже есть в библиотеке или в очереди — загружать не нужно",
"possible_dup": "Похожий трек уже есть (совпали исполнитель, название и длительность) — решит администратор",
"file_error": "Ошибка: {{message}}",
"remove": "Убрать {{name}}",
"quota": "Очередь: {{used}} из {{limit}}",
"quota_mine": "ваши: {{used}} из {{limit}}",
"mine_title": "Мои предложения",
"mine_subtitle": "Что вы загрузили и что решил администратор",
"mine_empty": "Вы ещё ничего не предлагали",
"filter": "Фильтр",
"filter_all": "Все",
"filter_pending": "В работе",
"filter_imported": "Добавлены",
"filter_declined": "Не приняты",
"col_file": "Трек",
"col_status": "Статус",
"col_size": "Размер",
"col_date": "Загружен",
"received": "принято {{pct}}% — откройте «Предложить треки» в той же вкладке, чтобы продолжить",
"withdraw": "Отозвать",
"load_more": "Показать ещё",
"queue_title": "Очередь на добавление",
"queue_subtitle": "Проверьте метаданные и путь в хранилище, затем одобрите или отклоните",
"queue_empty": "Здесь пусто",
"tab_review": "На рассмотрении",
"tab_failed": "Ошибки импорта",
"tab_progress": "В работе",
"tab_imported": "Добавлены",
"tab_declined": "Не приняты",
"only_possible": "Только возможные дубли",
"review_failed_one": "Не удалось применить решение к {{count}} файлу: {{reason}}",
"review_failed_few": "Не удалось применить решение к {{count}} файлам: {{reason}}",
"review_failed_many": "Не удалось применить решение к {{count}} файлам: {{reason}}",
"review_failed_other": "Не удалось применить решение к {{count}} файлам: {{reason}}",
"bulk": "Действия с выбранными",
"selected_one": "Выбран {{count}} файл",
"selected_few": "Выбрано {{count}} файла",
"selected_many": "Выбрано {{count}} файлов",
"selected_other": "Выбрано {{count}} файла",
"approve": "Одобрить",
"reject": "Отклонить",
"batch_of": "Пачка от {{author}}",
"batch_size_one": "{{count}} файл · {{size}}",
"batch_size_few": "{{count}} файла · {{size}}",
"batch_size_many": "{{count}} файлов · {{size}}",
"batch_size_other": "{{count}} файла · {{size}}",
"select_batch": "Выбрать всю пачку",
"select_file": "Выбрать {{name}}",
"disc": "диск {{n}}",
"edited": "правлено",
"flag_possible": "возможный дубль",
"flag_existing_album": "в существующий альбом",
"flag_new_album": "новый альбом",
"dup_level_sha256": "точная копия файла",
"dup_level_fingerprint": "тот же трек по акустическому отпечатку",
"dup_level_metadata": "совпали метаданные",
"edit": "Править {{name}}",
"retry": "Повторить",
"edit_title": "Метаданные трека",
"meta_artist": "Исполнитель",
"meta_albumartist": "Исполнитель альбома",
"meta_album": "Альбом",
"meta_title": "Название",
"meta_genre": "Жанр",
"meta_year": "Год",
"meta_track": "Номер трека",
"meta_disc": "Диск",
"target_source": "Хранилище",
"target_default": "По умолчанию",
"tags_note": "Правленые поля запишутся в теги файла при импорте — путь и теги не разойдутся.",
"save": "Сохранить",
"saved": "Сохранено",
"reject_title_one": "Отклонить {{count}} файл",
"reject_title_few": "Отклонить {{count}} файла",
"reject_title_many": "Отклонить {{count}} файлов",
"reject_title_other": "Отклонить {{count}} файла",
"reject_reason": "Причина (её увидит автор)",
"settings_title": "Настройки очереди",
"set_queue": "Лимит очереди, ГБ",
"set_per_user": "Лимит на одного модератора, ГБ",
"set_per_user_hint": "Пусто — без лимита",
"set_max_file": "Максимальный размер файла, ГБ",
"set_source": "Хранилище по умолчанию",
"set_source_auto": "Единственное, принимающее загрузки",
"set_note": "Уменьшение лимита ниже занятого ничего не удаляет: новые загрузки подождут, пока очередь освободится.",
"status": {
"uploading": "Загружается",
"processing": "Обработка",
"pending_review": "На рассмотрении",
"approved": "Одобрен",
"importing": "Импорт",
"imported": "Добавлен",
"import_failed": "Ошибка импорта",
"duplicate": "Дубль",
"invalid": "Не аудио",
"rejected": "Отклонён",
"withdrawn": "Отозван",
"expired": "Не докачан"
},
"stage": {
"hashing": "Считаю контрольную сумму",
"checking": "Проверяю на дубли",
"ready": "Готов к отправке",
"duplicate": "Уже есть",
"too_large": "Слишком большой",
"uploading": "Загружается",
"done": "Загружен, обрабатывается",
"rejected": "Не принят",
"error": "Ошибка"
}
}
}
+5 -1
View File
@@ -29,7 +29,11 @@
"collection": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки"
"dislikes": "Дизлайки",
"uploads": "Предложения",
"suggest": "Предложить треки",
"my_uploads": "Мои предложения",
"upload_queue": "Очередь на добавление"
},
"profile": {
"menu": "Меню профиля",
+135 -8
View File
@@ -144,6 +144,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.120.20
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
react:
specifier: ^19.1.0
version: 19.3.1
@@ -153,6 +156,9 @@ importers:
react-i18next:
specifier: ^15.5.3
version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.8.3)
tus-js-client:
specifier: ^4.3.1
version: 4.3.1
devDependencies:
'@testing-library/dom':
specifier: ^10.4.0
@@ -177,13 +183,13 @@ importers:
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
jsdom:
specifier: ^26.1.0
version: 26.1.0
version: 26.1.0(supports-color@10.2.2)
vite:
specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/api-client:
dependencies:
@@ -203,7 +209,7 @@ importers:
devDependencies:
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/design-tokens: {}
@@ -1878,6 +1884,9 @@ packages:
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
combine-errors@3.0.3:
resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
@@ -1937,6 +1946,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
custom-error-instance@2.1.1:
resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -2331,6 +2343,9 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
@@ -2453,6 +2468,10 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
@@ -2510,6 +2529,9 @@ packages:
jimp-compact@0.16.1:
resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==}
js-base64@3.9.4:
resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
@@ -2653,12 +2675,33 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash._baseiteratee@4.7.0:
resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==}
lodash._basetostring@4.12.0:
resolution: {integrity: sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==}
lodash._baseuniq@4.6.0:
resolution: {integrity: sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==}
lodash._createset@4.0.3:
resolution: {integrity: sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==}
lodash._root@3.0.1:
resolution: {integrity: sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==}
lodash._stringtopath@4.8.0:
resolution: {integrity: sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
lodash.uniqby@4.5.0:
resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==}
log-symbols@2.2.0:
resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==}
engines: {node: '>=4'}
@@ -3044,6 +3087,9 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3056,6 +3102,9 @@ packages:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
@@ -3230,6 +3279,9 @@ packages:
resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==}
engines: {node: '>= 4.0.0'}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resolve-from@3.0.0:
resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==}
engines: {node: '>=4'}
@@ -3257,6 +3309,10 @@ packages:
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
engines: {node: '>=4'}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -3616,6 +3672,10 @@ packages:
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
hasBin: true
tus-js-client@4.3.1:
resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==}
engines: {node: '>=18'}
type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
@@ -3680,6 +3740,9 @@ packages:
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
use-latest-callback@0.2.6:
resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==}
peerDependencies:
@@ -5936,6 +5999,11 @@ snapshots:
colorette@1.4.0: {}
combine-errors@3.0.3:
dependencies:
custom-error-instance: 2.1.1
lodash.uniqby: 4.5.0
commander@12.1.0: {}
commander@2.20.3: {}
@@ -6002,6 +6070,8 @@ snapshots:
csstype@3.2.3: {}
custom-error-instance@2.1.1: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -6380,6 +6450,8 @@ snapshots:
has-flag@4.0.0: {}
hash-wasm@4.12.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -6416,7 +6488,7 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
http-proxy-agent@7.0.2(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
@@ -6490,6 +6562,8 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
is-stream@2.0.1: {}
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
@@ -6588,6 +6662,8 @@ snapshots:
jimp-compact@0.16.1: {}
js-base64@3.9.4: {}
js-levenshtein@1.1.6: {}
js-sha256@1.0.0: {}
@@ -6607,13 +6683,13 @@ snapshots:
jsc-safe-url@0.2.4: {}
jsdom@26.1.0:
jsdom@26.1.0(supports-color@10.2.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
@@ -6710,10 +6786,34 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash._baseiteratee@4.7.0:
dependencies:
lodash._stringtopath: 4.8.0
lodash._basetostring@4.12.0: {}
lodash._baseuniq@4.6.0:
dependencies:
lodash._createset: 4.0.3
lodash._root: 3.0.1
lodash._createset@4.0.3: {}
lodash._root@3.0.1: {}
lodash._stringtopath@4.8.0:
dependencies:
lodash._basetostring: 4.12.0
lodash.debounce@4.0.8: {}
lodash.throttle@4.1.1: {}
lodash.uniqby@4.5.0:
dependencies:
lodash._baseiteratee: 4.7.0
lodash._baseuniq: 4.6.0
log-symbols@2.2.0:
dependencies:
chalk: 2.4.2
@@ -7176,6 +7276,12 @@ snapshots:
kleur: 3.0.3
sisteransi: 1.0.5
proper-lockfile@4.1.2:
dependencies:
graceful-fs: 4.2.11
retry: 0.12.0
signal-exit: 3.0.7
punycode@2.3.1: {}
qrcode-terminal@0.11.0: {}
@@ -7187,6 +7293,8 @@ snapshots:
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
querystringify@2.2.0: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
@@ -7397,6 +7505,8 @@ snapshots:
rc: 1.2.8
resolve: 1.7.1
requires-port@1.0.0: {}
resolve-from@3.0.0: {}
resolve-from@5.0.0: {}
@@ -7421,6 +7531,8 @@ snapshots:
onetime: 2.0.1
signal-exit: 3.0.7
retry@0.12.0: {}
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@@ -7764,6 +7876,16 @@ snapshots:
turbo-windows-64: 2.5.4
turbo-windows-arm64: 2.5.4
tus-js-client@4.3.1:
dependencies:
buffer-from: 1.1.2
combine-errors: 3.0.3
is-stream: 2.0.1
js-base64: 3.9.4
lodash.throttle: 4.1.1
proper-lockfile: 4.1.2
url-parse: 1.5.10
type-detect@4.0.8: {}
type-fest@0.21.3: {}
@@ -7805,6 +7927,11 @@ snapshots:
uri-js-replace@1.0.1: {}
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
use-latest-callback@0.2.6(react@19.0.0):
dependencies:
react: 19.0.0
@@ -7860,7 +7987,7 @@ snapshots:
lightningcss: 1.27.0
terser: 5.51.2
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -7887,7 +8014,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
jsdom: 26.1.0
jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies:
- jiti
- less