From a685b1b7ca5db983b1b881985f0f390d6e0caca1 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:52 +0300 Subject: [PATCH] feat(web): storage admin, search and catalog pages (M2) - Storage admin: local folders with a folder picker and NFS sources, connection test, scan and full scan with live progress, enable, disable, delete - path pattern editor with reorder, examples and a preview table that shows which pattern matched and what came from tags; compile errors land under the broken pattern - Library page with counters, scans and unreadable files; search reindex for admins - search box in the top bar: combobox with keyboard navigation, 150 ms debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint - search page with tabs and paging, artist and album pages, album cards and track lists; unavailable tracks are dimmed Co-Authored-By: Claude Opus 5.5 --- apps/web/src/App.test.tsx | 33 ++ apps/web/src/app/router.tsx | 87 ++++- apps/web/src/entities/catalog.module.css | 164 +++++++++ apps/web/src/entities/catalog.tsx | 148 ++++++++ apps/web/src/pages/AlbumPage.tsx | 53 +++ apps/web/src/pages/ArtistPage.tsx | 57 ++++ apps/web/src/pages/NotFound.tsx | 19 ++ apps/web/src/pages/SearchPage.tsx | 102 ++++++ apps/web/src/pages/admin/LibraryPage.tsx | 94 ++++++ apps/web/src/pages/admin/PatternsPage.tsx | 215 ++++++++++++ apps/web/src/pages/admin/ScanStatus.tsx | 44 +++ apps/web/src/pages/admin/SourcesPage.tsx | 352 ++++++++++++++++++++ apps/web/src/pages/admin/library.module.css | 301 +++++++++++++++++ apps/web/src/pages/catalog.module.css | 116 +++++++ apps/web/src/shared/lib/format.ts | 9 + apps/web/src/shared/ui/icons.ts | 13 + apps/web/src/test/fakeApi.ts | 29 ++ apps/web/src/widgets/AppShell.module.css | 9 + apps/web/src/widgets/AppShell.tsx | 12 +- apps/web/src/widgets/SearchBox.module.css | 146 ++++++++ apps/web/src/widgets/SearchBox.tsx | 217 ++++++++++++ packages/i18n/locales/en/admin.json | 87 +++++ packages/i18n/locales/en/common.json | 40 ++- packages/i18n/locales/en/errors.json | 20 +- packages/i18n/locales/ru/admin.json | 87 +++++ packages/i18n/locales/ru/common.json | 40 ++- packages/i18n/locales/ru/errors.json | 20 +- 27 files changed, 2505 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/entities/catalog.module.css create mode 100644 apps/web/src/entities/catalog.tsx create mode 100644 apps/web/src/pages/AlbumPage.tsx create mode 100644 apps/web/src/pages/ArtistPage.tsx create mode 100644 apps/web/src/pages/NotFound.tsx create mode 100644 apps/web/src/pages/SearchPage.tsx create mode 100644 apps/web/src/pages/admin/LibraryPage.tsx create mode 100644 apps/web/src/pages/admin/PatternsPage.tsx create mode 100644 apps/web/src/pages/admin/ScanStatus.tsx create mode 100644 apps/web/src/pages/admin/SourcesPage.tsx create mode 100644 apps/web/src/pages/admin/library.module.css create mode 100644 apps/web/src/pages/catalog.module.css create mode 100644 apps/web/src/widgets/SearchBox.module.css create mode 100644 apps/web/src/widgets/SearchBox.tsx diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index c09e56e..7fe0f97 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -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'], diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 709a33f..d523675 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -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): { q: string; type?: Exclude } => { + 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 ; + }, +}); + +const artistRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/artist/$id', + component: function ArtistRoute() { + const { id } = artistRoute.useParams(); + return ; + }, +}); + +const albumRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/album/$id', + component: function AlbumRoute() { + const { id } = albumRoute.useParams(); + return ; + }, +}); + +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 ; + }, +}); + +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[0]['history'] } = {}) { diff --git a/apps/web/src/entities/catalog.module.css b/apps/web/src/entities/catalog.module.css new file mode 100644 index 0000000..03cb3ac --- /dev/null +++ b/apps/web/src/entities/catalog.module.css @@ -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; + } +} diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx new file mode 100644 index 0000000..ba11ede --- /dev/null +++ b/apps/web/src/entities/catalog.tsx @@ -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 ( + + {album.cover_url ? ( + + ) : ( + + )} + + ); +} + +/** Исполнители через запятую, каждый — ссылка. */ +export function ArtistLinks({ artists }: { artists: ArtistRef[] }) { + return ( + <> + {artists.map((a, i) => ( + + {i > 0 && ', '} + + {a.name} + + + ))} + + ); +} + +export function AlbumCard({ album }: { album: AlbumSummary }) { + return ( + + + {album.title} + + {[album.artist.name, album.year].filter(Boolean).join(' · ')} + + + ); +} + +export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) { + return ( +
+ {albums.map((a) => ( + + ))} +
+ ); +} + +/** + * Список треков. 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 ( +
+ {tracks.map((tr, i) => { + const disc = tr.disc_no ?? 1; + const header = numbered && discs.size > 1 && disc !== lastDisc; + lastDisc = disc; + return ( + + {header &&
{t('catalog.disc', { n: disc })}
} +
+ {numbered ? (tr.track_no ?? i + 1) : } + + {tr.title} + + + + + {showAlbum && ( + + {tr.album.title} + + )} + {formatDuration(tr.duration_ms)} +
+
+ ); + })} +
+ ); +} + +export function ArtistChip({ artist }: { artist: ArtistRef }) { + const { t } = useTranslation(); + return ( + + + {artist.name} + {t('search.artist_kind')} + + ); +} diff --git a/apps/web/src/pages/AlbumPage.tsx b/apps/web/src/pages/AlbumPage.tsx new file mode 100644 index 0000000..7c92e76 --- /dev/null +++ b/apps/web/src/pages/AlbumPage.tsx @@ -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 ; + const a = album.data; + const colors = a?.cover_colors.length === 3 ? a.cover_colors : null; + + return ( +
+
+ {colors && ( + + )} + +
+ {t('catalog.album')} +

{a?.title ?? '…'}

+ {a && ( + + + {a.artist.name} + + {a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '} + {formatDuration(a.duration_ms)} + + )} +
+
+ {a && } +
+ ); +} diff --git a/apps/web/src/pages/ArtistPage.tsx b/apps/web/src/pages/ArtistPage.tsx new file mode 100644 index 0000000..8150a8d --- /dev/null +++ b/apps/web/src/pages/ArtistPage.tsx @@ -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 ; + 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 ( +
+
+ +
+ {t('catalog.artist')} +

{a?.name ?? '…'}

+ {a && ( + + {t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })} + + )} +
+
+ {own.length > 0 && ( +
+

{t('catalog.albums')}

+ +
+ )} + {appears.length > 0 && ( +
+

{t('catalog.appears_on')}

+ +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/NotFound.tsx b/apps/web/src/pages/NotFound.tsx new file mode 100644 index 0000000..dbfbf26 --- /dev/null +++ b/apps/web/src/pages/NotFound.tsx @@ -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 ( +
+

{missing ? t('catalog.not_found') : errorText(error)}

+ {t('catalog.back_home')} +
+ ); +} diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx new file mode 100644 index 0000000..6a93e6f --- /dev/null +++ b/apps/web/src/pages/SearchPage.tsx @@ -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) => first?.totals[k]; + + return ( +
+
+
+

{t('search.title', { query })}

+ {first?.corrected_query && ( +

{t('search.corrected', { query: first.corrected_query })}

+ )} +
+
+ +
+ {TYPES.map((k) => ( + + {t(`search.${k}`)} + {k !== 'all' && count(k) !== undefined && type === 'all' && {count(k)}} + + ))} +
+ + {results.error ? errorText(results.error) : null} + {empty &&
{t('search.nothing')}
} + + {artists.length > 0 && ( +
+ {type === 'all' &&

{t('search.artists')}

} +
+ {artists.map((a) => ( + + ))} +
+
+ )} + {albums.length > 0 && ( +
+ {type === 'all' &&

{t('search.albums')}

} + +
+ )} + {tracks.length > 0 && ( +
+ {type === 'all' &&

{t('search.tracks')}

} + +
+ )} + {results.hasNextPage && ( + + )} +
+ ); +} diff --git a/apps/web/src/pages/admin/LibraryPage.tsx b/apps/web/src/pages/admin/LibraryPage.tsx new file mode 100644 index 0000000..4f1a0b3 --- /dev/null +++ b/apps/web/src/pages/admin/LibraryPage.tsx @@ -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 ( +
+
+
+

{t('library.title')}

+

{t('library.subtitle')}

+
+ {admin && ( + + )} +
+ {status.error || reindex.error ? errorText(status.error ?? reindex.error) : null} +
+ {tiles.map(([k, v]) => ( +
+ {v.toLocaleString()} + {t(`library.${k}`)} +
+ ))} +
+ {d && d.sources.length > 0 && ( +
+

{t('library.sources')}

+ {d.sources.map((src) => ( +
+
+ {src.name} +
+ +
+ ))} +
+ )} + {admin && errors.data && errors.data.items.length > 0 && ( +
+

{t('library.error_files')}

+ {errors.data.items.map((e) => ( +
+ {e.rel_path} + {e.error} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/admin/PatternsPage.tsx b/apps/web/src/pages/admin/PatternsPage.tsx new file mode 100644 index 0000000..558d4a0 --- /dev/null +++ b/apps/web/src/pages/admin/PatternsPage.tsx @@ -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[^/]+)/(?P\d{4}) - (?P[^/]+)/(?:(?P\d)-)?(?P\d{1,3}) - (?P[^/]+)\.\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> + </> + ); +} diff --git a/apps/web/src/pages/admin/ScanStatus.tsx b/apps/web/src/pages/admin/ScanStatus.tsx new file mode 100644 index 0000000..9401c41 --- /dev/null +++ b/apps/web/src/pages/admin/ScanStatus.tsx @@ -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> + ); +} diff --git a/apps/web/src/pages/admin/SourcesPage.tsx b/apps/web/src/pages/admin/SourcesPage.tsx new file mode 100644 index 0000000..be71f2b --- /dev/null +++ b/apps/web/src/pages/admin/SourcesPage.tsx @@ -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> + ); +} diff --git a/apps/web/src/pages/admin/library.module.css b/apps/web/src/pages/admin/library.module.css new file mode 100644 index 0000000..86b03b6 --- /dev/null +++ b/apps/web/src/pages/admin/library.module.css @@ -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; + } +} diff --git a/apps/web/src/pages/catalog.module.css b/apps/web/src/pages/catalog.module.css new file mode 100644 index 0000000..c0e56ff --- /dev/null +++ b/apps/web/src/pages/catalog.module.css @@ -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; + } +} diff --git a/apps/web/src/shared/lib/format.ts b/apps/web/src/shared/lib/format.ts index ca89326..d10cb32 100644 --- a/apps/web/src/shared/lib/format.ts +++ b/apps/web/src/shared/lib/format.ts @@ -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}`; +} diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts index ed24c25..4e6d9db 100644 --- a/apps/web/src/shared/ui/icons.ts +++ b/apps/web/src/shared/ui/icons.ts @@ -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'; diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index e6d5227..cfd9eed 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -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'); }; diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index 5ba115d..0aea899 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -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; diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 521207e..667e135 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -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} /> )} diff --git a/apps/web/src/widgets/SearchBox.module.css b/apps/web/src/widgets/SearchBox.module.css new file mode 100644 index 0000000..1bece3a --- /dev/null +++ b/apps/web/src/widgets/SearchBox.module.css @@ -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; + } +} diff --git a/apps/web/src/widgets/SearchBox.tsx b/apps/web/src/widgets/SearchBox.tsx new file mode 100644 index 0000000..a7bfde4 --- /dev/null +++ b/apps/web/src/widgets/SearchBox.tsx @@ -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> + ); +} diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 322cf41..795b61a 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -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" } } diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index ae6d587..5cd4ff6 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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" } } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index d6945e7..d62d2f4 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -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" } } diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 2464bf1..7f16396 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -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": "Переиндексация запущена" } } diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 5043489..210cb00 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -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": "Альбом" } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 27ff09f..3a34eda 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -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": "Поиск временно недоступен" } }