mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
25289bbf31
commit
6552d2a5cb
@@ -0,0 +1,77 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { nameColor, nameInitial } from '../../utils/artistsHelpers';
|
||||
|
||||
interface AvatarProps {
|
||||
artist: SubsonicArtist;
|
||||
showImages: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-sized artist avatar for the grid view. Falls back to a coloured
|
||||
* monogram (Catppuccin palette, hashed by name) when artist images are
|
||||
* disabled or the artist has no cover art.
|
||||
*/
|
||||
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { coverSrc, coverKey } = useMemo(
|
||||
() => ({
|
||||
coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
|
||||
coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
|
||||
}),
|
||||
[coverId],
|
||||
);
|
||||
if (showImages && coverId) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
alt={artist.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-sized artist avatar for the list view. Same fallback rules as the
|
||||
* card variant, but smaller cover-art size (64px vs 300px) so list rows
|
||||
* don't pull oversized images from the server.
|
||||
*/
|
||||
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { coverSrc, coverKey } = useMemo(
|
||||
() => ({
|
||||
coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
|
||||
coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
|
||||
}),
|
||||
[coverId],
|
||||
);
|
||||
if (showImages && coverId) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||
import { ArtistCardAvatar } from './ArtistAvatars';
|
||||
|
||||
interface Props {
|
||||
visible: SubsonicArtist[];
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card grid for the artists page. Click navigates to the artist detail
|
||||
* (or toggles selection while in select-mode); right-click opens the
|
||||
* standard context menu, escalating to `multi-artist` when there is an
|
||||
* active multi-selection.
|
||||
*/
|
||||
export function ArtistsGridView({
|
||||
visible,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
navigate,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
||||
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');
|
||||
}
|
||||
}}
|
||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||
outline: '2px solid var(--accent)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
} : {}}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
||||
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user