Files
pipu-music/apps/web/src/pages/SearchPage.tsx
T
mrleo1nidandClaude Opus 5.5 976ffb574b
ci / ts / ts (packages + web) (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 2m33s
feat(web): player bar, playable track lists and Collection (M3)
- 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>
2026-10-20 12:11:12 +03:00

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>
);
}