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' && (
|
||||
|
||||
+12
-1
@@ -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`, файлы удаляются.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ошибка"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,11 @@
|
||||
"collection": "Коллекция",
|
||||
"history": "История",
|
||||
"likes": "Лайки",
|
||||
"dislikes": "Дизлайки"
|
||||
"dislikes": "Дизлайки",
|
||||
"uploads": "Предложения",
|
||||
"suggest": "Предложить треки",
|
||||
"my_uploads": "Мои предложения",
|
||||
"upload_queue": "Очередь на добавление"
|
||||
},
|
||||
"profile": {
|
||||
"menu": "Меню профиля",
|
||||
|
||||
Generated
+135
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user