feat(web): suggest tracks, my uploads and admin upload queue screens (M5.5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m4s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m4s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s
- Suggest tracks: drag-and-drop of files and folders with relative paths, streaming SHA-256 in a Web Worker (hash-wasm), duplicate precheck, batch reservation and tus-js-client upload (8 MB chunks, 4 in parallel, token refresh on 401, resume of failed files). - My uploads: statuses, rejection reasons, withdraw, quota meter. - Upload queue (admin): status tabs, possible-duplicate filter, batches, metadata and storage editing, bulk approve / reject with a reason, import retry, and the queue settings form. - Sidebar section for moderators and admins; ru / en strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca106e8eb1
commit
a3f212d253
19 files changed
+2144
-14
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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' && (
|
||||
|
||||
Reference in new issue
Block a user