release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
108 lines
3.8 KiB
TypeScript
108 lines
3.8 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 { searchIcon } from '../shared/ui/icons';
|
|
import { Button, EmptyState, FormError, PageSkeleton } 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>
|
|
{query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
|
|
{query !== '' && results.isPending && <PageSkeleton />}
|
|
{empty && (
|
|
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|