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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user