feat(artist): functional Artist detail screen (§A3)
Replace the scaffold Placeholder with a real artist page wired to the
existing `/artists/{id}` (+ `/albums`, `/tracks`) endpoints: header with
procedural avatar / counts / "Play all" (queues with source=artist),
discography grid (cards → album detail), and the full track list. All
three list states per async section.
Also make the Library artists-tab rows clickable → `/artists/:id`,
closing the previous navigation dead-end.
i18n: new `artist.*` block (en + ru).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,270 @@
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { ScrollArea, IconButton, Button, Card } from '@olly/modern-sk';
|
||||
import {
|
||||
useGetArtistQuery,
|
||||
useGetArtistAlbumsQuery,
|
||||
useGetArtistTracksQuery,
|
||||
} from '../../api/endpoints/library';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
import { ArtTile } from '../../components/common/ArtTile';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { setQueue } from '../../store/slices/queue';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||
import type { Album } from '../../api/types';
|
||||
|
||||
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
|
||||
export function ArtistDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.artist')} />;
|
||||
const { artistId } = useParams<{ artistId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const artistQuery = useGetArtistQuery(artistId ?? '', { skip: !artistId });
|
||||
const albumsQuery = useGetArtistAlbumsQuery(artistId ?? '', {
|
||||
skip: !artistId,
|
||||
});
|
||||
const tracksQuery = useGetArtistTracksQuery(artistId ?? '', {
|
||||
skip: !artistId,
|
||||
});
|
||||
|
||||
if (artistQuery.isLoading) {
|
||||
return (
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<LoadingSkeleton rows={10} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (artistQuery.isError || !artistQuery.data) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={t('artist.error')}
|
||||
onRetry={() => artistQuery.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const artist = artistQuery.data;
|
||||
const albums = albumsQuery.data ?? [];
|
||||
const tracks = tracksQuery.data ?? [];
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!tracks.length) return;
|
||||
dispatch(
|
||||
setQueue({
|
||||
entries: tracks.map((tr) => ({
|
||||
trackId: tr.id,
|
||||
title: tr.title,
|
||||
artistName: tr.artistName,
|
||||
albumTitle: tr.albumTitle,
|
||||
durationMs: tr.durationMs,
|
||||
albumArtUrl: tr.albumArtUrl,
|
||||
})),
|
||||
source: 'artist',
|
||||
sourceId: artist.id,
|
||||
sourceName: artist.name,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label={t('common.back')}
|
||||
>
|
||||
←
|
||||
</IconButton>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<ArtTile seed={artist.id} label={artist.name} size={96} radius={48} />
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{t('artist.type')}
|
||||
</p>
|
||||
<h1
|
||||
style={{
|
||||
margin: '0.25rem 0',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{artist.name}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('artist.meta', {
|
||||
albumCount: artist.albumCount,
|
||||
trackCount: artist.trackCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePlayAll}
|
||||
disabled={!tracks.length}
|
||||
>
|
||||
{t('artist.play')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{/* Discography */}
|
||||
<section style={{ padding: '1.25rem 1.5rem 0' }}>
|
||||
<h2
|
||||
style={{ margin: '0 0 0.75rem', fontSize: '1rem', fontWeight: 600 }}
|
||||
>
|
||||
{t('artist.albums')}
|
||||
</h2>
|
||||
{albumsQuery.isLoading && <LoadingSkeleton rows={3} height={72} />}
|
||||
{albumsQuery.isError && (
|
||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||
)}
|
||||
{!albumsQuery.isLoading &&
|
||||
!albumsQuery.isError &&
|
||||
albums.length === 0 && (
|
||||
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
||||
{t('artist.noAlbums')}
|
||||
</p>
|
||||
)}
|
||||
{albums.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(9rem, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{albums.map((album) => (
|
||||
<AlbumCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* All tracks */}
|
||||
<section style={{ padding: '1.5rem 0 0.5rem' }}>
|
||||
<h2
|
||||
style={{
|
||||
margin: '0 1.5rem 0.5rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('artist.tracks')}
|
||||
</h2>
|
||||
{tracksQuery.isLoading && <LoadingSkeleton rows={6} />}
|
||||
{tracksQuery.isError && (
|
||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||
)}
|
||||
{!tracksQuery.isLoading &&
|
||||
!tracksQuery.isError &&
|
||||
tracks.length === 0 && (
|
||||
<EmptyState
|
||||
icon="♫"
|
||||
title={t('artist.empty.title')}
|
||||
description={t('artist.empty.description')}
|
||||
/>
|
||||
)}
|
||||
{tracks.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||
))}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const artUrl = getCoverUrl(album.artUrl);
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '0.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{artUrl ? (
|
||||
<img
|
||||
src={artUrl}
|
||||
alt={album.title}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', aspectRatio: '1' }}>
|
||||
<ArtTile seed={album.id} label={album.title} size={144} radius={6} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.8125rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
|
||||
{album.year ? `${album.year} · ` : ''}
|
||||
{t('library.albumCard.tracksDuration', {
|
||||
count: album.trackCount,
|
||||
duration: formatDuration(album.totalDurationMs),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user