refactor(webui): link stats artist chips

- replace manual artist-detail navigation with declarative anchors
- reuse the shared artist-detail helper for bubble and ranked views
- keep the no-id bubble fallback non-interactive
pull/689/head
Antti Kettunen 2 days ago
parent f68afe80c8
commit 81bdf4355f
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -117,14 +117,29 @@ describe('stats route', () => {
await waitFor(() => expect(history.location.search).toContain('range=30d'));
});
it('hands artist detail navigation directly to the shell bridge', async () => {
renderStatsRoute();
it('links artist names to the artist-detail route', async () => {
const { history } = renderStatsRoute();
const bubbleLink = await screen.findByRole('link', {
name: 'Open artist detail for Artist A',
});
expect(bubbleLink).toHaveAttribute('href', '/artist-detail/library/7');
fireEvent.click(await screen.findByRole('button', { name: 'Artist A' }));
const rankedLink = screen.getByRole('link', { name: 'Artist A' });
expect(rankedLink).toHaveAttribute('href', '/artist-detail/library/7');
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
7,
'Artist A',
fireEvent.click(bubbleLink);
await waitFor(() => expect(history.location.pathname).toBe('/artist-detail/library/7'));
await waitFor(() =>
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
'7',
'',
null,
{
skipRouteChange: true,
},
),
);
});

@ -364,10 +364,13 @@
border: none;
padding: 0;
cursor: pointer;
color: inherit;
text-decoration: none;
font: inherit;
transition: transform 0.2s ease;
}
.statsArtistBubble:disabled {
.statsArtistBubbleDisabled {
cursor: default;
}
@ -375,6 +378,10 @@
transform: translateY(-3px);
}
.statsArtistBubbleDisabled:hover {
transform: none;
}
.statsBubbleImage {
border-radius: 50%;
background-size: cover;

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from '@tanstack/react-router';
import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useRef, useState } from 'react';
import {
Bar,
BarChart,
@ -72,6 +72,8 @@ const STATS_CHART_CURSOR = {
fill: 'rgba(var(--accent-rgb), 0.12)',
} as const;
const ARTIST_DETAIL_SOURCE = 'library' as const;
export function StatsPage() {
const bridge = useReactPageShell('stats');
@ -136,10 +138,6 @@ export function StatsPage() {
});
};
const openArtistDetail = (artistId: string | number, artistName: string) => {
bridge.navigateToArtistDetail(artistId, artistName);
};
return (
<div id="stats-container" className={styles.statsContainer} data-testid="stats-page">
<header className={styles.statsHeader}>
@ -229,25 +227,15 @@ export function StatsPage() {
</div>
<div className={styles.statsRightCol}>
<StatsSectionCard title="Top Artists">
<TopArtistsVisual
artists={cachedStats?.top_artists ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
<StatsRankedArtists
artists={cachedStats?.top_artists ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
<TopArtistsVisual artists={cachedStats?.top_artists ?? []} />
<StatsRankedArtists artists={cachedStats?.top_artists ?? []} />
</StatsSectionCard>
<StatsSectionCard title="Top Albums">
<StatsRankedAlbums
albums={cachedStats?.top_albums ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
<StatsRankedAlbums albums={cachedStats?.top_albums ?? []} />
</StatsSectionCard>
<StatsSectionCard title="Top Tracks">
<StatsRankedTracks
tracks={cachedStats?.top_tracks ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
@ -405,13 +393,7 @@ function StatsGenreLegend({
);
}
function TopArtistsVisual({
artists,
onArtistSelect,
}: {
artists: StatsArtistRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
function TopArtistsVisual({ artists }: { artists: StatsArtistRow[] }) {
const topArtists = getTopArtistBubbles(artists);
if (topArtists.length === 0) return null;
@ -420,18 +402,8 @@ function TopArtistsVisual({
<div className={styles.statsArtistBubbles}>
{topArtists.map(({ artist, percent, size }) => {
const isClickable = artist.id !== null && artist.id !== undefined;
return (
<button
key={`${artist.name}-${artist.id ?? 'unknown'}`}
type="button"
className={styles.statsArtistBubble}
onClick={() => {
if (isClickable) {
onArtistSelect(artist.id as string | number, artist.name);
}
}}
disabled={!isClickable}
>
const bubbleContent = (
<>
<div
className={styles.statsBubbleImage}
style={{
@ -451,7 +423,25 @@ function TopArtistsVisual({
<div className={styles.statsBubbleCount}>
{formatCompactNumber(artist.play_count)}
</div>
</button>
</>
);
return isClickable ? (
<ArtistDetailLink
key={`${artist.name}-${artist.id ?? 'unknown'}`}
artistId={artist.id}
className={styles.statsArtistBubble}
aria-label={`Open artist detail for ${artist.name}`}
>
{bubbleContent}
</ArtistDetailLink>
) : (
<div
key={`${artist.name}-${artist.id ?? 'unknown'}`}
className={`${styles.statsArtistBubble} ${styles.statsArtistBubbleDisabled}`}
aria-disabled="true"
>
{bubbleContent}
</div>
);
})}
</div>
@ -459,13 +449,30 @@ function TopArtistsVisual({
);
}
function StatsRankedArtists({
artists,
onArtistSelect,
function ArtistDetailLink({
artistId,
children,
...linkProps
}: {
artists: StatsArtistRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
artistId: string | number | null | undefined;
children: ReactNode;
} & Omit<ComponentPropsWithoutRef<'a'>, 'children' | 'href'>) {
if (artistId == null) {
return <>{children}</>;
}
return (
<Link
to="/artist-detail/$source/$id"
params={{ source: ARTIST_DETAIL_SOURCE, id: String(artistId) }}
{...linkProps}
>
{children}
</Link>
);
}
function StatsRankedArtists({ artists }: { artists: StatsArtistRow[] }) {
return (
<div id="stats-top-artists" className={styles.statsRankedList}>
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
@ -479,17 +486,9 @@ function StatsRankedArtists({
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>
{artist.id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() => onArtistSelect(artist.id as string | number, artist.name)}
>
{artist.name}
</button>
) : (
artist.name
)}
<ArtistDetailLink artistId={artist.id} className={styles.statsArtistLink}>
{artist.name}
</ArtistDetailLink>
{artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? (
<img src="/static/trans2.png" className={styles.statsSoulIdBadge} alt="SoulID" />
) : null}
@ -509,13 +508,7 @@ function StatsRankedArtists({
);
}
function StatsRankedAlbums({
albums,
onArtistSelect,
}: {
albums: StatsAlbumRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
function StatsRankedAlbums({ albums }: { albums: StatsAlbumRow[] }) {
return (
<div id="stats-top-albums" className={styles.statsRankedList}>
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
@ -530,19 +523,9 @@ function StatsRankedAlbums({
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>{album.name}</div>
<div className={styles.statsRankedMeta}>
{album.artist_id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() =>
onArtistSelect(album.artist_id as string | number, album.artist || '')
}
>
{album.artist || ''}
</button>
) : (
album.artist || ''
)}
<ArtistDetailLink artistId={album.artist_id} className={styles.statsArtistLink}>
{album.artist || ''}
</ArtistDetailLink>
</div>
</div>
<span className={styles.statsRankedCount}>
@ -556,11 +539,9 @@ function StatsRankedAlbums({
function StatsRankedTracks({
tracks,
onArtistSelect,
onPlay,
}: {
tracks: StatsTrackRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
}) {
return (
@ -577,19 +558,9 @@ function StatsRankedTracks({
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>{track.name}</div>
<div className={styles.statsRankedMeta}>
{track.artist_id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() =>
onArtistSelect(track.artist_id as string | number, track.artist || '')
}
>
{track.artist || ''}
</button>
) : (
track.artist || ''
)}
<ArtistDetailLink artistId={track.artist_id} className={styles.statsArtistLink}>
{track.artist || ''}
</ArtistDetailLink>
{track.album ? ` · ${track.album}` : ''}
</div>
</div>

Loading…
Cancel
Save