refactor(artists): I.4 — split Artists.tsx 520 → 233 LOC across 6 files (#676)

* refactor(artists): extract helpers + constants

Pull ALL_SENTINEL / ALPHABET / ARTIST_LIST_* row-height estimates,
the ArtistListFlatRow union, CTP_COLORS palette, and the deterministic
nameColor / nameInitial helpers into utils/artistsHelpers.ts.

Artists.tsx: 520 → 496 LOC.

* refactor(artists): extract ArtistAvatars subcomponents

Pull ArtistCardAvatar (300px, grid view) and ArtistRowAvatar (64px, list
view) into components/artists/ArtistAvatars.tsx. Both fall back to a
hashed-Catppuccin monogram when artist images are off or no cover art
is available.

Artists.tsx: 496 → 436 LOC.

* refactor(artists): extract useArtistsFiltering hook

Bundle the letter/text/star filter pipeline, visible-slice memo,
group-by-letter, and the virtualizer flat-rows list into
hooks/useArtistsFiltering.ts. List-view-only outputs short-circuit when
grid view is active.

Artists.tsx: 436 → 386 LOC.

* refactor(artists): extract useArtistsInfiniteScroll hook

Bundle visibleCount + loadingMore state, the sentinel
IntersectionObserver, loadMore callback, and the filter-change reset
into hooks/useArtistsInfiniteScroll.ts. The observer no longer takes
hasMore — the sentinel element only mounts while there is more data,
so the observer attaches/detaches naturally with it.

Artists.tsx: 386 → 370 LOC.

* refactor(artists): extract ArtistsGridView + ArtistsListView

Move the grid card layout to components/artists/ArtistsGridView.tsx and
the dual-path list layout (non-virtualized fallback + virtualized stream)
to components/artists/ArtistsListView.tsx. Both paths now share an
internal ArtistListRow component so click + context-menu behaviour is
identical regardless of which renderer is active.

Artists.tsx: 370 → 233 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-14 00:33:27 +02:00
committed by GitHub
parent 25289bbf31
commit 6552d2a5cb
7 changed files with 584 additions and 337 deletions
+183
View File
@@ -0,0 +1,183 @@
import React from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { Virtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import type { PlayerState } from '../../store/playerStoreTypes';
import type { ArtistListFlatRow } from '../../utils/artistsHelpers';
import { ArtistRowAvatar } from './ArtistAvatars';
interface RowProps {
artist: SubsonicArtist;
selectionMode: boolean;
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
function ArtistListRow({
artist,
selectionMode,
selectedIds,
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
openContextMenu,
t,
}: RowProps) {
return (
<button
type="button"
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
id={`artist-${artist.id}`}
style={selectionMode && selectedIds.has(artist.id) ? {
background: 'var(--accent-dim)',
color: 'var(--accent)',
} : {}}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
);
}
interface Props {
virtualized: boolean;
groups: Record<string, SubsonicArtist[]>;
letters: string[];
artistListFlatRows: ArtistListFlatRow[];
artistListVirtualizer: Virtualizer<HTMLElement, Element>;
selectionMode: boolean;
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
/**
* List view for the artists page. Two render paths:
* - Non-virtualized — emits one `<div class="artist-list">` per starting
* letter, used when the `disableMainstageVirtualLists` perf flag is on
* (mostly for low-end devices where translate-Y positioning costs more
* than the saved DOM nodes).
* - Virtualized — flat `letter / artist / artist / …` row stream sitting
* on a single absolutely-positioned `<div>` whose height matches the
* virtualizer's totalSize.
*
* Both paths share `ArtistListRow` so click + context-menu behaviour is
* identical regardless of the rendering path.
*/
export function ArtistsListView({
virtualized,
groups,
letters,
artistListFlatRows,
artistListVirtualizer,
selectionMode,
selectedIds,
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
openContextMenu,
t,
}: Props) {
const rowCommonProps = {
selectionMode, selectedIds, selectedArtists, showArtistImages,
toggleSelect, navigate, openContextMenu, t,
};
if (!virtualized) {
return (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<ArtistListRow key={artist.id} artist={artist} {...rowCommonProps} />
))}
</div>
</div>
))}
</>
);
}
return (
<div style={{ position: 'relative', width: '100%' }}>
<div
style={{
height: artistListFlatRows.length === 0 ? 0 : artistListVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{artistListVirtualizer.getVirtualItems().map(vi => {
const row = artistListFlatRows[vi.index];
if (!row) return null;
if (row.kind === 'letter') {
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<h3 className="letter-heading">{row.letter}</h3>
</div>
);
}
const artist = row.artist;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
}}
>
<ArtistListRow artist={artist} {...rowCommonProps} />
</div>
);
})}
</div>
</div>
);
}