diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 7a0abdcd..55492773 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -136,7 +136,11 @@ describe('issues route', () => { it('renders stats and list items through the app router', async () => { renderIssuesRoute(); await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2')); - expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags'); + const issueCard = await screen.findByRole('link', { name: /Bad tags/i }); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('/issues?')); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('status=open')); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('category=all')); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('issueId=7')); }); it('loads the detail modal from the route search state', async () => { @@ -157,17 +161,22 @@ describe('issues route', () => { it('opens and closes the detail modal', async () => { const { history } = renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7')); await waitFor(() => expect(history.location.search).toContain('issueId=7')); - fireEvent.click(screen.getByRole('button', { name: /close issue detail/i })); + const closeLink = screen.getByRole('link', { name: /^close$/i }); + expect(closeLink).toHaveAttribute( + 'href', + expect.stringContaining('/issues?status=open&category=all'), + ); + fireEvent.click(closeLink); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(history.location.search).toBe('?status=open&category=all')); }); it('closes the detail modal with Escape', async () => { const { history } = renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7')); await waitFor(() => expect(history.location.search).toContain('issueId=7')); @@ -179,7 +188,7 @@ describe('issues route', () => { it('focuses the detail modal close button on open', async () => { renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); const closeButton = await screen.findByRole('button', { name: /close issue detail/i, @@ -190,7 +199,7 @@ describe('issues route', () => { it('invokes the shared workflow adapter for admin downloads', async () => { renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); fireEvent.click(await screen.findByRole('button', { name: /download album/i })); await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled()); expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith( diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.module.css b/webui/src/routes/issues/-ui/issue-detail-modal.module.css index 7348fa43..48a25c54 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.module.css +++ b/webui/src/routes/issues/-ui/issue-detail-modal.module.css @@ -878,6 +878,43 @@ font-size: 13px; } +.modalLinkButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + min-height: 36px; + padding: 8px 12px; + text-decoration: none; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.modalLinkButton:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); +} + +.modalLinkButton:focus-visible { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + .modalButtonSecondary { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.tsx b/webui/src/routes/issues/-ui/issue-detail-modal.tsx index 1cbb21d7..c773e8e4 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.tsx +++ b/webui/src/routes/issues/-ui/issue-detail-modal.tsx @@ -1,4 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog'; @@ -22,6 +23,7 @@ import { ISSUE_CATEGORY_META, parseSnapshot, } from '../-issues.helpers'; +import { Route } from '../route'; import styles from './issue-detail-modal.module.css'; export function IssueDetailModal({ @@ -213,9 +215,14 @@ export function IssueDetailModal({ /> {renderIssueDetailContent()} - + {issue && ( <> {statusButtons} diff --git a/webui/src/routes/issues/-ui/issues-page.module.css b/webui/src/routes/issues/-ui/issues-page.module.css index 9fc1d269..95ab7faa 100644 --- a/webui/src/routes/issues/-ui/issues-page.module.css +++ b/webui/src/routes/issues/-ui/issues-page.module.css @@ -159,6 +159,7 @@ width: 100%; padding: 14px 16px; text-align: left; + text-decoration: none; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.03); diff --git a/webui/src/routes/issues/-ui/issues-page.tsx b/webui/src/routes/issues/-ui/issues-page.tsx index 52e86f83..2354cde6 100644 --- a/webui/src/routes/issues/-ui/issues-page.tsx +++ b/webui/src/routes/issues/-ui/issues-page.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { Select } from '@/components/form'; import { Show } from '@/components/primitives'; @@ -73,13 +73,6 @@ function IssueBoard() { ...issueListQueryOptions(profileId, params), }); - const openIssue = (issueId: number) => { - void navigate({ - to: Route.fullPath, - search: (prev) => ({ ...prev, issueId }), - }); - }; - const onCategoryChange = (category: IssuesSearch['category']) => { void navigate({ to: Route.fullPath, @@ -112,7 +105,6 @@ function IssueBoard() { issuesError={issuesQuery.error} issuesLoading={issuesQuery.isLoading} showReporterName={isAdmin} - onIssueSelect={openIssue} statusFilter={params.status} /> @@ -225,7 +217,6 @@ function IssueBoardList({ issues, issuesError, issuesLoading, - onIssueSelect, showReporterName, statusFilter, }: { @@ -233,7 +224,6 @@ function IssueBoardList({ issues: IssueRecord[]; issuesError: unknown; issuesLoading: boolean; - onIssueSelect: (issueId: number) => void; showReporterName: boolean; statusFilter: IssuesSearch['status']; }) { @@ -285,7 +275,6 @@ function IssueBoardList({ key={issue.id} issue={issue} showReporterName={showReporterName} - onIssueSelect={onIssueSelect} /> )); } @@ -294,11 +283,9 @@ function IssueBoardList({ function IssueBoardCard({ issue, showReporterName, - onIssueSelect, }: { issue: IssueRecord; showReporterName: boolean; - onIssueSelect: (issueId: number) => void; }) { const snapshot = parseSnapshot(issue.snapshot_data); const artwork = getIssueArtwork(snapshot); @@ -311,11 +298,11 @@ function IssueBoardCard({ const createdDate = formatIssueDate(issue.created_at); return ( - + ); } diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index ec251ae9..20867072 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -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(); - fireEvent.click(await screen.findByRole('button', { name: 'Artist A' })); + const bubbleLink = await screen.findByRole('link', { + name: 'Open artist detail for Artist A', + }); + expect(bubbleLink).toHaveAttribute('href', '/artist-detail/library/7'); - expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( - 7, - 'Artist A', + const rankedLink = screen.getByRole('link', { name: 'Artist A' }); + expect(rankedLink).toHaveAttribute('href', '/artist-detail/library/7'); + + 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, + }, + ), ); }); diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css index 17be6814..a6260111 100644 --- a/webui/src/routes/stats/-ui/stats-page.module.css +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -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; diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 63259a7d..1c93b461 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -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 (
@@ -229,25 +227,15 @@ export function StatsPage() {
- openArtistDetail(artistId, artistName)} - /> - openArtistDetail(artistId, artistName)} - /> + + - openArtistDetail(artistId, artistName)} - /> + openArtistDetail(artistId, artistName)} onPlay={(track) => playStatsTrack(bridge, track)} /> @@ -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({
{topArtists.map(({ artist, percent, size }) => { const isClickable = artist.id !== null && artist.id !== undefined; - return ( - + + ); + return isClickable ? ( + + {bubbleContent} + + ) : ( +
+ {bubbleContent} +
); })}
@@ -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, 'children' | 'href'>) { + if (artistId == null) { + return <>{children}; + } + + return ( + + {children} + + ); +} + +function StatsRankedArtists({ artists }: { artists: StatsArtistRow[] }) { return (
{artists.length === 0 ? : null} @@ -479,17 +486,9 @@ function StatsRankedArtists({ )}
- {artist.id ? ( - - ) : ( - artist.name - )} + + {artist.name} + {artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? ( 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 (
{albums.length === 0 ? : null} @@ -530,19 +523,9 @@ function StatsRankedAlbums({
{album.name}
- {album.artist_id ? ( - - ) : ( - album.artist || '' - )} + + {album.artist || ''} +
@@ -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; }) { return ( @@ -577,19 +558,9 @@ function StatsRankedTracks({
{track.name}
- {track.artist_id ? ( - - ) : ( - track.artist || '' - )} + + {track.artist || ''} + {track.album ? ` ยท ${track.album}` : ''}