import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession, useUser } from '../../app/session'; import { GeniusPanel } from './GeniusPanel'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons'; import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui'; import s from '../page.module.css'; import l from './library.module.css'; import { ScanLine, StatusBadge } from './ScanStatus'; type Source = components['schemas']['Source']; type TestResult = components['schemas']['SourceTestResult']; const SCAN_POLL_MS = 2000; export function SourcesPage() { const user = useUser(); const { t } = useTranslation(['admin', 'common']); const session = useSession(); const errorText = useErrorText(); const [editing, setEditing] = useState(null); const sources = useQuery({ queryKey: ['sources'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items, // Пока идёт скан — опрашиваем прогресс. refetchInterval: (q) => (q.state.data?.some((x) => x.scan?.running) ? SCAN_POLL_MS : false), }); return (

{t('sources.title')}

{t('sources.subtitle')}

{sources.error ? errorText(sources.error) : null} {user.role === 'admin' && } {sources.isSuccess && sources.data.length === 0 &&
{t('sources.empty')}
}
{sources.data?.map((src) => ( setEditing(src)} /> ))}
{editing && setEditing(null)} />}
); } function location(src: Source): string { if (src.type === 'nfs') return `${src.nfs_server ?? ''}:${src.nfs_export ?? ''}`; return `LIBRARY_HOST/${src.local_path ?? ''}`; } function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); const navigate = useNavigate(); const errorText = useErrorText(); const [test, setTest] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const id = source.id; const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] }); const scan = useMutation({ mutationFn: async (full: boolean) => unwrap(await session.client.POST('/api/v1/admin/sources/{id}/scan', { params: { path: { id } }, body: { full } })), onSuccess: refresh, }); const check = useMutation({ mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/sources/{id}/test', { params: { path: { id } } })), onSuccess: setTest, }); const patch = useMutation({ mutationFn: async (enabled: boolean) => unwrap(await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id } }, body: { enabled } })), onSuccess: refresh, }); const remove = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/admin/sources/{id}', { params: { path: { id } } })), onSuccess: refresh, }); const error = scan.error ?? check.error ?? patch.error ?? remove.error; const running = source.scan?.running ?? false; const disabled = source.status === 'disabled'; const menu: MenuItem[] = [ { label: t('sources.full_scan'), icon: refreshIcon, onSelect: () => scan.mutate(true) }, { label: t('sources.edit'), onSelect: onEdit }, disabled ? { label: t('sources.enable'), onSelect: () => patch.mutate(true) } : { label: t('sources.disable'), onSelect: () => patch.mutate(false) }, { label: t('sources.delete'), danger: true, onSelect: () => setConfirmDelete(true) }, ]; return (
{source.name} {t(`sources.type_${source.type}`)} {source.accepts_uploads && {t('write_template.badge')}}
{location(source)} {source.status === 'error' && source.status_message && ( {source.status_message} )}
} items={menu} />
{test && ( {test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })} )} {test && test.write_ok !== null && test.write_ok !== undefined && ( {test.write_ok ? t('write_template.write_ok') : t('write_template.write_fail', { message: test.write_message ?? '' })} )} {error ? errorText(error) : null}
{confirmDelete && ( setConfirmDelete(false)} footer={ <> } >

{t('sources.delete_confirm', { name: source.name })}

)}
); } const INTERVALS = [0, 60, 360, 1440]; function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); const errorText = useErrorText(); const fieldError = useFieldError(); const [type, setType] = useState<'local' | 'nfs'>(source?.type ?? 'local'); const [name, setName] = useState(source?.name ?? ''); const [localPath, setLocalPath] = useState(source?.local_path ?? ''); const [server, setServer] = useState(source?.nfs_server ?? ''); const [exportPath, setExportPath] = useState(source?.nfs_export ?? ''); const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3'); const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first'); const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0); const save = useMutation({ mutationFn: async () => { const common = { name: name.trim(), tag_priority: priority, ...(type === 'local' ? { local_path: localPath } : { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }), }; if (source) return unwrap( await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id: source.id } }, body: { ...common, scan_interval_minutes: interval }, }), ); return unwrap( await session.client.POST('/api/v1/admin/sources', { body: { ...common, type, scan_interval_minutes: interval || null }, }), ); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['sources'] }); onClose(); }, }); const err = save.error; const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options']; const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null; const submit = (e: FormEvent) => { e.preventDefault(); save.mutate(); }; return ( } >
{!source && (
{t('sources.type')}
)} {type === 'local' ? ( ) : ( <> )}
{t('sources.tag_priority')}
{general}
); } /** Выбор подпапки LIBRARY_HOST: хлебные крошки и список вложенных папок. */ function FolderPicker({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) { const { t } = useTranslation('admin'); const session = useSession(); const dirs = useQuery({ queryKey: ['host-dirs', value], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/host-dirs', { params: { query: { path: value } } })), }); const parts = value ? value.split('/') : []; return (
{t('sources.folder')}
{parts.map((p, i) => ( ))}
{dirs.data?.dirs.map((d) => ( ))}
{error ?? (value ? value : t('sources.folder_root'))}
); }