mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
7c6d3694d4
Add cardGridLayout helpers, useCardGridMetrics, useRemeasureGridVirtualizer, and VirtualCardGrid (TanStack row virtualization, always remeasure on layout changes). Apply to Artists (grid + list unchanged policy), Albums, Composers grid, playlists, radio stations, offline library, and album-heavy browse/detail pages. Respect disableMainstageVirtualLists for a non-virtual grid with the same column rules. Includes vitest coverage for column cap.
179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
import React from 'react';
|
|
import type { NavigateFunction } from 'react-router-dom';
|
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
|
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';
|
|
|
|
export type ArtistsGridVirtualization = {
|
|
virtualizer: Virtualizer<HTMLElement, Element>;
|
|
};
|
|
|
|
interface TileProps {
|
|
artist: SubsonicArtist;
|
|
selectionMode: boolean;
|
|
selectedIds: Set<string>;
|
|
selectedArtists: SubsonicArtist[];
|
|
showArtistImages: boolean;
|
|
toggleSelect: (id: string) => void;
|
|
navigate: NavigateFunction;
|
|
openContextMenu: PlayerState['openContextMenu'];
|
|
t: TFunction;
|
|
}
|
|
|
|
type TilePropsShared = Omit<TileProps, 'artist'>;
|
|
|
|
function ArtistGridTile({ artist, ...rest }: TileProps) {
|
|
return (
|
|
<div
|
|
className={`artist-card${rest.selectionMode && rest.selectedIds.has(artist.id) ? ' selected' : ''}${rest.selectionMode ? ' artist-card--selectable' : ''}`}
|
|
onClick={() => {
|
|
if (rest.selectionMode) {
|
|
rest.toggleSelect(artist.id);
|
|
} else {
|
|
rest.navigate(`/artist/${artist.id}`);
|
|
}
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
if (rest.selectionMode && rest.selectedIds.size > 0) {
|
|
rest.openContextMenu(e.clientX, e.clientY, rest.selectedArtists, 'multi-artist');
|
|
} else {
|
|
rest.openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
|
}
|
|
}}
|
|
style={rest.selectionMode && rest.selectedIds.has(artist.id) ? {
|
|
outline: '2px solid var(--accent)',
|
|
outlineOffset: '2px',
|
|
borderRadius: 'var(--radius-md)',
|
|
} : {}}
|
|
>
|
|
{rest.selectionMode && (
|
|
<div className={`artist-card-select-check${rest.selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
|
{rest.selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
|
</div>
|
|
)}
|
|
<ArtistCardAvatar artist={artist} showImages={rest.showArtistImages} />
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div className="artist-card-name">{artist.name}</div>
|
|
{artist.albumCount != null && (
|
|
<div className="artist-card-meta">{rest.t('artists.albumCount', { count: artist.albumCount })}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
visible: SubsonicArtist[];
|
|
/** Column count from layout (capped at six in parent); drives `repeat(n, minmax(0,1fr))`. */
|
|
gridCols: number;
|
|
/** ResizeObserver target — same node for plain and virtual grid. */
|
|
measureRef: React.RefObject<HTMLDivElement | null>;
|
|
virtualization?: ArtistsGridVirtualization | null;
|
|
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. Optional row virtualization (TanStack) for
|
|
* large libraries; column count and `measureRef` always come from the parent.
|
|
*/
|
|
export function ArtistsGridView({
|
|
visible,
|
|
gridCols,
|
|
measureRef,
|
|
virtualization,
|
|
selectionMode,
|
|
selectedIds,
|
|
selectedArtists,
|
|
showArtistImages,
|
|
toggleSelect,
|
|
navigate,
|
|
openContextMenu,
|
|
t,
|
|
}: Props) {
|
|
const tilePropsShared: TilePropsShared = {
|
|
selectionMode,
|
|
selectedIds,
|
|
selectedArtists,
|
|
showArtistImages,
|
|
toggleSelect,
|
|
navigate,
|
|
openContextMenu,
|
|
t,
|
|
};
|
|
|
|
const cols = Math.max(1, gridCols);
|
|
|
|
if (virtualization) {
|
|
const { virtualizer } = virtualization;
|
|
const rowCount = Math.ceil(visible.length / cols);
|
|
return (
|
|
<div
|
|
ref={measureRef}
|
|
className="album-grid-wrap"
|
|
style={{ display: 'block', position: 'relative', width: '100%' }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: rowCount === 0 ? 0 : virtualizer.getTotalSize(),
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map(vRow => {
|
|
const start = vRow.index * cols;
|
|
const rowArtists = visible.slice(start, start + cols);
|
|
return (
|
|
<div
|
|
key={vRow.key}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${vRow.start}px)`,
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
gap: 'var(--space-4)',
|
|
alignItems: 'start',
|
|
}}
|
|
>
|
|
{rowArtists.map(artist => (
|
|
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={measureRef}
|
|
className="album-grid-wrap"
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
gap: 'var(--space-4)',
|
|
alignItems: 'start',
|
|
}}
|
|
>
|
|
{visible.map(artist => (
|
|
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|