feat(web): storage admin, search and catalog pages (M2)
ci / ts / ts (packages + web) (push) Successful in 1m56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m59s

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:01:52 +03:00
1 parent c1160cccd3
commit a685b1b7ca
27 files changed
+2505 -9

No files matched your search

+94
View File
@@ -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>
);
}
+215
View File
@@ -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>
</>
);
}
+44
View File
@@ -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>
);
}
+352
View File
@@ -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>
);
}
+301
View File
@@ -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;
}
}