- player bar with cover, like and dislike, previous / play / next, shuffle, repeat, seek bar and volume; PlayerProvider lives with the signed-in shell and restores the last track and position paused - clicking a row (or Enter) plays the list from that track with its context (album, search, history, collection); the current track is highlighted and rows have a like button; a reaction refreshes lists - Collection: history grouped by day with clearing, likes with "play all" and dislikes, all with paging; sidebar section - events and playback state are flushed when the page is hidden; player timers survive StrictMode remounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
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 context={{ type: 'search', id: query, label: query }} />
|
|
</section>
|
|
)}
|
|
{results.hasNextPage && (
|
|
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
|
|
{t('search.more')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|