- Ambient background in the app shell: three drifting blurred blobs,
the third one tinted with the current track's cover; can be turned off
in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
in settings; player source line ("Album · …", "My Wave"), reactions
around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
lead column with an equalizer for the current track, album column,
dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
Play and popular tracks (new GET /artists/{id}/top-tracks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../app/session';
|
|
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
|
import { usePlayer } from '../player/context';
|
|
import { playIcon, waveIcon } from '../shared/ui/icons';
|
|
import { Button } from '../shared/ui/ui';
|
|
import { NotFound } from './NotFound';
|
|
import s from './page.module.css';
|
|
import c from './catalog.module.css';
|
|
|
|
export function ArtistPage({ id }: { id: string }) {
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const player = usePlayer();
|
|
const artist = useQuery({
|
|
queryKey: ['artist', id],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
|
|
});
|
|
const top = useQuery({
|
|
queryKey: ['artist-top', id],
|
|
queryFn: async () =>
|
|
unwrap(await session.client.GET('/api/v1/artists/{id}/top-tracks', { params: { path: { id }, query: { limit: 6 } } })).items,
|
|
});
|
|
const albums = useQuery({
|
|
queryKey: ['artist-albums', id],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
|
});
|
|
|
|
if (artist.isError) return <NotFound error={artist.error} />;
|
|
const a = artist.data;
|
|
const tracks = top.data ?? [];
|
|
const ctx = { type: 'artist' as const, id, label: a?.name };
|
|
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
|
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
|
|
|
return (
|
|
<div className={s.page}>
|
|
<header className={c.hero}>
|
|
<Cover album={{ id }} size={168} round />
|
|
<div className={c.heroBody}>
|
|
<span className={c.kind}>{t('catalog.artist')}</span>
|
|
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
|
{a && (
|
|
<span className={c.heroMeta}>
|
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
|
</span>
|
|
)}
|
|
{a && (
|
|
<div className={c.heroActions}>
|
|
<Button
|
|
variant="primary"
|
|
className={c.pill}
|
|
icon={waveIcon}
|
|
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
|
>
|
|
{t('catalog.artist_wave')}
|
|
</Button>
|
|
{tracks.length > 0 && (
|
|
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
|
{t('catalog.play')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
{tracks.length > 0 && (
|
|
<section className={c.section}>
|
|
<h2>{t('catalog.top_tracks')}</h2>
|
|
<TrackList tracks={tracks} showAlbum context={ctx} lead={(_, i) => String(i + 1)} />
|
|
</section>
|
|
)}
|
|
{own.length > 0 && (
|
|
<section className={c.section}>
|
|
<h2>{t('catalog.albums')}</h2>
|
|
<AlbumGrid albums={own} />
|
|
</section>
|
|
)}
|
|
{appears.length > 0 && (
|
|
<section className={c.section}>
|
|
<h2>{t('catalog.appears_on')}</h2>
|
|
<AlbumGrid albums={appears} />
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|