remove item callbacks from list - move to item component

This commit is contained in:
jeffvli
2025-10-07 11:36:15 -07:00
parent 545ea25e43
commit d9e8625b15
11 changed files with 580 additions and 350 deletions
@@ -138,17 +138,29 @@
}
svg {
fill: rgb(255 255 255);
stroke: rgb(255 255 255);
}
}
.secondary-button.favorite {
.user-data {
position: absolute;
top: 0;
right: 0;
}
.rating {
position: absolute;
top: 0;
right: 0;
padding: var(--theme-spacing-md);
}
.secondary-button.options {
right: 0;
bottom: 0;
}
.secondary-button.expand {
bottom: 0;
left: 0;
}
@@ -1,12 +1,27 @@
import clsx from 'clsx';
import { motion } from 'motion/react';
import { MouseEvent } from 'react';
import styles from './item-card-controls.module.css';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { animationVariants } from '/@/shared/components/animations/animation-variants';
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
import { Rating } from '/@/shared/components/rating/rating';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface ItemCardControlsProps {
controls: ItemControls;
item: Album | AlbumArtist | Artist | Playlist | Song | undefined;
itemType: LibraryItem;
type?: 'compact' | 'default' | 'poster';
}
@@ -27,27 +42,79 @@ const containerProps = {
animate: 'show',
exit: 'hidden',
initial: 'hidden',
variants: animationVariants.combine(animationVariants.slideInUp, animationVariants.fadeIn),
variants: animationVariants.combine(animationVariants.zoomIn, animationVariants.fadeIn),
},
};
export const ItemCardControls = ({ type = 'default' }: ItemCardControlsProps) => {
export const ItemCardControls = ({
controls,
item,
itemType,
type = 'default',
}: ItemCardControlsProps) => {
return (
<motion.div className={clsx(styles.container)} {...containerProps[type]}>
<PlayButton />
<SecondaryPlayButton className={styles.left} icon="mediaPlayNext" />
<SecondaryPlayButton className={styles.right} icon="mediaPlayLast" />
<SecondaryButton className={styles.favorite} icon="favorite" />
<SecondaryButton className={styles.options} icon="ellipsisHorizontal" />
<PlayButton
onClick={(e) => {
e.stopPropagation();
controls.onPlay?.(item, itemType, Play.NOW, e);
}}
/>
<SecondaryPlayButton
className={styles.left}
icon="mediaPlayNext"
onClick={(e) => {
e.stopPropagation();
controls.onPlay?.(item, itemType, Play.NEXT, e);
}}
/>
<SecondaryPlayButton
className={styles.right}
icon="mediaPlayLast"
onClick={(e) => {
e.stopPropagation();
controls.onPlay?.(item, itemType, Play.LAST, e);
}}
/>
<SecondaryButton
className={styles.favorite}
icon="favorite"
onClick={(e) => {
e.stopPropagation();
controls.onFavorite?.(item, itemType, e);
}}
/>
<Rating className={styles.rating} size="xs" />
<SecondaryButton
className={styles.options}
icon="ellipsisHorizontal"
onClick={(e) => {
e.stopPropagation();
controls.onMore?.(item, itemType, e);
}}
/>
{controls.onItemExpand && (
<SecondaryButton
className={styles.expand}
icon="arrowDownS"
onClick={(e) => {
e.stopPropagation();
controls.onItemExpand?.(item, itemType, e);
}}
/>
)}
</motion.div>
);
};
const PlayButton = () => {
const PlayButton = ({ onClick }: { onClick?: (e: MouseEvent<HTMLButtonElement>) => void }) => {
return (
<button
className={clsx(styles.playButton, styles.primary)}
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
<Icon icon="mediaPlay" size="lg" />
</button>
@@ -57,12 +124,20 @@ const PlayButton = () => {
const SecondaryPlayButton = ({
className,
icon,
onClick,
}: {
className?: string;
icon: keyof typeof AppIcon;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
}) => {
return (
<button className={clsx(styles.playButton, styles.secondary, className)}>
<button
className={clsx(styles.playButton, styles.secondary, className)}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
<Icon icon={icon} size="lg" />
</button>
);
@@ -71,11 +146,18 @@ const SecondaryPlayButton = ({
interface SecondaryButtonProps {
className?: string;
icon: keyof typeof AppIcon;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
}
const SecondaryButton = ({ className, icon }: SecondaryButtonProps) => {
const SecondaryButton = ({ className, icon, onClick }: SecondaryButtonProps) => {
return (
<button className={clsx(styles.secondaryButton, className)}>
<button
className={clsx(styles.secondaryButton, className)}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
<Icon icon={icon} size="lg" />
</button>
);
@@ -28,7 +28,7 @@
&:hover {
&::before {
opacity: 0.6;
opacity: 0.7;
}
}
}
+38 -51
View File
@@ -1,19 +1,12 @@
import clsx from 'clsx';
import { AnimatePresence } from 'motion/react';
import {
Dispatch,
Fragment,
lazy,
memo,
MouseEvent,
ReactNode,
SetStateAction,
useState,
} from 'react';
import { Dispatch, Fragment, memo, ReactNode, SetStateAction, useState } from 'react';
import { generatePath, Link } from 'react-router-dom';
import styles from './item-card.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { AppRoute } from '/@/renderer/router/routes';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
@@ -28,12 +21,6 @@ import {
Song,
} from '/@/shared/types/domain-types';
const ItemCardControls = lazy(() =>
import('/@/renderer/components/item-card/item-card-controls').then((module) => ({
default: module.ItemCardControls,
})),
);
type DataRow = {
format: (data: Album | AlbumArtist | Artist | Playlist | Song) => ReactNode | string;
id: string;
@@ -41,27 +28,20 @@ type DataRow = {
};
interface ItemCardProps {
controls: ItemControls;
data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
isRound?: boolean;
itemType: LibraryItem;
onClick?: (
e: MouseEvent<HTMLDivElement>,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
) => void;
onItemExpand?: () => void;
onItemSelect?: () => void;
type?: 'compact' | 'default' | 'poster';
withControls?: boolean;
}
export const ItemCard = ({
controls,
data,
isRound,
itemType,
onClick,
onItemExpand,
onItemSelect,
type = 'poster',
withControls,
}: ItemCardProps) => {
@@ -74,13 +54,11 @@ export const ItemCard = ({
case 'compact':
return (
<CompactItemCard
controls={controls}
data={data}
imageUrl={imageUrl}
isRound={isRound}
itemType={itemType}
onClick={onClick}
onItemExpand={onItemExpand}
onItemSelect={onItemSelect}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
@@ -90,13 +68,11 @@ export const ItemCard = ({
case 'poster':
return (
<PosterItemCard
controls={controls}
data={data}
imageUrl={imageUrl}
isRound={isRound}
itemType={itemType}
onClick={onClick}
onItemExpand={onItemExpand}
onItemSelect={onItemSelect}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
@@ -107,13 +83,11 @@ export const ItemCard = ({
default:
return (
<DefaultItemCard
controls={controls}
data={data}
imageUrl={imageUrl}
isRound={isRound}
itemType={itemType}
onClick={onClick}
onItemExpand={onItemExpand}
onItemSelect={onItemSelect}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
@@ -124,6 +98,7 @@ export const ItemCard = ({
};
export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
controls: ItemControls;
imageUrl: string | undefined;
rows: DataRow[];
setShowControls: Dispatch<SetStateAction<boolean>>;
@@ -131,13 +106,11 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
}
const CompactItemCard = ({
controls,
data,
imageUrl,
isRound,
itemType,
onClick,
onItemExpand,
onItemSelect,
rows,
setShowControls,
showControls,
@@ -148,7 +121,6 @@ const CompactItemCard = ({
<div className={clsx(styles.container, styles.compact)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={(e) => onClick?.(e, data, itemType)}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
@@ -157,7 +129,14 @@ const CompactItemCard = ({
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="compact" />}
{withControls && showControls && (
<ItemCardControls
controls={controls}
item={data}
itemType={itemType}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows.map((row) => (
@@ -186,13 +165,11 @@ const CompactItemCard = ({
};
const DefaultItemCard = ({
controls,
data,
imageUrl,
isRound,
itemType,
onClick,
onItemExpand,
onItemSelect,
rows,
setShowControls,
showControls,
@@ -203,8 +180,6 @@ const DefaultItemCard = ({
<div className={clsx(styles.container)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={(e) => onClick?.(e, data, itemType)}
onDoubleClick={onItemExpand}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
@@ -213,7 +188,14 @@ const DefaultItemCard = ({
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="default" />}
{withControls && showControls && (
<ItemCardControls
controls={controls}
item={data}
itemType={itemType}
type="default"
/>
)}
</AnimatePresence>
</div>
<div className={styles.detailContainer}>
@@ -242,13 +224,11 @@ const DefaultItemCard = ({
};
const PosterItemCard = ({
controls,
data,
imageUrl,
isRound,
itemType,
onClick,
onItemExpand,
onItemSelect,
rows,
setShowControls,
showControls,
@@ -259,7 +239,7 @@ const PosterItemCard = ({
<div className={clsx(styles.container, styles.poster)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={(e) => onClick?.(e, data, itemType)}
onClick={(e) => controls?.onClick?.(data, itemType, e)}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
@@ -268,7 +248,14 @@ const PosterItemCard = ({
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="poster" />}
{withControls && showControls && (
<ItemCardControls
controls={controls}
item={data}
itemType={itemType}
type="poster"
/>
)}
</AnimatePresence>
</div>
<div className={styles.detailContainer}>
@@ -6,6 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { getServerById } from '/@/renderer/store';
export interface InfiniteListProps<TQuery> {
itemsPerPage?: number;
query: Omit<TQuery, 'limit' | 'startIndex'>;
serverId: string;
}
@@ -6,17 +6,6 @@
padding: 0 var(--theme-spacing-md);
}
.auto-sizer {
width: 100% !important;
height: 100% !important;
}
.list-container {
display: flex;
width: 100%;
height: 100%;
}
.grid-list-container {
width: 100%;
padding: 0 var(--theme-spacing-md);
@@ -35,11 +24,6 @@
overflow: hidden;
}
.full-width-content {
grid-column: 1 / -1;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 10%);
}
.list-expanded-container {
width: 100%;
height: 100%;
@@ -4,7 +4,6 @@ import { AnimatePresence, motion, Variants } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import {
CSSProperties,
MouseEvent,
Ref,
UIEvent,
useCallback,
@@ -21,46 +20,42 @@ import styles from './item-grid-list.module.css';
import { getDataRowsCount, ItemCard } from '/@/renderer/components/item-card/item-card';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import {
ItemListItem,
ItemListStateActions,
useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export interface GridItemProps {
columns: number;
data: any[];
enableExpansion?: boolean;
enableSelection?: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
}
export interface ItemGridListProps {
data: unknown[];
enableExpansion?: boolean;
enableSelection?: boolean;
initialTopMostItemIndex?:
| number
| {
align: 'center' | 'end' | 'start';
behavior: 'auto' | 'smooth';
index: number;
offset?: number;
};
initialTop?: {
behavior?: 'auto' | 'smooth';
to: number;
type: 'index' | 'offset';
};
itemType: LibraryItem;
onEndReached?: (index: number) => void;
onItemClick?: (item: unknown, index: number) => void;
onItemContextMenu?: (item: unknown, index: number) => void;
onItemDoubleClick?: (item: unknown, index: number) => void;
onEndReached?: (index: number, handle: ItemListHandle) => void;
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
onScroll?: (e: UIEvent<HTMLDivElement>) => void;
onScrollEnd?: () => void;
onStartReached?: (index: number) => void;
onScrollEnd?: (offset: number, handle: ItemListHandle) => void;
onStartReached?: (index: number, handle: ItemListHandle) => void;
ref: Ref<ListImperativeAPI>;
totalItemCount?: number;
}
interface ItemContext {
enableExpansion?: boolean;
enableSelection?: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
onItemClick?: (item: unknown, index: number) => void;
onItemContextMenu?: (item: unknown, index: number) => void;
onItemDoubleClick?: (item: unknown, index: number) => void;
}
const expandedAnimationVariants: Variants = {
hidden: {
height: 0,
@@ -79,15 +74,10 @@ export const ItemGridList = ({
data,
enableExpansion = false,
enableSelection = false,
initialTopMostItemIndex = 0,
itemType,
onEndReached,
onItemClick,
onItemContextMenu,
onItemDoubleClick,
onRangeChanged,
onScroll,
onScrollEnd,
onStartReached,
totalItemCount = 0,
}: ItemGridListProps) => {
@@ -133,19 +123,6 @@ export const ItemGridList = ({
const hasExpanded = internalState.hasExpanded();
const handleExpand = useCallback(
(_e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => {
if (item && typeof item === 'object' && 'id' in item && 'serverId' in item) {
internalState.toggleExpanded({
id: item.id as string,
itemType: itemType,
serverId: item.serverId as string,
});
}
},
[internalState],
);
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
onScroll?.(e);
@@ -218,14 +195,21 @@ export const ItemGridList = ({
const endRow = visibleRows.stopIndex;
if (startRow === 0) {
onStartReached?.(startRow);
onStartReached?.(startRow, itemGridRef.current ?? (undefined as any));
}
if (endRow >= totalRows) {
onEndReached?.(endRow);
onEndReached?.(endRow, itemGridRef.current ?? (undefined as any));
}
}
},
[onEndReached, onRangeChanged, onStartReached, totalItemCount, tableMeta?.columnCount],
[
onRangeChanged,
tableMeta?.columnCount,
onStartReached,
onEndReached,
totalItemCount,
itemGridRef,
],
);
const elements = useMemo(() => {
@@ -253,6 +237,15 @@ export const ItemGridList = ({
);
}, [tableMeta, data]);
const itemProps: GridItemProps = {
columns: tableMeta?.columnCount || 0,
data: elements,
enableExpansion,
enableSelection,
internalState,
itemType,
};
return (
<motion.div
animate={{
@@ -272,15 +265,10 @@ export const ItemGridList = ({
listRef={itemGridRef}
onRowsRendered={handleOnRowsRendered}
onScroll={handleScroll}
rowComponent={RowComponent}
rowComponent={ListComponent}
rowCount={tableMeta?.rowCount || 0}
rowHeight={tableMeta?.itemHeight || 0}
rowProps={{
columns: tableMeta?.columnCount || 0,
data: elements,
handleExpand,
itemType,
}}
rowProps={itemProps}
/>
<AnimatePresence>
{hasExpanded && (
@@ -300,19 +288,16 @@ export const ItemGridList = ({
);
};
function RowComponent({
const ListComponent = ({
columns,
data,
handleExpand,
enableExpansion,
enableSelection,
index,
internalState,
itemType,
style,
}: RowComponentProps<{
columns: number;
data: any[];
handleExpand: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
itemType: LibraryItem;
}>) {
}: RowComponentProps<GridItemProps>) => {
return (
<div className={styles.itemList} style={style}>
{data[index].map((d) => (
@@ -322,14 +307,104 @@ function RowComponent({
style={{ '--columns': columns } as CSSProperties}
>
<ItemCard
controls={{
onClick: enableSelection
? (item, itemType) => {
return handleItemClick(item, itemType, internalState);
}
: undefined,
onDoubleClick: (item, itemType) => {
return handleItemDoubleClick(item, itemType, internalState);
},
onFavorite: (item, itemType) => {
return handleItemFavorite(item, itemType, internalState);
},
onItemExpand: enableExpansion
? (item, itemType) => {
return handleItemExpand(item, itemType, internalState);
}
: undefined,
onMore: (item, itemType) => {
return handleItemMore(item, itemType, internalState);
},
onPlay: (item, itemType, playType) => {
return handleItemPlay(item, itemType, playType, internalState);
},
onRating: (item, itemType) => {
return handleItemRating(item, itemType, internalState);
},
}}
data={d.data}
itemType={itemType}
onClick={(e, item, itemType) => handleExpand(e, item, itemType)}
type="poster"
withControls
/>
</div>
))}
</div>
);
}
};
const handleItemClick = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemClick', item, itemType, internalState);
};
const handleItemDoubleClick = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemDoubleClick', item, itemType, internalState);
};
const handleItemExpand = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
if (!item) {
return;
}
return internalState.toggleExpanded({
id: item.id,
itemType,
serverId: item.serverId,
});
};
const handleItemFavorite = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemFavorite', item, itemType, internalState);
};
const handleItemRating = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemRating', item, itemType, internalState);
};
const handleItemMore = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemMore', item, itemType, internalState);
};
const handleItemPlay = (
item: (ItemListItem & object) | undefined,
itemType: LibraryItem,
playType: Play,
internalState: ItemListStateActions,
) => {
console.log('handleItemPlay', item, itemType, playType, internalState);
};
@@ -1,13 +1,9 @@
import { useSearchParams } from 'react-router-dom';
interface UseItemListPaginationProps {
initialPage?: number;
}
export const useItemListPagination = ({ initialPage }: UseItemListPaginationProps) => {
export const useItemListPagination = () => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = initialPage || Number(searchParams.get('currentPage')) || 0;
const currentPage = Number(searchParams.get('currentPage')) || 0;
const onChange = (index: number) => {
setSearchParams(
@@ -5,6 +5,7 @@ import { CellComponentProps } from 'react-window-v2';
import styles from './item-table-list-column.module.css';
import i18n from '/@/i18n/i18n';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
import { CountColumn } from '/@/renderer/components/item-list/item-table-list/columns/count-column';
@@ -23,13 +24,13 @@ import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/c
import { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column';
import { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column';
import { TextColumn } from '/@/renderer/components/item-list/item-table-list/columns/text-column';
import { CellProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { TableColumn } from '/@/shared/types/types';
import { createDoubleClickHandler } from '/@/shared/utils/double-click-handler';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play, TableColumn } from '/@/shared/types/types';
export interface ItemTableListColumn extends CellComponentProps<CellProps> {}
export interface ItemTableListColumn extends CellComponentProps<TableItemProps> {}
export interface ItemTableListInnerColumn extends ItemTableListColumn {
type: TableColumn;
@@ -159,18 +160,6 @@ export const TableColumnTextContainer = (
props.enableRowBorders && props.enableHeader && props.rowIndex > 0,
})}
data-row-index={isDataRow ? props.rowIndex : undefined}
onClick={createDoubleClickHandler<HTMLDivElement>({
onDoubleClick: (e) => {
props.onItemDoubleClick?.(props.data[props.rowIndex], dataIndex, e);
},
onSingleClick: (e) => {
props.onItemClick?.(props.data[props.rowIndex], dataIndex, e);
props.handleExpand(e, props.data[props.rowIndex], props.itemType);
},
})}
onContextMenu={(e) => {
props.onItemContextMenu?.(props.data[props.rowIndex], dataIndex, e);
}}
ref={containerRef}
style={props.style}
>
@@ -237,18 +226,6 @@ export const TableColumnContainer = (
props.enableRowBorders && props.enableHeader && props.rowIndex > 0,
})}
data-row-index={isDataRow ? props.rowIndex : undefined}
onClick={createDoubleClickHandler<HTMLDivElement>({
onDoubleClick: (e) => {
props.onItemDoubleClick?.(props.data[props.rowIndex], dataIndex, e);
},
onSingleClick: (e) => {
props.onItemClick?.(props.data[props.rowIndex], dataIndex, e);
props.handleExpand(e, props.data[props.rowIndex], props.itemType);
},
})}
onContextMenu={(e) => {
props.onItemContextMenu?.(props.data[props.rowIndex], dataIndex, e);
}}
ref={containerRef}
style={props.style}
>
@@ -283,8 +260,73 @@ export const TableColumnHeaderContainer = (
);
};
const handleItemClick = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemClick', item, itemType, internalState);
};
const handleItemExpand = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemExpand', item, itemType, internalState);
};
const handleItemSelect = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemSelect', item, itemType, internalState);
};
const handleItemDoubleClick = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemDoubleClick', item, itemType, internalState);
};
const handleItemFavorite = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemFavorite', item, itemType, internalState);
};
const handleItemRating = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemRating', item, itemType, internalState);
};
const handleItemMore = (
item: unknown,
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemMore', item, itemType, internalState);
};
const handleItemPlay = (
item: unknown,
itemType: LibraryItem,
playType: Play,
internalState: ItemListStateActions,
) => {
console.log('handleItemPlay', item, itemType, playType, internalState);
};
const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.ACTIONS]: '',
[TableColumn.ACTIONS]: <Icon fill="default" icon="ellipsisHorizontal" size="md" />,
[TableColumn.ALBUM]: i18n.t('table.column.album', { postProcess: 'upperCase' }) as string,
[TableColumn.ALBUM_ARTIST]: i18n.t('table.column.albumArtist', {
postProcess: 'upperCase',
@@ -335,9 +377,7 @@ const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.TRACK_NUMBER]: i18n.t('table.column.trackNumber', {
postProcess: 'upperCase',
}) as string,
[TableColumn.USER_FAVORITE]: i18n.t('table.column.favorite', {
postProcess: 'upperCase',
}) as string,
[TableColumn.USER_FAVORITE]: <Icon fill="default" icon="favorite" size="md" />,
[TableColumn.USER_RATING]: i18n.t('table.column.rating', {
postProcess: 'upperCase',
}) as string,
@@ -6,38 +6,28 @@ import { AnimatePresence, motion, Variants } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import {
type JSXElementConstructor,
MouseEvent,
Ref,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { type CellComponentProps, Grid, GridImperativeAPI, type GridProps } from 'react-window-v2';
import { type CellComponentProps, Grid, type GridProps } from 'react-window-v2';
import styles from './item-table-list.module.css';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { useItemListState } from '/@/renderer/components/item-list/helpers/item-list-state';
import {
ItemListStateActions,
useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { sortTableColumns } from '/@/renderer/components/item-list/helpers/sort-table-columns';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { TableColumn } from '/@/shared/types/types';
export interface CellProps {
columns: ItemTableListColumnConfig[];
data: unknown[];
enableHeader?: boolean;
enableRowBorders?: boolean;
enableRowHover?: boolean;
handleExpand: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
itemType: LibraryItem;
onItemClick?: (item: unknown, index: number, event: MouseEvent<HTMLDivElement>) => void;
onItemContextMenu?: (item: unknown, index: number, event: MouseEvent<HTMLDivElement>) => void;
onItemDoubleClick?: (item: unknown, index: number, event: MouseEvent<HTMLDivElement>) => void;
size?: 'compact' | 'default';
}
export interface ItemTableListColumnConfig {
align: 'center' | 'end' | 'start';
id: TableColumn;
@@ -45,8 +35,21 @@ export interface ItemTableListColumnConfig {
width: number;
}
export interface TableItemProps {
columns: ItemTableListColumnConfig[];
data: unknown[];
enableExpansion?: boolean;
enableHeader?: boolean;
enableRowBorders?: boolean;
enableRowHover?: boolean;
enableSelection?: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
size?: 'compact' | 'default';
}
interface ItemTableListProps {
CellComponent: JSXElementConstructor<CellComponentProps<CellProps>>;
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
columns: ItemTableListColumnConfig[];
data: unknown[];
enableExpansion?: boolean;
@@ -61,18 +64,17 @@ interface ItemTableListProps {
type: 'index' | 'offset';
};
itemType: LibraryItem;
onCellsRendered?: GridProps<CellProps>['onCellsRendered'];
onEndReached?: (index: number) => void;
onItemClick?: (item: unknown, index: number, event: MouseEvent<HTMLDivElement>) => void;
onItemContextMenu?: (item: unknown, index: number, event: MouseEvent<HTMLDivElement>) => void;
onItemDoubleClick?: (item: unknown, index: number, event: MouseEvent<HTMLDivElement>) => void;
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
onScrollEnd?: (offset: number) => void;
onStartReached?: (index: number) => void;
ref?: Ref<GridImperativeAPI>;
rowHeight: ((index: number, cellProps: CellProps) => number) | number;
onCellsRendered?: GridProps<TableItemProps>['onCellsRendered'];
onEndReached?: (index: number, internalState: ItemListStateActions) => void;
onRangeChanged?: (
range: { endIndex: number; startIndex: number },
internalState: ItemListStateActions,
) => void;
onScrollEnd?: (offset: number, internalState: ItemListStateActions) => void;
onStartReached?: (index: number, internalState: ItemListStateActions) => void;
ref?: Ref<ItemListHandle>;
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
size?: 'compact' | 'default';
totalItemCount: number;
}
const expandedAnimationVariants: Variants = {
@@ -97,26 +99,24 @@ export const ItemTableList = ({
enableHeader = true,
enableRowBorders = false,
enableRowHover = false,
enableSelection = false,
headerHeight = 40,
initialTop,
itemType,
onCellsRendered,
onEndReached,
onItemClick,
onItemContextMenu,
onItemDoubleClick,
onRangeChanged,
onScrollEnd,
onStartReached,
ref,
rowHeight,
size = 'default',
totalItemCount,
}: ItemTableListProps) => {
const totalItemCount = data.length;
const sortedColumns = useMemo(() => sortTableColumns(columns), [columns]);
const columnCount = sortedColumns.length;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const columnWidth = (index: number, _cellProps: CellProps) => sortedColumns[index].width;
const columnWidth = (index: number, _cellProps: TableItemProps) => sortedColumns[index].width;
const pinnedLeftColumnCount = sortedColumns.filter((col) => col.pinned === 'left').length;
const pinnedRightColumnCount = sortedColumns.filter((col) => col.pinned === 'right').length;
@@ -131,8 +131,53 @@ export const ItemTableList = ({
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(false);
const handleRef = useRef<ItemListHandle | null>(null);
const onScrollEndRef = useRef<ItemTableListProps['onScrollEnd']>(onScrollEnd);
useEffect(() => {
onScrollEndRef.current = onScrollEnd;
}, [onScrollEnd]);
const scrollToTableOffset = useCallback((offset: number) => {
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
const pinnedLeftContainer = pinnedLeftColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
if (mainContainer) {
mainContainer.scrollTo({ behavior: 'instant', top: offset });
}
if (pinnedLeftContainer) {
pinnedLeftContainer.scrollTo({ behavior: 'instant', top: offset });
}
if (pinnedRightContainer) {
pinnedRightContainer.scrollTo({ behavior: 'instant', top: offset });
}
}, []);
const calculateScrollTopForIndex = useCallback(
(index: number) => {
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
let scrollTop = 0;
for (let i = 0; i < adjustedIndex; i++) {
const height = rowHeight as number;
scrollTop += height;
}
return scrollTop;
},
[enableHeader, rowHeight],
);
const scrollToTableIndex = useCallback(
(index: number) => {
const offset = calculateScrollTopForIndex(index);
scrollToTableOffset(offset);
},
[calculateScrollTopForIndex, scrollToTableOffset],
);
const [initialize] = useOverlayScrollbars({
defer: true,
@@ -232,7 +277,10 @@ export const ItemTableList = ({
scrollTimeouts.delete(element);
if (element === row && onScrollEndRef.current) {
onScrollEndRef.current(row.scrollTop);
onScrollEndRef.current(
row.scrollTop,
handleRef.current ?? (undefined as any),
);
}
}, 150);
@@ -430,9 +478,11 @@ export const ItemTableList = ({
}, [pinnedLeftColumnCount, pinnedRightColumnCount]);
const getRowHeight = useCallback(
(index: number, cellProps: CellProps) => {
(index: number, cellProps: TableItemProps) => {
const height = size === 'compact' ? 40 : 68;
const baseHeight =
typeof rowHeight === 'number' ? rowHeight : rowHeight(index, cellProps);
typeof rowHeight === 'number' ? rowHeight : rowHeight?.(index, cellProps) || height;
// If enableHeader is true and this is the first sticky row, use fixed header height
if (enableHeader && index === 0 && pinnedRowCount > 0) {
@@ -441,30 +491,13 @@ export const ItemTableList = ({
return baseHeight;
},
[enableHeader, headerHeight, rowHeight, pinnedRowCount],
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
);
const internalState = useItemListState();
const hasExpanded = internalState.hasExpanded();
const handleExpand = useCallback(
(_e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => {
if (!enableExpansion) {
return;
}
if (item && typeof item === 'object' && 'id' in item && 'serverId' in item) {
internalState.toggleExpanded({
id: item.id as string,
itemType: itemType,
serverId: item.serverId as string,
});
}
},
[enableExpansion, internalState],
);
const handleOnCellsRendered = useCallback(
(cells: {
columnStartIndex: number;
@@ -472,18 +505,21 @@ export const ItemTableList = ({
rowStartIndex: number;
rowStopIndex: number;
}) => {
onRangeChanged?.({
endIndex: cells.rowStopIndex,
startIndex: cells.rowStartIndex,
});
onRangeChanged?.(
{
endIndex: cells.rowStopIndex,
startIndex: cells.rowStartIndex,
},
internalState,
);
if (onStartReached || onEndReached) {
if (cells.rowStartIndex === 0) {
onStartReached?.(0);
onStartReached?.(0, handleRef.current ?? (undefined as any));
}
if (cells.rowStopIndex + 10 >= totalItemCount) {
onEndReached?.(totalItemCount);
onEndReached?.(totalItemCount, handleRef.current ?? (undefined as any));
}
}
@@ -511,11 +547,12 @@ export const ItemTableList = ({
pinnedLeftColumnCount,
pinnedRowCount,
totalItemCount,
internalState,
],
);
const PinnedRowCell = useCallback(
(cellProps: CellComponentProps & CellProps) => {
(cellProps: CellComponentProps & TableItemProps) => {
return (
<CellComponent
{...cellProps}
@@ -527,14 +564,14 @@ export const ItemTableList = ({
);
const PinnedColumnCell = useCallback(
(cellProps: CellComponentProps & CellProps) => {
(cellProps: CellComponentProps & TableItemProps) => {
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
},
[pinnedRowCount, CellComponent],
);
const PinnedRightColumnCell = useCallback(
(cellProps: CellComponentProps & CellProps) => {
(cellProps: CellComponentProps & TableItemProps) => {
return (
<CellComponent
{...cellProps}
@@ -547,7 +584,7 @@ export const ItemTableList = ({
);
const PinnedRightIntersectionCell = useCallback(
(cellProps: CellComponentProps & CellProps) => {
(cellProps: CellComponentProps & TableItemProps) => {
return (
<CellComponent
{...cellProps}
@@ -559,7 +596,7 @@ export const ItemTableList = ({
);
const RowCell = useCallback(
(cellProps: CellComponentProps<CellProps>) => {
(cellProps: CellComponentProps<TableItemProps>) => {
return (
<CellComponent
{...cellProps}
@@ -571,17 +608,16 @@ export const ItemTableList = ({
[pinnedLeftColumnCount, pinnedRowCount, CellComponent],
);
const cellProps = {
const itemProps: TableItemProps = {
columns: sortedColumns,
data,
enableExpansion,
enableHeader,
enableRowBorders,
enableRowHover,
handleExpand,
enableSelection,
internalState,
itemType,
onItemClick,
onItemContextMenu,
onItemDoubleClick,
size,
};
@@ -589,92 +625,41 @@ export const ItemTableList = ({
useEffect(() => {
if (!initialTop || isInitialScrollPositionSet.current) return;
isInitialScrollPositionSet.current = true;
const scrollToIndex = (index: number, behavior: 'auto' | 'smooth' = 'auto') => {
isInitialScrollPositionSet.current = true;
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
// Calculate scroll position based on row heights
const calculateScrollTop = (rowIndex: number) => {
let scrollTop = 0;
for (let i = 0; i < rowIndex; i++) {
const height = rowHeight as number;
scrollTop += height;
}
return scrollTop;
};
const scrollTop = calculateScrollTop(adjustedIndex);
// Get the scroll containers and scroll them directly
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement;
const pinnedLeftContainer = pinnedLeftColumnRef.current
?.childNodes[0] as HTMLDivElement;
const pinnedRightContainer = pinnedRightColumnRef.current
?.childNodes[0] as HTMLDivElement;
if (initialTop.type === 'offset') {
if (mainContainer) {
mainContainer.scrollTo({
behavior,
top: initialTop.to,
});
}
if (pinnedLeftContainer) {
pinnedLeftContainer.scrollTo({
behavior,
top: initialTop.to,
});
}
if (pinnedRightContainer) {
pinnedRightContainer.scrollTo({
behavior,
top: initialTop.to,
});
}
} else {
if (mainContainer) {
mainContainer.scrollTo({
behavior,
top: scrollTop,
});
}
if (pinnedLeftContainer) {
pinnedLeftContainer.scrollTo({
behavior,
top: scrollTop,
});
}
if (pinnedRightContainer) {
pinnedRightContainer.scrollTo({
behavior,
top: scrollTop,
});
}
}
};
scrollToIndex(initialTop.to, initialTop.behavior);
}, [initialTop, enableHeader, pinnedLeftColumnCount, pinnedRightColumnCount, rowHeight]);
// Expose grid refs to parent component
useEffect(() => {
if (ref && typeof ref === 'object') {
// Create a simple API that exposes the main container
const combinedAPI: GridImperativeAPI = {
// We'll create a minimal API that can be extended later
// For now, we'll just expose the main container ref
} as GridImperativeAPI;
if ('current' in ref) {
(ref as React.MutableRefObject<GridImperativeAPI>).current = combinedAPI;
}
if (initialTop.type === 'offset') {
scrollToTableOffset(initialTop.to);
} else {
scrollToTableIndex(initialTop.to);
}
}, [ref]);
}, [initialTop, scrollToTableIndex, scrollToTableOffset]);
const imperativeHandle: ItemListHandle = useMemo(() => {
return {
clearExpanded: () => {
internalState.clearExpanded();
},
clearSelected: () => {
internalState.clearSelected();
},
getItem: (index: number) => (enableHeader ? data[index + 1] : data[index]),
getItemCount: () => (enableHeader ? data.length - 1 : data.length),
getItems: () => data,
internalState,
scrollToIndex: (index: number) => {
scrollToTableIndex(enableHeader ? index + 1 : index);
},
scrollToOffset: (offset: number) => {
scrollToTableOffset(enableHeader ? offset + headerHeight : offset);
},
};
}, [data, enableHeader, headerHeight, internalState, scrollToTableIndex, scrollToTableOffset]);
useImperativeHandle(ref, () => imperativeHandle);
useEffect(() => {
handleRef.current = imperativeHandle;
}, [imperativeHandle]);
return (
<motion.div
@@ -694,7 +679,7 @@ export const ItemTableList = ({
className={styles.itemTablePinnedColumnsGridContainer}
style={{
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(
(a, _, i) => a + columnWidth(i, cellProps),
(a, _, i) => a + columnWidth(i, itemProps),
0,
)}px`,
}}
@@ -708,12 +693,12 @@ export const ItemTableList = ({
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
}}
>
<Grid
cellComponent={CellComponent as any}
cellProps={cellProps}
cellProps={itemProps}
className={styles.noScrollbar}
columnCount={pinnedLeftColumnCount}
columnWidth={columnWidth}
@@ -730,7 +715,7 @@ export const ItemTableList = ({
>
<Grid
cellComponent={PinnedColumnCell}
cellProps={cellProps}
cellProps={itemProps}
className={clsx(styles.noScrollbar, styles.height100)}
columnCount={pinnedLeftColumnCount}
columnWidth={columnWidth}
@@ -755,13 +740,13 @@ export const ItemTableList = ({
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
} as React.CSSProperties
}
>
<Grid
cellComponent={PinnedRowCell}
cellProps={cellProps}
cellProps={itemProps}
className={styles.noScrollbar}
columnCount={totalColumnCount}
columnWidth={(index, cellProps) => {
@@ -776,7 +761,7 @@ export const ItemTableList = ({
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid
cellComponent={RowCell}
cellProps={cellProps}
cellProps={itemProps}
className={styles.height100}
columnCount={totalColumnCount}
columnWidth={(index, cellProps) => {
@@ -808,7 +793,7 @@ export const ItemTableList = ({
a +
columnWidth(
i + pinnedLeftColumnCount + totalColumnCount,
cellProps,
itemProps,
),
0,
)}px`,
@@ -823,12 +808,12 @@ export const ItemTableList = ({
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
}}
>
<Grid
cellComponent={PinnedRightIntersectionCell}
cellProps={cellProps}
cellProps={itemProps}
className={styles.noScrollbar}
columnCount={pinnedRightColumnCount}
columnWidth={(index, cellProps) => {
@@ -851,7 +836,7 @@ export const ItemTableList = ({
>
<Grid
cellComponent={PinnedRightColumnCell}
cellProps={cellProps}
cellProps={itemProps}
className={clsx(styles.noScrollbar, styles.height100)}
columnCount={pinnedRightColumnCount}
columnWidth={(index, cellProps) => {
@@ -0,0 +1,68 @@
import { MouseEvent } from 'react';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export interface ItemControls {
onClick?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
e: MouseEvent<HTMLDivElement>,
) => void;
onDoubleClick?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
e: MouseEvent<HTMLDivElement>,
) => void;
onFavorite?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
e: MouseEvent<HTMLButtonElement>,
) => void;
onItemExpand?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
e: MouseEvent<HTMLButtonElement>,
) => void;
onMore?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
e: MouseEvent<HTMLButtonElement>,
) => void;
onPlay?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
playType: Play,
e: MouseEvent<HTMLButtonElement>,
) => void;
onRating?: (
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
e: MouseEvent<HTMLDivElement>,
) => void;
}
export interface ItemListComponentProps<TQuery> {
itemsPerPage?: number;
query: Omit<TQuery, 'limit' | 'startIndex'>;
serverId: string;
}
export interface ItemListHandle {
clearExpanded: () => void;
clearSelected: () => void;
getItem: (index: number) => unknown;
getItemCount: () => number;
getItems: () => unknown[];
internalState: ItemListStateActions;
scrollToIndex: (index: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
}