Files
pipu-music/apps/web/src/pages/uploads/SuggestPage.tsx
T
mrleo1nid 64dd8ed05e
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
2026-10-29 10:28:29 +03:00

255 lines
10 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, needsFormatAttention } 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, 'jpg', 'jpeg', 'png', 'webp'].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.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null && !ready.length;
const resume = st.items.filter((it) => it.stage === 'resume').length;
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>
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
<div className={s.note}>{t('uploads.drop_folders')}</div>
<div className={s.note}>{t('uploads.cover_hint')}</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) => {
const picked = fromInput(e.target.files);
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
else uploader.add(picked);
e.target.value = '';
}}
/>
<input
ref={dirRef}
className={u.hiddenInput}
type="file"
multiple
onChange={(e) => {
const picked = fromInput(e.target.files);
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
else uploader.add(picked);
e.target.value = '';
}}
/>
</div>
{resume > 0 && (
<section className={s.section}>
<p>{t('uploads.resume_hint', { count: resume })}</p>
<Button onClick={() => filesRef.current?.click()}>{t('uploads.resume_pick')}</Button>
</section>
)}
{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} retryDisabled={!uploader.canRetryFailed} onRetry={() => void uploader.retryFailed(it.id)} 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}
disabled={st.busy}
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, retryDisabled, onRetry, onRemove }: {
item: UploadItem;
locked: boolean;
retryDisabled: boolean;
onRetry: () => void;
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: item.code === 'uploads.storage_full' ? tr('errors:uploads.storage_full') : 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>
)}
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
{item.coverName && <span className={u.sub}>{t(item.coverName === 'embedded-cover' ? 'uploads.cover_embedded' : 'uploads.cover_found', { name: item.coverName })}</span>}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
{item.stage === 'error' && item.tusUrl && item.file ? (
<Button variant="secondary" disabled={retryDisabled} aria-label={t('uploads.retry_file', { name: item.relPath })} onClick={onRetry}>
{t('uploads.retry')}
</Button>
) : locked ? (
<span />
) : (
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
<Icon src={closeIcon} size={18} />
</Button>
)}
</div>
);
}