refactor(components): GenreFilterBar -> ui/ (shared), SongCard -> features/home (sole consumer)

This commit is contained in:
Psychotoxical
2026-06-30 18:50:36 +02:00
parent 0a8d82fe2a
commit 9eee4eed5b
8 changed files with 12 additions and 250 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch';
import { useAuthStore } from '@/store/authStore';
import { clampLibraryGridMaxColumns } from '@/store/authStoreHelpers';
import { computeCardGridColumnCount } from '@/utils/cardGridLayout';
import GenreFilterBar from '@/components/GenreFilterBar';
import GenreFilterBar from '@/ui/GenreFilterBar';
import YearFilterButton from '@/ui/YearFilterButton';
import StarFilterButton from '@/ui/StarFilterButton';
import LosslessFilterButton from '@/ui/LosslessFilterButton';
+1 -1
View File
@@ -8,7 +8,7 @@ import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } fr
import { Download, HardDriveDownload } from 'lucide-react';
import SelectionToggleButton from '@/ui/SelectionToggleButton';
import AlbumCard from '@/features/album/components/AlbumCard';
import GenreFilterBar from '@/components/GenreFilterBar';
import GenreFilterBar from '@/ui/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
+1 -1
View File
@@ -9,7 +9,7 @@ import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from
import { RefreshCw, Download, HardDriveDownload } from 'lucide-react';
import SelectionToggleButton from '@/ui/SelectionToggleButton';
import AlbumCard from '@/features/album/components/AlbumCard';
import GenreFilterBar from '@/components/GenreFilterBar';
import GenreFilterBar from '@/ui/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
@@ -6,7 +6,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useSelectionStore } from '@/store/selectionStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import GenreFilterBar from '@/components/GenreFilterBar';
import GenreFilterBar from '@/ui/GenreFilterBar';
interface Props {
visibleSongs: SubsonicSong[];
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import SongCard from '@/features/home/components/SongCard';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
const navigateToArtist = vi.fn();
// Only navigate-to-artist is stubbed; mock that submodule directly so the
// barrel re-exports the stub while coerceOpenArtistRefs (used by
// trackArtistRefs, a different submodule) stays real.
vi.mock('@/features/artist/hooks/useNavigateToArtist', () => ({
useNavigateToArtist: () => navigateToArtist,
}));
vi.mock('@/cover/useLibraryCoverRef', () => ({
useTrackCoverRef: () => undefined,
}));
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
return {
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
...overrides,
} as SubsonicSong;
}
describe('SongCard', () => {
it('splits OpenSubsonic artists into individual links', async () => {
navigateToArtist.mockClear();
const user = userEvent.setup();
renderWithProviders(
<SongCard
disableArtwork
song={song({
artist: 'Apocalyptica', artistId: 'a1',
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
})}
/>,
);
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
await user.click(screen.getByText('Joe Duplantier'));
expect(navigateToArtist).toHaveBeenCalledWith('a2');
});
});
+181
View File
@@ -0,0 +1,181 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import React, { memo, useMemo } from 'react';
import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { useCoverArt } from '@/cover/useCoverArt';
import { useTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '@/cover/layoutSizes';
import { enqueueAndPlay } from '@/features/playback/utils/playback/playSong';
import { useDragDrop } from '@/lib/dnd/DragDropContext';
import { useOrbitSongRowBehavior } from '@/features/orbit';
import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { OpenArtistRefInline } from '@/features/artist';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
interface SongCardProps {
song: SubsonicSong;
disableArtwork?: boolean;
/** Layout-native cover square width in CSS px (rail cell). */
displayCssPx?: number;
/** @deprecated Use displayCssPx */
artworkSize?: number;
}
function SongCard({
song,
disableArtwork = false,
displayCssPx = COVER_DENSE_RAIL_CELL_CSS_PX,
artworkSize,
}: SongCardProps) {
const layoutPx = artworkSize ?? displayCssPx;
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, layoutPx, {
surface: 'dense',
ensurePriority: 'middle',
});
const coverUrl = coverHandle.src;
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const navigateToAlbum = useNavigateToAlbum();
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueueAndPlay(song);
};
const handleEnqueue = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueue([songToTrack(song)]);
};
const handleClick = handlePlay;
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
e.stopPropagation();
navigateToAlbum(song.albumId);
};
return (
<div
className="song-card card"
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${song.title} ${song.artist}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
onMouseDown={e => {
if (e.button !== 0) return;
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', id: song.id, name: song.title }), label: song.title, coverUrl: coverUrl || undefined },
me.clientX, me.clientY,
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="song-card-cover cover-circle">
{!disableArtwork && coverRef ? (
<CoverArtImage
coverRef={coverRef}
displayCssPx={layoutPx}
surface="dense"
alt={`${song.album} Cover`}
loading="eager"
decoding="async"
/>
) : (
<div className="song-card-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)}
<div className="song-card-play-overlay">
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handlePlay(); }}
aria-label={t('tracks.playSong')}
data-tooltip={t('tracks.playSong')}
data-tooltip-pos="top"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handleEnqueue(); }}
aria-label={t('tracks.enqueueSong')}
data-tooltip={t('tracks.enqueueSong')}
data-tooltip-pos="top"
>
<ListPlus size={14} />
</button>
</div>
</div>
<div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p className="song-card-artist truncate" title={song.artist}>
<OpenArtistRefInline
refs={artistRefs}
fallbackName={song.artist}
onGoArtist={id => navigateToArtist(id)}
as="none"
linkTag="span"
linkClassName="track-artist-link"
/>
</p>
{song.albumId && (
<button
type="button"
className="song-card-album-badge"
onClick={handleAlbumClick}
aria-label={`${t('tracks.toAlbum')} ${song.album}`}
title={song.album}
>
<Disc3 size={11} />
<span>{t('tracks.toAlbum')}</span>
</button>
)}
{(song.userRating ?? 0) > 0 && (
<div className="song-card-rating" aria-label={`${song.userRating} stars`}>
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
size={11}
fill={i < (song.userRating ?? 0) ? 'currentColor' : 'none'}
strokeWidth={1.5}
/>
))}
</div>
)}
</div>
</div>
);
}
export default memo(SongCard);
+1 -1
View File
@@ -1,7 +1,7 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useRef, useState, useEffect, useMemo } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import SongCard from '@/components/SongCard';
import SongCard from '@/features/home/components/SongCard';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { dedupeById } from '@/lib/util/dedupeById';