{t('uploads.queue_subtitle')}
+{t('uploads.set_note')}
+{t('uploads.mine_subtitle')}
+| {t('uploads.col_file')} | +{t('uploads.col_status')} | +{t('uploads.col_size')} | +{t('uploads.col_date')} | ++ |
|---|
{t('uploads.suggest_subtitle')}
+{t('uploads.skipped', { count: st.skipped })}
} + + {st.items.length > 0 && ( +{t('uploads.not_enough_space', { need: fmt(readyBytes), free: fmt(free) })}
+ )} +{t('uploads.sent', { count: uploaded })}
} + {failedUploads > 0 &&{t('uploads.failed_some', { count: failedUploads })}
} + или текст отклонения админа. */
+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;
+ };
+}
diff --git a/apps/web/src/pages/uploads/uploads.module.css b/apps/web/src/pages/uploads/uploads.module.css
new file mode 100644
index 0000000..373088f
--- /dev/null
+++ b/apps/web/src/pages/uploads/uploads.module.css
@@ -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;
+}
diff --git a/apps/web/src/shared/lib/format.ts b/apps/web/src/shared/lib/format.ts
index d10cb32..86aaf69 100644
--- a/apps/web/src/shared/lib/format.ts
+++ b/apps/web/src/shared/lib/format.ts
@@ -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);
+}
diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts
index ae371e5..ece61b9 100644
--- a/apps/web/src/shared/ui/icons.ts
+++ b/apps/web/src/shared/ui/icons.ts
@@ -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';
diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx
index 3f9122e..14a00f1 100644
--- a/apps/web/src/widgets/AppShell.tsx
+++ b/apps/web/src/widgets/AppShell.tsx
@@ -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() {
{staff && (
<>
+ {t('nav.uploads')}
+
+
+ {user.role === 'admin' && (
+
+ )}
{t('nav.admin')}
{user.role === 'admin' && (
diff --git a/docs/uploads.md b/docs/uploads.md
index 42d54e5..ee8489f 100644
--- a/docs/uploads.md
+++ b/docs/uploads.md
@@ -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`, файлы удаляются.
diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json
index 926e1aa..bf1e578 100644
--- a/packages/i18n/locales/en/admin.json
+++ b/packages/i18n/locales/en/admin.json
@@ -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"
+ }
}
}
diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json
index e892d94..0145ed8 100644
--- a/packages/i18n/locales/en/common.json
+++ b/packages/i18n/locales/en/common.json
@@ -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",
diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json
index 832abf5..e7c546d 100644
--- a/packages/i18n/locales/ru/admin.json
+++ b/packages/i18n/locales/ru/admin.json
@@ -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": "Ошибка"
+ }
}
}
diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json
index fc4e154..f61dbf7 100644
--- a/packages/i18n/locales/ru/common.json
+++ b/packages/i18n/locales/ru/common.json
@@ -29,7 +29,11 @@
"collection": "Коллекция",
"history": "История",
"likes": "Лайки",
- "dislikes": "Дизлайки"
+ "dislikes": "Дизлайки",
+ "uploads": "Предложения",
+ "suggest": "Предложить треки",
+ "my_uploads": "Мои предложения",
+ "upload_queue": "Очередь на добавление"
},
"profile": {
"menu": "Меню профиля",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c876c1a..22bbe78 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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