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

+33
View File
@@ -212,6 +212,39 @@ describe('language and settings', () => {
});
});
describe('search box', () => {
it('opens the search page on Enter and an item with the arrows', async () => {
const alice = makeUser('alice', 'user');
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
const box = await screen.findByRole('combobox', { name: 'Поиск' });
await user.type(box, 'rbyj');
const list = await screen.findByRole('listbox');
const options = within(list).getAllByRole('option');
expect(options.map((o) => o.textContent)).toEqual(['КиноИсполнитель', 'Группа кровиКино · 1988']);
expect(screen.getByText('Показаны результаты для «кино»')).toBeInTheDocument();
await user.keyboard('{Enter}');
await waitFor(() => expect(router.state.location.pathname).toBe('/search'));
expect(router.state.location.search).toEqual({ q: 'rbyj' });
await user.clear(box);
await user.type(box, 'кино');
await screen.findByRole('listbox');
await user.keyboard('{ArrowDown}{Enter}');
await waitFor(() => expect(router.state.location.pathname).toBe('/artist/00000000-0000-4000-8000-0000000000aa'));
expect(await screen.findByRole('heading', { name: 'Кино' })).toBeInTheDocument();
});
it('focuses on the slash key', async () => {
const alice = makeUser('alice', 'user');
const { user } = await renderApp([alice], '/', { signedInAs: alice });
const box = await screen.findByRole('combobox', { name: 'Поиск' });
await user.keyboard('/');
expect(box).toHaveFocus();
});
});
describe('safeRedirect', () => {
it.each([
['/settings', '/settings'],
+83 -4
View File
@@ -9,9 +9,15 @@ import {
} from '@tanstack/react-router';
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { HomePage } from '../pages/HomePage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
@@ -96,6 +102,10 @@ const appRoute = createRoute({
component: AppShell,
});
const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role !== 'admin') throw redirect({ to: '/' });
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -112,17 +122,86 @@ const usersRoute = createRoute({
const auditRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/audit',
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin') throw redirect({ to: '/' });
},
beforeLoad: adminOnly,
component: AuditPage,
});
const searchRoute = createRoute({
getParentRoute: () => appRoute,
path: '/search',
validateSearch: (search: Record<string, unknown>): { q: string; type?: Exclude<SearchType, 'all'> } => {
const type = search.type;
return {
q: typeof search.q === 'string' ? search.q : '',
...(type === 'artists' || type === 'albums' || type === 'tracks' ? { type } : {}),
};
},
component: function SearchRoute() {
const { q, type } = searchRoute.useSearch();
return <SearchPage q={q} type={type ?? 'all'} />;
},
});
const artistRoute = createRoute({
getParentRoute: () => appRoute,
path: '/artist/$id',
component: function ArtistRoute() {
const { id } = artistRoute.useParams();
return <ArtistPage key={id} id={id} />;
},
});
const albumRoute = createRoute({
getParentRoute: () => appRoute,
path: '/album/$id',
component: function AlbumRoute() {
const { id } = albumRoute.useParams();
return <AlbumPage key={id} id={id} />;
},
});
const sourcesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources',
beforeLoad: adminOnly,
component: SourcesPage,
});
const patternsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources/$id/patterns',
beforeLoad: adminOnly,
component: function PatternsRoute() {
const { id } = patternsRoute.useParams();
return <PatternsPage id={id} />;
},
});
const libraryRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/library',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: LibraryPage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([homeRoute, settingsRoute, usersRoute, auditRoute]),
appRoute.addChildren([
homeRoute,
settingsRoute,
searchRoute,
artistRoute,
albumRoute,
usersRoute,
auditRoute,
sourcesRoute,
patternsRoute,
libraryRoute,
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
+164
View File
@@ -0,0 +1,164 @@
.cover {
position: relative;
display: inline-grid;
place-items: center;
flex: none;
overflow: hidden;
border-radius: 8px;
color: rgba(255, 255, 255, 0.85);
}
.cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.round {
border-radius: 50%;
}
.link {
color: inherit;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 20px;
}
.card,
.artistChip {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 10px;
margin: -10px;
border-radius: var(--radius-lg);
color: inherit;
text-decoration: none;
}
.card:hover,
.artistChip:hover {
background: var(--hover);
}
.artistChip {
align-items: center;
text-align: center;
width: 128px;
}
.cardCover {
aspect-ratio: 1;
border-radius: 12px;
margin-bottom: 4px;
}
.cardTitle {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.cardMeta {
font-size: 13px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.tracks {
display: flex;
flex-direction: column;
}
.disc {
margin: 14px 12px 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text3);
}
.track {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
}
.track:has(.trackAlbum) {
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
}
.track:hover {
background: var(--hover);
}
.unavailable {
opacity: 0.45;
}
.num {
display: grid;
place-items: center;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.trackMain {
display: flex;
flex-direction: column;
min-width: 0;
}
.trackTitle {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackArtists,
.trackAlbum {
font-size: 13px;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duration {
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px) {
.track:has(.trackAlbum) {
grid-template-columns: 40px minmax(0, 1fr) auto;
}
.trackAlbum {
display: none;
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
import { albumIcon, personIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
export type Track = components['schemas']['Track'];
export type AlbumSummary = components['schemas']['AlbumSummary'];
export type ArtistRef = components['schemas']['ArtistRef'];
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
function fallbackColors(id: string): string[] {
let h = 0;
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const base = h % 360;
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
}
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
export function Cover({
album,
size = 48,
round,
className,
}: {
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
size?: number | string;
round?: boolean;
className?: string;
}) {
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
return (
<span
className={cx(s.cover, round && s.round, className)}
style={{
width: size,
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
height: typeof size === 'number' ? size : undefined,
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
}}
aria-hidden
>
{album.cover_url ? (
<img src={album.cover_url} alt="" loading="lazy" />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
</span>
);
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
return (
<>
{artists.map((a, i) => (
<Fragment key={a.id}>
{i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>
</Fragment>
))}
</>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
</span>
</Link>
);
}
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
return (
<div className={s.grid}>
{albums.map((a) => (
<AlbumCard key={a.id} album={a} />
))}
</div>
);
}
/**
* Список треков. showAlbum — колонка альбома (поиск); иначе номер трека (страница альбома).
* Воспроизведение появится в M3: пока строка только показывает трек.
*/
export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; showAlbum?: boolean; numbered?: boolean }) {
const { t } = useTranslation();
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
lastDisc = disc;
return (
<Fragment key={tr.id}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="row"
className={cx(s.track, !tr.available && s.unavailable)}
title={tr.available ? undefined : t('catalog.unavailable')}
>
<span className={s.num}>{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}</span>
<span className={s.trackMain}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</div>
</Fragment>
);
})}
</div>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation();
return (
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
<Cover album={{ id: artist.id }} size={88} round />
<span className={s.cardTitle}>{artist.name}</span>
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
</Link>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function AlbumPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const album = useQuery({
queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
return (
<div className={s.page}>
<header className={c.hero}>
{colors && (
<span
className={c.heroBg}
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
aria-hidden
/>
)}
<Cover album={a ?? { id }} size={200} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '}
{formatDuration(a.duration_ms)}
</span>
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered />}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, Cover } from '../entities/catalog';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function ArtistPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const artist = useQuery({
queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
});
const albums = useQuery({
queryKey: ['artist-albums', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return (
<div className={s.page}>
<header className={c.hero}>
<Cover album={{ id }} size={168} round />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
)}
</div>
</header>
{own.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.albums')}</h2>
<AlbumGrid albums={own} />
</section>
)}
{appears.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.appears_on')}</h2>
<AlbumGrid albums={appears} />
</section>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiError } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../shared/lib/errors';
import s from './page.module.css';
/** Сущность каталога не найдена (удалена сканом) или не загрузилась. */
export function NotFound({ error }: { error: unknown }) {
const { t } = useTranslation();
const errorText = useErrorText();
const missing = error instanceof ApiError && error.status === 404;
return (
<div className={s.empty} role="alert">
<p>{missing ? t('catalog.not_found') : errorText(error)}</p>
<Link to="/">{t('catalog.back_home')}</Link>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui';
import s from './page.module.css';
import c from './catalog.module.css';
export type SearchType = 'all' | 'artists' | 'albums' | 'tracks';
const TYPES: SearchType[] = ['all', 'artists', 'albums', 'tracks'];
export function SearchPage({ q, type }: { q: string; type: SearchType }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const query = q.trim();
const results = useInfiniteQuery({
queryKey: ['search', query, type],
enabled: query !== '',
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) =>
unwrap(
await session.client.GET('/api/v1/search', {
params: { query: { q: query, type, cursor: pageParam, limit: type === 'all' ? undefined : 40 } },
signal,
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const pages = results.data?.pages ?? [];
const first = pages[0];
const artists = pages.flatMap((p) => p.artists);
const albums = pages.flatMap((p) => p.albums);
const tracks = pages.flatMap((p) => p.tracks);
const empty = results.isSuccess && artists.length + albums.length + tracks.length === 0;
const count = (k: Exclude<SearchType, 'all'>) => first?.totals[k];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('search.title', { query })}</h1>
{first?.corrected_query && (
<p className={s.subtitle}>{t('search.corrected', { query: first.corrected_query })}</p>
)}
</div>
</div>
<div className={s.chips} role="tablist" aria-label={t('search.label')}>
{TYPES.map((k) => (
<Link
key={k}
to="/search"
search={{ q, type: k === 'all' ? undefined : k }}
role="tab"
aria-selected={type === k}
className={c.tab}
>
{t(`search.${k}`)}
{k !== 'all' && count(k) !== undefined && type === 'all' && <span className={c.tabCount}>{count(k)}</span>}
</Link>
))}
</div>
<FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
{artists.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.artists')}</h2>}
<div className={c.artistRow}>
{artists.map((a) => (
<ArtistChip key={a.id} artist={a} />
))}
</div>
</section>
)}
{albums.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.albums')}</h2>}
<AlbumGrid albums={albums} />
</section>
)}
{tracks.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.tracks')}</h2>}
<TrackList tracks={tracks} showAlbum />
</section>
)}
{results.hasNextPage && (
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
{t('search.more')}
</Button>
)}
</div>
);
}
+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;
}
}
+116
View File
@@ -0,0 +1,116 @@
.section {
display: flex;
flex-direction: column;
gap: 14px;
}
.section h2 {
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.artistRow {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.tab[aria-selected='true'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
.tabCount {
font-size: 12px;
color: var(--text3);
}
.tab[aria-selected='true'] .tabCount {
color: inherit;
opacity: 0.6;
}
.hero {
position: relative;
display: flex;
align-items: flex-end;
gap: 28px;
padding: 28px;
margin: -32px -36px 0;
overflow: hidden;
}
.heroBg {
position: absolute;
inset: 0;
opacity: 0.35;
filter: blur(60px);
pointer-events: none;
}
.heroBody {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.kind {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text2);
}
.heroTitle {
margin: 0;
font-family: var(--font-display);
font-size: clamp(28px, 4vw, 48px);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
overflow-wrap: anywhere;
}
.heroMeta {
color: var(--text2);
}
.heroMeta a {
color: var(--text);
font-weight: 600;
text-decoration: none;
}
.heroMeta a:hover {
text-decoration: underline;
}
@media (max-width: 767px) {
.hero {
flex-direction: column;
align-items: flex-start;
margin: -20px -16px 0;
padding: 20px 16px;
}
}
+9
View File
@@ -21,3 +21,12 @@ export function formatRelative(iso: string, locale: string, now: Date = new Date
}
return rtf.format(0, 'minute');
}
/** Длительность трека: 215000 → «3:35», 3725000 → «1:02:05». */
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
+13
View File
@@ -19,3 +19,16 @@ export { default as settingsIcon } from '@material-symbols/svg-400/rounded/setti
export { default as shieldIcon } from '@material-symbols/svg-400/rounded/shield_person.svg';
export { default as visibilityIcon } from '@material-symbols/svg-400/rounded/visibility.svg';
export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/visibility_off.svg';
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
export { default as libraryIcon } from '@material-symbols/svg-400/rounded/monitoring.svg';
export { default as libraryFillIcon } from '@material-symbols/svg-400/rounded/monitoring-fill.svg';
export { default as personIcon } from '@material-symbols/svg-400/rounded/person.svg';
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
+29
View File
@@ -17,6 +17,17 @@ const problem = (status: number, code: string, extra: object = {}) => json({ sta
let seq = 0;
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
const albumSummary = {
id: '00000000-0000-4000-8000-0000000000bb',
title: 'Группа крови',
year: 1988,
cover_url: null,
cover_colors: [],
artist: kino,
track_count: 11,
};
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
seq++;
return {
@@ -146,6 +157,24 @@ export function fakeApi(initial: FakeUser[]) {
}
case 'GET /api/v1/admin/audit':
return json({ items: [], next_cursor: null });
case 'GET /api/v1/search/suggest':
case 'GET /api/v1/search': {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj';
return json({
artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [],
tracks: [],
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null,
});
}
case `GET /api/v1/artists/${kino.id}`:
return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
return problem(404, 'common.not_found');
};
+9
View File
@@ -13,6 +13,7 @@
grid-area: top;
display: flex;
align-items: center;
gap: 24px;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--line);
@@ -21,9 +22,17 @@
}
.logoLink {
width: 196px;
flex: none;
text-decoration: none;
}
@media (max-width: 1023px) {
.logoLink {
width: auto;
}
}
.topRight {
display: flex;
align-items: center;
+11 -1
View File
@@ -9,15 +9,20 @@ import {
groupIcon,
homeFillIcon,
homeIcon,
libraryFillIcon,
libraryIcon,
logoutIcon,
musicIcon,
settingsIcon,
storageFillIcon,
storageIcon,
} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
type NavPath = '/' | '/admin/users' | '/admin/audit';
type NavPath = '/' | '/admin/users' | '/admin/audit' | '/admin/sources' | '/admin/library';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
return (
@@ -51,6 +56,7 @@ function Shell() {
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<SearchBox />
<div className={s.topRight}>
<Menu
label={t('profile.menu')}
@@ -82,6 +88,10 @@ function Shell() {
<>
<div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
)}
+146
View File
@@ -0,0 +1,146 @@
.box {
position: relative;
flex: 1;
max-width: 520px;
}
.icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
pointer-events: none;
}
.input {
width: 100%;
height: 40px;
padding: 0 40px 0 42px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::-webkit-search-cancel-button {
display: none;
}
.kbd {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
min-width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--line2);
color: var(--text3);
font: 12px var(--font-mono);
pointer-events: none;
}
.input:focus ~ .kbd {
display: none;
}
.popover {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 45;
max-height: min(70vh, 560px);
overflow-y: auto;
padding: 8px;
border-radius: var(--radius-lg);
background: var(--elev);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.15s ease-out;
}
.corrected {
margin: 4px 10px 8px;
font-size: 13px;
color: var(--warn);
}
.groupTitle {
margin: 8px 10px 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.option {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-radius: 10px;
cursor: pointer;
}
.active {
background: var(--hover);
}
.optionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.optionTitle {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionSub {
font-size: 12px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nothing {
padding: 16px 10px;
color: var(--text3);
text-align: center;
}
.all {
width: 100%;
height: 38px;
margin-top: 6px;
border-radius: 10px;
color: var(--accent2);
font-weight: 600;
font-size: 14px;
}
.all:hover {
background: var(--hover);
}
@media (max-width: 767px) {
.kbd {
display: none;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { unwrap } from '@pipu/core';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { searchIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './SearchBox.module.css';
const DEBOUNCE_MS = 150;
type Item = {
key: string;
group: 'artists' | 'albums' | 'tracks';
title: string;
subtitle: string;
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
round?: boolean;
go: () => void;
};
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
export function SearchBox() {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
return () => clearTimeout(id);
}, [q]);
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const suggest = useQuery({
queryKey: ['suggest', debounced],
enabled: debounced !== '',
placeholderData: keepPreviousData,
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
queryFn: async ({ signal }) =>
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
staleTime: 30_000,
});
const data = debounced ? suggest.data : undefined;
const close = () => {
setOpen(false);
setActive(-1);
};
const goSearch = () => {
const query = q.trim();
if (!query) return;
close();
inputRef.current?.blur();
void navigate({ to: '/search', search: { q: query } });
};
const items: Item[] = [];
if (data) {
for (const a of data.artists)
items.push({
key: `ar-${a.id}`,
group: 'artists',
title: a.name,
subtitle: t('search.artist_kind'),
cover: { id: a.id },
round: true,
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
});
for (const al of data.albums)
items.push({
key: `al-${al.id}`,
group: 'albums',
title: al.title,
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
cover: al,
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
});
for (const tr of data.tracks)
items.push({
key: `tr-${tr.id}`,
group: 'tracks',
title: tr.title,
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
cover: tr.album,
// Воспроизведение — M3; пока трек открывает свой альбом.
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
});
}
const choose = (i: number) => {
const it = items[i];
if (!it) return goSearch();
close();
setQ('');
inputRef.current?.blur();
it.go();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActive((i) => Math.min(items.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(-1, i - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
choose(active);
} else if (e.key === 'Escape') {
close();
inputRef.current?.blur();
}
};
const showList = open && debounced !== '' && data !== undefined;
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
const optionId = (i: number) => `${listId}-${i}`;
return (
<div className={s.box}>
<Icon src={searchIcon} size={18} className={s.icon} />
<input
ref={inputRef}
type="search"
className={s.input}
value={q}
placeholder={t('search.placeholder')}
aria-label={t('search.label')}
role="combobox"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(close, 120)}
onKeyDown={onKeyDown}
/>
<kbd className={s.kbd} aria-hidden>
/
</kbd>
{showList && (
<div className={s.popover}>
{data.corrected_query && (
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
)}
<div id={listId} role="listbox" aria-label={t('search.label')}>
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
{groups.map((g) => {
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
if (groupItems.length === 0) return null;
return (
<div key={g} role="group" aria-label={t(`search.${g}`)}>
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
{groupItems.map(({ it, i }) => (
<div
key={it.key}
id={optionId(i)}
role="option"
aria-selected={i === active}
className={cx(s.option, i === active && s.active)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)}
>
<Cover album={it.cover} size={36} round={it.round} />
<span className={s.optionText}>
<span className={s.optionTitle}>{it.title}</span>
<span className={s.optionSub}>{it.subtitle}</span>
</span>
</div>
))}
</div>
);
})}
</div>
<button
type="button"
className={s.all}
onMouseDown={(e) => e.preventDefault()}
onClick={goSearch}
>
{t('search.show_all')}
</button>
</div>
)}
</div>
);
}
+87
View File
@@ -62,5 +62,92 @@
"user.password_reset": "Password reset",
"user.delete": "User deleted"
}
},
"sources": {
"title": "Storage",
"subtitle": "Where the server takes music from: a folder on the server or NFS",
"add": "Add storage",
"empty": "No storage yet. Connect a music folder to get started.",
"type_local": "Server folder",
"type_nfs": "NFS",
"status_pending": "Connecting",
"status_mounted": "Connected",
"status_error": "Error",
"status_disabled": "Disabled",
"last_scan": "Last scan: {{date}}",
"never_scanned": "Not scanned yet",
"scan": "Scan",
"full_scan": "Full scan",
"test": "Test",
"patterns": "Path patterns",
"edit": "Edit",
"delete": "Delete",
"disable": "Disable",
"enable": "Enable",
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
"scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…",
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
"scan_failed": "Scan stopped: {{error}}",
"test_ok": "Reachable, audio files found: {{count}}",
"test_fail": "Not reachable: {{message}}",
"form_title_new": "New storage",
"form_title_edit": "Storage",
"name": "Name",
"type": "Type",
"folder": "Folder",
"folder_root": "Whole folder",
"folder_hint": "A subfolder of LIBRARY_HOST on the server",
"up": "Up",
"nfs_server": "Server",
"nfs_server_hint": "Host name or IP, e.g. nas.lan",
"nfs_export": "Export",
"nfs_export_hint": "Path on the server, e.g. /export/music",
"nfs_options": "Mount options",
"nfs_options_hint": "Read-only is added automatically",
"tag_priority": "Prefer",
"tags_first": "File tags",
"path_first": "Path patterns",
"interval": "Auto scan",
"interval_off": "Manual",
"interval_hours": "Every {{count}} h",
"save": "Save"
},
"patterns": {
"title": "Path patterns: {{name}}",
"intro": "RE2 regular expressions with named groups are matched against the file path relative to the storage root. The first match wins.",
"groups": "Groups: artist, albumartist, album, year, disc, track, title, genre; skip ignores the file.",
"add": "Add pattern",
"regex": "Pattern {{n}}",
"enabled": "Enabled",
"up": "Up",
"down": "Down",
"remove": "Remove",
"preview": "Preview",
"save": "Save",
"saved": "Saved. Run a full scan to apply it to files already found.",
"col_path": "Path",
"col_pattern": "Pattern",
"col_result": "Result",
"no_match": "no match",
"skipped": "skipped",
"unmatched": "No pattern matched: {{count}} of {{total}}",
"from_tags": "from tags",
"error_at": "Error in pattern {{n}}: {{reason}}",
"examples": "Examples"
},
"library": {
"title": "Library",
"subtitle": "Catalog and scan status",
"artists": "Artists",
"albums": "Albums",
"tracks": "Tracks",
"files": "Files",
"missing": "Missing files",
"errors": "Unreadable",
"sources": "Storage",
"error_files": "Files with errors",
"reindex": "Rebuild search index",
"reindex_started": "Rebuild started"
}
}
+39 -1
View File
@@ -23,7 +23,9 @@
"home": "Home",
"admin": "Admin",
"users": "Users",
"audit": "Audit log"
"audit": "Audit log",
"library": "Library",
"sources": "Storage"
},
"profile": {
"menu": "Profile menu",
@@ -79,5 +81,41 @@
"loading": "Loading…",
"route_error": {
"reload": "Reload the page"
},
"search": {
"placeholder": "Artists, albums, tracks",
"label": "Search",
"artists": "Artists",
"albums": "Albums",
"tracks": "Tracks",
"all": "All",
"nothing": "Nothing found",
"corrected": "Showing results for “{{query}}”",
"show_all": "All results",
"title": "Search: {{query}}",
"more": "Show more",
"artist_kind": "Artist",
"hint": "Press / to search"
},
"catalog": {
"albums": "Albums",
"appears_on": "Appears on",
"album_count_one": "{{count}} album",
"album_count_other": "{{count}} albums",
"album_count_few": "{{count}} albums",
"album_count_many": "{{count}} albums",
"track_count_one": "{{count}} track",
"track_count_other": "{{count}} tracks",
"track_count_few": "{{count}} tracks",
"track_count_many": "{{count}} tracks",
"unavailable": "Unavailable: storage is offline or the file is gone",
"not_found": "Nothing here — it may have been removed by the last scan",
"disc": "Disc {{n}}",
"col_title": "Title",
"col_album": "Album",
"col_duration": "Time",
"back_home": "Back home",
"artist": "Artist",
"album": "Album"
}
}
+19 -1
View File
@@ -38,10 +38,28 @@
"ui_prefs_invalid": "Invalid setting value",
"client_type_invalid": "Unknown app type",
"role_invalid": "Unknown role",
"cursor_invalid": "The list is outdated, please reload"
"cursor_invalid": "The list is outdated, please reload",
"pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found",
"name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type",
"query_empty": "Enter a query"
},
"users": {
"not_found": "User not found",
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
},
"library": {
"source_not_found": "Storage not found",
"scan_running": "A scan is already running",
"source_offline": "The storage is not connected"
},
"catalog": {
"not_found": "Not found"
},
"search": {
"unavailable": "Search is temporarily unavailable"
}
}
+87
View File
@@ -62,5 +62,92 @@
"user.password_reset": "Сброшен пароль",
"user.delete": "Пользователь удалён"
}
},
"sources": {
"title": "Хранилища",
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
"add": "Подключить хранилище",
"empty": "Хранилищ пока нет. Подключите папку с музыкой, чтобы начать.",
"type_local": "Папка на сервере",
"type_nfs": "NFS",
"status_pending": "Подключается",
"status_mounted": "Подключено",
"status_error": "Ошибка",
"status_disabled": "Отключено",
"last_scan": "Последний скан: {{date}}",
"never_scanned": "Ещё не сканировалось",
"scan": "Сканировать",
"full_scan": "Полный скан",
"test": "Проверить",
"patterns": "Шаблоны путей",
"edit": "Изменить",
"delete": "Удалить",
"disable": "Отключить",
"enable": "Подключить",
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…",
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
"scan_failed": "Скан прервался: {{error}}",
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
"test_fail": "Нет доступа: {{message}}",
"form_title_new": "Новое хранилище",
"form_title_edit": "Хранилище",
"name": "Название",
"type": "Тип",
"folder": "Папка",
"folder_root": "Весь каталог",
"folder_hint": "Подпапка каталога LIBRARY_HOST на сервере",
"up": "Вверх",
"nfs_server": "Сервер",
"nfs_server_hint": "Имя или IP, например nas.lan",
"nfs_export": "Экспорт",
"nfs_export_hint": "Путь на сервере, например /export/music",
"nfs_options": "Опции монтирования",
"nfs_options_hint": "Только чтение добавляется автоматически",
"tag_priority": "Что важнее",
"tags_first": "Теги файлов",
"path_first": "Шаблоны путей",
"interval": "Автоскан",
"interval_off": "Вручную",
"interval_hours": "Каждые {{count}} ч",
"save": "Сохранить"
},
"patterns": {
"title": "Шаблоны путей: {{name}}",
"intro": "Регулярные выражения RE2 с именованными группами применяются к пути файла относительно корня хранилища. Побеждает первое совпавшее.",
"groups": "Группы: artist, albumartist, album, year, disc, track, title, genre; skip — игнорировать файл.",
"add": "Добавить шаблон",
"regex": "Шаблон {{n}}",
"enabled": "Включён",
"up": "Выше",
"down": "Ниже",
"remove": "Удалить",
"preview": "Предпросмотр",
"save": "Сохранить",
"saved": "Сохранено. Запустите полный скан, чтобы применить к уже найденным файлам.",
"col_path": "Путь",
"col_pattern": "Шаблон",
"col_result": "Результат",
"no_match": "не совпал",
"skipped": "пропускается",
"unmatched": "Не совпал ни один шаблон: {{count}} из {{total}}",
"from_tags": "из тегов",
"error_at": "Ошибка в шаблоне {{n}}: {{reason}}",
"examples": "Примеры"
},
"library": {
"title": "Библиотека",
"subtitle": "Состояние каталога и сканирования",
"artists": "Исполнителей",
"albums": "Альбомов",
"tracks": "Треков",
"files": "Файлов",
"missing": "Пропавших файлов",
"errors": "Не прочитано",
"sources": "Хранилища",
"error_files": "Файлы с ошибками",
"reindex": "Переиндексировать поиск",
"reindex_started": "Переиндексация запущена"
}
}
+39 -1
View File
@@ -23,7 +23,9 @@
"home": "Главная",
"admin": "Админка",
"users": "Пользователи",
"audit": "Журнал"
"audit": "Журнал",
"library": "Библиотека",
"sources": "Хранилища"
},
"profile": {
"menu": "Меню профиля",
@@ -79,5 +81,41 @@
"loading": "Загрузка…",
"route_error": {
"reload": "Обновить страницу"
},
"search": {
"placeholder": "Исполнители, альбомы, треки",
"label": "Поиск",
"artists": "Исполнители",
"albums": "Альбомы",
"tracks": "Треки",
"all": "Всё",
"nothing": "Ничего не нашли",
"corrected": "Показаны результаты для «{{query}}»",
"show_all": "Все результаты",
"title": "Поиск: {{query}}",
"more": "Показать ещё",
"artist_kind": "Исполнитель",
"hint": "Нажмите / для поиска"
},
"catalog": {
"albums": "Альбомы",
"appears_on": "Встречается на",
"album_count_one": "{{count}} альбом",
"album_count_few": "{{count}} альбома",
"album_count_many": "{{count}} альбомов",
"album_count_other": "{{count}} альбома",
"track_count_one": "{{count}} трек",
"track_count_few": "{{count}} трека",
"track_count_many": "{{count}} треков",
"track_count_other": "{{count}} трека",
"unavailable": "Недоступен: хранилище отключено или файл пропал",
"not_found": "Ничего не найдено — возможно, удалено при последнем скане",
"disc": "Диск {{n}}",
"col_title": "Название",
"col_album": "Альбом",
"col_duration": "Время",
"back_home": "На главную",
"artist": "Исполнитель",
"album": "Альбом"
}
}
+19 -1
View File
@@ -38,10 +38,28 @@
"ui_prefs_invalid": "Недопустимое значение настройки",
"client_type_invalid": "Неизвестный тип приложения",
"role_invalid": "Неизвестная роль",
"cursor_invalid": "Список устарел, обновите страницу"
"cursor_invalid": "Список устарел, обновите страницу",
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена",
"name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища",
"query_empty": "Введите запрос"
},
"users": {
"not_found": "Пользователь не найден",
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
},
"library": {
"source_not_found": "Хранилище не найдено",
"scan_running": "Скан уже идёт",
"source_offline": "Хранилище не подключено"
},
"catalog": {
"not_found": "Не найдено"
},
"search": {
"unavailable": "Поиск временно недоступен"
}
}