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(null); const dirRef = useRef(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 (

{t('uploads.suggest_title')}

{t('uploads.suggest_subtitle')}

{ e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={(e) => void onDrop(e)} >
{t('uploads.drop_title')}

{t('uploads.drop_hint')}

{t('uploads.drop_folders')}
{t('uploads.cover_hint')}
{ 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 = ''; }} /> { 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 = ''; }} />
{resume > 0 && (

{t('uploads.resume_hint', { count: resume })}

)} {st.skipped > 0 &&

{t('uploads.skipped', { count: st.skipped })}

} {st.items.length > 0 && (
{st.items.map((it) => ( void uploader.retryFailed(it.id)} onRemove={() => uploader.remove(it.id)} /> ))}
)} {st.items.length > 0 && !finished && (