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

- Storage admin: local folders with a folder picker and NFS sources,
  connection test, scan and full scan with live progress, enable,
  disable, delete
- path pattern editor with reorder, examples and a preview table that
  shows which pattern matched and what came from tags; compile errors
  land under the broken pattern
- Library page with counters, scans and unreadable files; search
  reindex for admins
- search box in the top bar: combobox with keyboard navigation, 150 ms
  debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint
- search page with tabs and paging, artist and album pages, album
  cards and track lists; unavailable tracks are dimmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:01:52 +03:00
1 parent c1160cccd3
commit a685b1b7ca
27 files changed
+2505 -9

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui';
import s from './page.module.css';
import c from './catalog.module.css';
export type SearchType = 'all' | 'artists' | 'albums' | 'tracks';
const TYPES: SearchType[] = ['all', 'artists', 'albums', 'tracks'];
export function SearchPage({ q, type }: { q: string; type: SearchType }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const query = q.trim();
const results = useInfiniteQuery({
queryKey: ['search', query, type],
enabled: query !== '',
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) =>
unwrap(
await session.client.GET('/api/v1/search', {
params: { query: { q: query, type, cursor: pageParam, limit: type === 'all' ? undefined : 40 } },
signal,
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const pages = results.data?.pages ?? [];
const first = pages[0];
const artists = pages.flatMap((p) => p.artists);
const albums = pages.flatMap((p) => p.albums);
const tracks = pages.flatMap((p) => p.tracks);
const empty = results.isSuccess && artists.length + albums.length + tracks.length === 0;
const count = (k: Exclude<SearchType, 'all'>) => first?.totals[k];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('search.title', { query })}</h1>
{first?.corrected_query && (
<p className={s.subtitle}>{t('search.corrected', { query: first.corrected_query })}</p>
)}
</div>
</div>
<div className={s.chips} role="tablist" aria-label={t('search.label')}>
{TYPES.map((k) => (
<Link
key={k}
to="/search"
search={{ q, type: k === 'all' ? undefined : k }}
role="tab"
aria-selected={type === k}
className={c.tab}
>
{t(`search.${k}`)}
{k !== 'all' && count(k) !== undefined && type === 'all' && <span className={c.tabCount}>{count(k)}</span>}
</Link>
))}
</div>
<FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
{artists.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.artists')}</h2>}
<div className={c.artistRow}>
{artists.map((a) => (
<ArtistChip key={a.id} artist={a} />
))}
</div>
</section>
)}
{albums.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.albums')}</h2>}
<AlbumGrid albums={albums} />
</section>
)}
{tracks.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.tracks')}</h2>}
<TrackList tracks={tracks} showAlbum />
</section>
)}
{results.hasNextPage && (
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
{t('search.more')}
</Button>
)}
</div>
);
}