feat(web): storage admin, search and catalog pages (M2)
- Storage admin: local folders with a folder picker and NFS sources, connection test, scan and full scan with live progress, enable, disable, delete - path pattern editor with reorder, examples and a preview table that shows which pattern matched and what came from tags; compile errors land under the broken pattern - Library page with counters, scans and unreadable files; search reindex for admins - search box in the top bar: combobox with keyboard navigation, 150 ms debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint - search page with tabs and paging, artist and album pages, album cards and track lists; unavailable tracks are dimmed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1160cccd3
commit
a685b1b7ca
27 files changed
+2505
-9
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||
|
||||
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
||||
export function LibraryPage() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const errorText = useErrorText();
|
||||
const admin = user.role === 'admin';
|
||||
|
||||
const status = useQuery({
|
||||
queryKey: ['library-status'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/status')),
|
||||
refetchInterval: (q) => (q.state.data?.sources.some((x) => x.scan?.running) ? 2000 : 15000),
|
||||
});
|
||||
const errors = useQuery({
|
||||
queryKey: ['library-errors'],
|
||||
enabled: admin,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
|
||||
});
|
||||
const reindex = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||
});
|
||||
const d = status.data;
|
||||
const tiles = d
|
||||
? ([
|
||||
['artists', d.artists],
|
||||
['albums', d.albums],
|
||||
['tracks', d.tracks],
|
||||
['files', d.files],
|
||||
['missing', d.missing_files],
|
||||
['errors', d.file_errors],
|
||||
] as const)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('library.title')}</h1>
|
||||
<p className={s.subtitle}>{t('library.subtitle')}</p>
|
||||
</div>
|
||||
{admin && (
|
||||
<Button icon={refreshIcon} busy={reindex.isPending} onClick={() => reindex.mutate()}>
|
||||
{reindex.isSuccess ? t('library.reindex_started') : t('library.reindex')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormError>{status.error || reindex.error ? errorText(status.error ?? reindex.error) : null}</FormError>
|
||||
<div className={l.tiles}>
|
||||
{tiles.map(([k, v]) => (
|
||||
<div key={k} className={l.tile}>
|
||||
<span className={l.tileValue}>{v.toLocaleString()}</span>
|
||||
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{d && d.sources.length > 0 && (
|
||||
<section className={s.section}>
|
||||
<h2>{t('library.sources')}</h2>
|
||||
{d.sources.map((src) => (
|
||||
<div key={src.id} className={l.cardFoot}>
|
||||
<div className={l.cardTitle}>
|
||||
{src.name} <StatusBadge status={src.status} />
|
||||
</div>
|
||||
<ScanLine scan={src.scan} lastScanAt={src.last_scan_at} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{admin && errors.data && errors.data.items.length > 0 && (
|
||||
<section className={s.section}>
|
||||
<h2>{t('library.error_files')}</h2>
|
||||
{errors.data.items.map((e) => (
|
||||
<div key={e.file_id} className={l.cardFoot}>
|
||||
<span className={l.location}>{e.rel_path}</span>
|
||||
<span className={l.scanError}>{e.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { ApiError, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons';
|
||||
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
|
||||
type Pattern = components['schemas']['PathPattern'];
|
||||
type Preview = components['schemas']['PatternPreview'];
|
||||
|
||||
/** Примеры из docs/library-and-storage.md — частые раскладки папок. */
|
||||
const EXAMPLES = [
|
||||
String.raw`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
|
||||
String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
|
||||
String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`,
|
||||
];
|
||||
|
||||
/** Ошибка компиляции из 422: номер шаблона и причина. */
|
||||
function patternError(err: unknown): { index: number; reason: string } | null {
|
||||
if (err instanceof ApiError && err.code === 'validation.pattern_invalid') {
|
||||
return { index: Number(err.params.index), reason: String(err.params.reason) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PatternsPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const source = useQuery({
|
||||
queryKey: ['source', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
const saved = useQuery({
|
||||
queryKey: ['patterns', id],
|
||||
queryFn: async () =>
|
||||
unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns,
|
||||
});
|
||||
const [rows, setRows] = useState<Pattern[]>([]);
|
||||
useEffect(() => {
|
||||
if (saved.data) setRows(saved.data);
|
||||
}, [saved.data]);
|
||||
|
||||
const preview = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', {
|
||||
params: { path: { id } },
|
||||
body: { patterns: rows, sample: 50 },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.PUT('/api/v1/admin/sources/{id}/patterns', {
|
||||
params: { path: { id } },
|
||||
body: { patterns: rows },
|
||||
}),
|
||||
),
|
||||
onSuccess: (r) => setRows(r.patterns),
|
||||
});
|
||||
|
||||
const err = save.error ?? preview.error;
|
||||
const perr = patternError(err);
|
||||
const update = (i: number, p: Partial<Pattern>) => {
|
||||
save.reset();
|
||||
setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r)));
|
||||
};
|
||||
const move = (i: number, d: -1 | 1) =>
|
||||
setRows((rs) => {
|
||||
const next = [...rs];
|
||||
[next[i], next[i + d]] = [next[i + d]!, next[i]!];
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
|
||||
<p className={s.subtitle}>
|
||||
<Link to="/admin/sources">{t('sources.title')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<section className={s.section}>
|
||||
<p className={s.note}>{t('patterns.intro')}</p>
|
||||
<p className={l.groups}>{t('patterns.groups')}</p>
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className={l.patternRow}>
|
||||
<span className={l.patternNum}>{i + 1}</span>
|
||||
<Field
|
||||
label={t('patterns.regex', { n: i + 1 })}
|
||||
value={r.regex}
|
||||
onChange={(v) => update(i, { regex: v })}
|
||||
maxLength={1000}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
mono
|
||||
error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined}
|
||||
/>
|
||||
<div className={l.patternTools} style={{ marginTop: 22 }}>
|
||||
<label className={l.check}>
|
||||
<input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} />
|
||||
{t('patterns.enabled')}
|
||||
</label>
|
||||
<Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}>
|
||||
<Icon src={arrowUpIcon} size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={t('patterns.down')}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<Icon src={arrowDownIcon} size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={t('patterns.remove')}
|
||||
onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))}
|
||||
>
|
||||
<Icon src={deleteIcon} size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={s.toolbar}>
|
||||
<Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}>
|
||||
{t('patterns.add')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])}
|
||||
>
|
||||
{t('patterns.examples')}
|
||||
</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button busy={preview.isPending} onClick={() => preview.mutate()}>
|
||||
{t('patterns.preview')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('patterns.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>}
|
||||
<FormError>{err && !perr ? errorText(err) : null}</FormError>
|
||||
</section>
|
||||
{preview.data && <PreviewTable preview={preview.data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewTable({ preview }: { preview: Preview }) {
|
||||
const { t } = useTranslation('admin');
|
||||
const fields = ['artist', 'album', 'title'] as const;
|
||||
return (
|
||||
<>
|
||||
<p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p>
|
||||
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
|
||||
<table className={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('patterns.col_path')}</th>
|
||||
<th>{t('patterns.col_pattern')}</th>
|
||||
<th>{t('patterns.col_result')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.items.map((it) => (
|
||||
<tr key={it.rel_path}>
|
||||
<td className={l.match}>{it.rel_path}</td>
|
||||
<td className={cx(l.match, it.pattern_index === null && l.noMatch)}>
|
||||
{it.skipped
|
||||
? t('patterns.skipped')
|
||||
: it.pattern_index === null
|
||||
? t('patterns.no_match')
|
||||
: `#${it.pattern_index + 1}`}
|
||||
</td>
|
||||
<td>
|
||||
<div className={l.result}>
|
||||
{fields.map((f) => {
|
||||
const fromPath = f in it.path_fields;
|
||||
const fromTags = (f === 'artist' ? 'artist' : f) in it.tags;
|
||||
return (
|
||||
<span key={f}>
|
||||
<b>{it.result[f]}</b>{' '}
|
||||
{!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className={l.fromTags}>
|
||||
{[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
{it.error && <span className={l.scanError}>{it.error}</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDateTime } from '../../shared/lib/format';
|
||||
import { cx } from '../../shared/ui/ui';
|
||||
import s from './library.module.css';
|
||||
|
||||
type ScanProgress = components['schemas']['ScanProgress'];
|
||||
export type SourceStatus = components['schemas']['SourceStatus'];
|
||||
|
||||
export function StatusBadge({ status }: { status: SourceStatus }) {
|
||||
const { t } = useTranslation('admin');
|
||||
return <span className={cx(s.status, s[`st_${status}`])}>{t(`sources.status_${status}`)}</span>;
|
||||
}
|
||||
|
||||
/** Прогресс идущего скана или итог последнего. */
|
||||
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
if (scan?.running) {
|
||||
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||
return (
|
||||
<div className={s.scan}>
|
||||
<div className={s.scanText}>
|
||||
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
||||
</div>
|
||||
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={s.scan}>
|
||||
<div className={s.scanText}>
|
||||
{lastScanAt ? t('sources.last_scan', { date: formatDateTime(lastScanAt, i18n.language) }) : t('sources.never_scanned')}
|
||||
</div>
|
||||
{scan?.error ? (
|
||||
<div className={s.scanError}>{t('sources.scan_failed', { error: scan.error })}</div>
|
||||
) : (
|
||||
scan && <div className={s.scanSummary}>{t('sources.scan_done', { ...scan })}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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 } from '../../app/session';
|
||||
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 { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [editing, setEditing] = useState<Source | 'new' | null>(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 (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('sources.title')}</h1>
|
||||
<p className={s.subtitle}>{t('sources.subtitle')}</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={addIcon} onClick={() => setEditing('new')}>
|
||||
{t('sources.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||
<div className={l.list}>
|
||||
{sources.data?.map((src) => (
|
||||
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
||||
))}
|
||||
</div>
|
||||
{editing && <SourceDialog source={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<TestResult | null>(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 (
|
||||
<article className={l.card} aria-label={source.name}>
|
||||
<div className={l.cardHead}>
|
||||
<div className={l.cardTitle}>
|
||||
{source.name}
|
||||
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
|
||||
<StatusBadge status={source.status} />
|
||||
</div>
|
||||
<span className={l.location}>{location(source)}</span>
|
||||
{source.status === 'error' && source.status_message && (
|
||||
<span className={l.message}>{source.status_message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={l.cardActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
busy={scan.isPending}
|
||||
disabled={running || source.status !== 'mounted'}
|
||||
onClick={() => scan.mutate(false)}
|
||||
>
|
||||
{t('sources.scan')}
|
||||
</Button>
|
||||
<Button busy={check.isPending} onClick={() => check.mutate()}>
|
||||
{t('sources.test')}
|
||||
</Button>
|
||||
<Button onClick={() => void navigate({ to: '/admin/sources/$id/patterns', params: { id } })}>
|
||||
{t('sources.patterns')}
|
||||
</Button>
|
||||
<Menu label={t('users.actions', { username: source.name })} trigger={<Icon src={moreIcon} />} items={menu} />
|
||||
</div>
|
||||
<div className={l.cardFoot}>
|
||||
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
||||
{test && (
|
||||
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
|
||||
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
|
||||
</span>
|
||||
)}
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
</div>
|
||||
{confirmDelete && (
|
||||
<Dialog
|
||||
title={t('sources.delete')}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{t('users.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className={s.note}>{t('sources.delete_confirm', { name: source.name })}</p>
|
||||
</Dialog>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
title={t(source ? 'sources.form_title_edit' : 'sources.form_title_new')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="source-form" variant="primary" busy={save.isPending} disabled={!name.trim()}>
|
||||
{t('sources.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="source-form" onSubmit={submit} noValidate className={s.sessions}>
|
||||
<Field label={t('sources.name')} value={name} onChange={setName} maxLength={100} error={fieldError(err, 'name')} />
|
||||
{!source && (
|
||||
<div className={l.field}>
|
||||
<span className={l.label}>{t('sources.type')}</span>
|
||||
<Segmented
|
||||
label={t('sources.type')}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
options={[
|
||||
{ value: 'local', label: t('sources.type_local') },
|
||||
{ value: 'nfs', label: t('sources.type_nfs') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'local' ? (
|
||||
<FolderPicker value={localPath} onChange={setLocalPath} error={fieldError(err, 'local_path')} />
|
||||
) : (
|
||||
<>
|
||||
<Field
|
||||
label={t('sources.nfs_server')}
|
||||
value={server}
|
||||
onChange={setServer}
|
||||
hint={t('sources.nfs_server_hint')}
|
||||
error={fieldError(err, 'nfs_server')}
|
||||
mono
|
||||
/>
|
||||
<Field
|
||||
label={t('sources.nfs_export')}
|
||||
value={exportPath}
|
||||
onChange={setExportPath}
|
||||
hint={t('sources.nfs_export_hint')}
|
||||
error={fieldError(err, 'nfs_export')}
|
||||
mono
|
||||
/>
|
||||
<Field
|
||||
label={t('sources.nfs_options')}
|
||||
value={options}
|
||||
onChange={setOptions}
|
||||
hint={t('sources.nfs_options_hint')}
|
||||
error={fieldError(err, 'nfs_options')}
|
||||
mono
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={l.field}>
|
||||
<span className={l.label}>{t('sources.tag_priority')}</span>
|
||||
<Segmented
|
||||
label={t('sources.tag_priority')}
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
options={[
|
||||
{ value: 'tags_first', label: t('sources.tags_first') },
|
||||
{ value: 'path_first', label: t('sources.path_first') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<label className={l.field}>
|
||||
<span className={l.label}>{t('sources.interval')}</span>
|
||||
<select className={s.select} value={interval} onChange={(e) => setIntervalMin(Number(e.target.value))}>
|
||||
{[...new Set([...INTERVALS, interval])].map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m === 0 ? t('sources.interval_off') : t('sources.interval_hours', { count: Math.round(m / 60) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<FormError>{general}</FormError>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Выбор подпапки 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 (
|
||||
<div className={l.field}>
|
||||
<span className={l.label}>{t('sources.folder')}</span>
|
||||
<div className={l.picker}>
|
||||
<div className={l.crumbs}>
|
||||
<button type="button" className={l.dir} onClick={() => onChange('')}>
|
||||
LIBRARY_HOST
|
||||
</button>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className={l.crumbs}>
|
||||
<Icon src={chevronRightIcon} size={14} />
|
||||
<button type="button" className={l.dir} onClick={() => onChange(parts.slice(0, i + 1).join('/'))}>
|
||||
{p}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{dirs.data?.dirs.map((d) => (
|
||||
<button key={d} type="button" className={l.dir} onClick={() => onChange(value ? `${value}/${d}` : d)}>
|
||||
<Icon src={folderIcon} size={18} className={l.dirIcon} />
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className={s.note}>{error ?? (value ? value : t('sources.folder_root'))}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 14px 20px;
|
||||
padding: 20px 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-a);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.location {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface3);
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.st_mounted {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.st_error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.st_pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.st_disabled {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.scan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scanText,
|
||||
.scanSummary {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.scanError {
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--violet));
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
.test {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.testOk {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.testFail {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--line2);
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.dir {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dir:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.dirIcon {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-a);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tileValue {
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.patternRow {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.patternNum {
|
||||
height: 48px;
|
||||
margin-top: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.patternTools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.groups {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.match {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.noMatch {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fromTags {
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user