Files
pipu-music/apps/web/src/pages/uploads/SuggestPage.tsx
T
mrleo1nid 1e3b350dfc
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
feat(upload): enhance audio upload handling and user feedback
- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort.
- Added functionality to highlight files needing format attention in the upload queue.
- Updated the UI to provide clearer guidance on acceptable audio formats during uploads.
- Improved documentation to reflect changes in upload requirements and user interface updates.
2026-10-21 10:07:43 +03:00

242 lines
9.5 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.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;
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={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} 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>
)}
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.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>
);
}