mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -0,0 +1,120 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
|
||||
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
|
||||
import type { CardGridRowHeightVariant } from '../utils/cardGridLayout';
|
||||
|
||||
export type VirtualCardGridProps<T> = {
|
||||
items: readonly T[];
|
||||
itemKey: (item: T, flatIndex: number) => string;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
rowVariant: CardGridRowHeightVariant;
|
||||
disableVirtualization: boolean;
|
||||
/** Bumps layout when list shape changes (e.g. `items.length`). */
|
||||
layoutSignal: number;
|
||||
wrapClassName?: string;
|
||||
/** Optional styles on the outer measurement wrapper (e.g. enter animation). */
|
||||
wrapStyle?: React.CSSProperties;
|
||||
/** Defaults to `var(--space-4)`; composer grid uses `var(--space-2)`. */
|
||||
gridGap?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Album-/playlist-style card grids: at most six columns, proportional stretch,
|
||||
* optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID`.
|
||||
*/
|
||||
export function VirtualCardGrid<T>({
|
||||
items,
|
||||
itemKey,
|
||||
renderItem,
|
||||
rowVariant,
|
||||
disableVirtualization,
|
||||
layoutSignal,
|
||||
wrapClassName = 'album-grid-wrap',
|
||||
wrapStyle,
|
||||
gridGap = 'var(--space-4)',
|
||||
}: VirtualCardGridProps<T>): React.JSX.Element {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal);
|
||||
const cols = Math.max(1, gridCols);
|
||||
const virtualRowCount = Math.max(0, Math.ceil(items.length / cols));
|
||||
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||
const overscan = Math.max(2, Math.ceil(mainScrollViewportHeight / Math.max(1, rowHeightEst)));
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: disableVirtualization ? 0 : virtualRowCount,
|
||||
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
|
||||
estimateSize: () => rowHeightEst,
|
||||
overscan,
|
||||
});
|
||||
|
||||
useRemeasureGridVirtualizer(virtualizer, {
|
||||
active: !disableVirtualization && virtualRowCount > 0,
|
||||
gridCols: cols,
|
||||
rowHeightEst,
|
||||
virtualRowCount,
|
||||
});
|
||||
|
||||
if (disableVirtualization) {
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={wrapClassName}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gap: gridGap,
|
||||
alignItems: 'start',
|
||||
...wrapStyle,
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={itemKey(item, i)}>{renderItem(item)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={wrapClassName}
|
||||
style={{ display: 'block', position: 'relative', width: '100%', ...wrapStyle }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualRowCount === 0 ? 0 : virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map(vRow => {
|
||||
const start = vRow.index * cols;
|
||||
const rowItems = items.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: gridGap,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, i) => (
|
||||
<React.Fragment key={itemKey(item, start + i)}>{renderItem(item)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user