feat(ui): shared card grid with max six columns and virtualization

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.
This commit is contained in:
Maxim Isaev
2026-05-15 02:05:34 +03:00
parent 778c9e00fd
commit 7c6d3694d4
20 changed files with 601 additions and 202 deletions
+137 -40
View File
@@ -1,13 +1,78 @@
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[];
@@ -19,13 +84,14 @@ interface Props {
}
/**
* 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.
* 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,
@@ -35,46 +101,77 @@ export function ArtistsGridView({
openContextMenu,
t,
}: Props) {
return (
<div className="album-grid-wrap">
{visible.map(artist => (
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
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}`);
}
style={{
height: rowCount === 0 ? 0 : virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
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>
{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>
);