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