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 { 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() {
|
export function ArtistDetailPage() {
|
||||||
const { t } = useTranslation();
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,11 @@ export function LibraryPage() {
|
|||||||
{artistsQuery.data && (
|
{artistsQuery.data && (
|
||||||
<div style={{ padding: '0.5rem 0' }}>
|
<div style={{ padding: '0.5rem 0' }}>
|
||||||
{artistsQuery.data.items.map((artist) => (
|
{artistsQuery.data.items.map((artist) => (
|
||||||
<ArtistRow key={artist.id} artist={artist} />
|
<ArtistRow
|
||||||
|
key={artist.id}
|
||||||
|
artist={artist}
|
||||||
|
onClick={() => void navigate(`/artists/${artist.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -302,15 +306,23 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtistRow({ artist }: { artist: Artist }) {
|
function ArtistRow({
|
||||||
|
artist,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
artist: Artist;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
padding: '0.5rem 1.5rem',
|
padding: '0.5rem 1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ const en = {
|
|||||||
description: 'This album has no tracks.',
|
description: 'This album has no tracks.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Artist',
|
||||||
|
play: '▶ Play all',
|
||||||
|
error: 'Failed to load artist',
|
||||||
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
|
albums: 'Albums',
|
||||||
|
tracks: 'Tracks',
|
||||||
|
noAlbums: 'No albums yet.',
|
||||||
|
empty: {
|
||||||
|
title: 'No tracks',
|
||||||
|
description: 'This artist has no tracks.',
|
||||||
|
},
|
||||||
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Playlist',
|
type: 'Playlist',
|
||||||
play: '▶ Play',
|
play: '▶ Play',
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ const ru: Translations = {
|
|||||||
description: 'В этом альбоме нет треков.',
|
description: 'В этом альбоме нет треков.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Исполнитель',
|
||||||
|
play: '▶ Слушать всё',
|
||||||
|
error: 'Не удалось загрузить исполнителя',
|
||||||
|
meta: '{{albumCount}} альбомов · {{trackCount}} треков',
|
||||||
|
albums: 'Альбомы',
|
||||||
|
tracks: 'Треки',
|
||||||
|
noAlbums: 'Пока нет альбомов.',
|
||||||
|
empty: {
|
||||||
|
title: 'Нет треков',
|
||||||
|
description: 'У этого исполнителя нет треков.',
|
||||||
|
},
|
||||||
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Плейлист',
|
type: 'Плейлист',
|
||||||
play: '▶ Слушать',
|
play: '▶ Слушать',
|
||||||
|
|||||||
Reference in New Issue
Block a user