45a624b642
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>
271 lines
7.6 KiB
TypeScript
271 lines
7.6 KiB
TypeScript
import { useParams, useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
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';
|
|
|
|
export function ArtistDetailPage() {
|
|
const { t } = useTranslation();
|
|
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>
|
|
);
|
|
}
|