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>
229 lines
8.7 KiB
TypeScript
229 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|