diff --git a/src/renderer/components/item-card/item-card-controls.module.css b/src/renderer/components/item-card/item-card-controls.module.css index 5b788c398..c9d961e45 100644 --- a/src/renderer/components/item-card/item-card-controls.module.css +++ b/src/renderer/components/item-card/item-card-controls.module.css @@ -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; +} diff --git a/src/renderer/components/item-card/item-card-controls.tsx b/src/renderer/components/item-card/item-card-controls.tsx index d43ddbc30..dadab8079 100644 --- a/src/renderer/components/item-card/item-card-controls.tsx +++ b/src/renderer/components/item-card/item-card-controls.tsx @@ -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 ( - - - - - + { + e.stopPropagation(); + controls.onPlay?.(item, itemType, Play.NOW, e); + }} + /> + { + e.stopPropagation(); + controls.onPlay?.(item, itemType, Play.NEXT, e); + }} + /> + { + e.stopPropagation(); + controls.onPlay?.(item, itemType, Play.LAST, e); + }} + /> + { + e.stopPropagation(); + controls.onFavorite?.(item, itemType, e); + }} + /> + + { + e.stopPropagation(); + controls.onMore?.(item, itemType, e); + }} + /> + {controls.onItemExpand && ( + { + e.stopPropagation(); + controls.onItemExpand?.(item, itemType, e); + }} + /> + )} ); }; -const PlayButton = () => { +const PlayButton = ({ onClick }: { onClick?: (e: MouseEvent) => void }) => { return ( @@ -57,12 +124,20 @@ const PlayButton = () => { const SecondaryPlayButton = ({ className, icon, + onClick, }: { className?: string; icon: keyof typeof AppIcon; + onClick?: (e: MouseEvent) => void; }) => { return ( - ); @@ -71,11 +146,18 @@ const SecondaryPlayButton = ({ interface SecondaryButtonProps { className?: string; icon: keyof typeof AppIcon; + onClick?: (e: MouseEvent) => void; } -const SecondaryButton = ({ className, icon }: SecondaryButtonProps) => { +const SecondaryButton = ({ className, icon, onClick }: SecondaryButtonProps) => { return ( - ); diff --git a/src/renderer/components/item-card/item-card.module.css b/src/renderer/components/item-card/item-card.module.css index 45660de09..83e33ed94 100644 --- a/src/renderer/components/item-card/item-card.module.css +++ b/src/renderer/components/item-card/item-card.module.css @@ -28,7 +28,7 @@ &:hover { &::before { - opacity: 0.6; + opacity: 0.7; } } } diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 0ef816ea6..81bb65b1e 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -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, - 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 ( { + controls: ItemControls; imageUrl: string | undefined; rows: DataRow[]; setShowControls: Dispatch>; @@ -131,13 +106,11 @@ export interface ItemCardDerivativeProps extends Omit { } const CompactItemCard = ({ + controls, data, imageUrl, isRound, itemType, - onClick, - onItemExpand, - onItemSelect, rows, setShowControls, showControls, @@ -148,7 +121,6 @@ const CompactItemCard = ({
onClick?.(e, data, itemType)} onMouseEnter={() => withControls && setShowControls(true)} onMouseLeave={() => withControls && setShowControls(false)} > @@ -157,7 +129,14 @@ const CompactItemCard = ({ src={imageUrl} /> - {withControls && showControls && } + {withControls && showControls && ( + + )}
{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 = ({
onClick?.(e, data, itemType)} - onDoubleClick={onItemExpand} onMouseEnter={() => withControls && setShowControls(true)} onMouseLeave={() => withControls && setShowControls(false)} > @@ -213,7 +188,14 @@ const DefaultItemCard = ({ src={imageUrl} /> - {withControls && showControls && } + {withControls && showControls && ( + + )}
@@ -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 = ({
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} /> - {withControls && showControls && } + {withControls && showControls && ( + + )}
diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index 9e156e48f..392761700 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -6,6 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys'; import { getServerById } from '/@/renderer/store'; export interface InfiniteListProps { + itemsPerPage?: number; query: Omit; serverId: string; } diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css b/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css index 83e204f5e..05e88f296 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css @@ -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%; diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 9ddde325b..3a46eea71 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -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) => void; - onScrollEnd?: () => void; - onStartReached?: (index: number) => void; + onScrollEnd?: (offset: number, handle: ItemListHandle) => void; + onStartReached?: (index: number, handle: ItemListHandle) => void; ref: Ref; 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, 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) => { 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 ( {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, item: unknown, itemType: LibraryItem) => void; - itemType: LibraryItem; -}>) { +}: RowComponentProps) => { return (
{data[index].map((d) => ( @@ -322,14 +307,104 @@ function RowComponent({ style={{ '--columns': columns } as CSSProperties} > { + 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 />
))}
); -} +}; + +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); +}; diff --git a/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts b/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts index d7e1852c2..fa0518c6f 100644 --- a/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts +++ b/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts @@ -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( diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index a72eb1a32..da3cd0c80 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -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 {} +export interface ItemTableListColumn extends CellComponentProps {} 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({ - 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({ - 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.ACTIONS]: '', + [TableColumn.ACTIONS]: , [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.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]: , [TableColumn.USER_RATING]: i18n.t('table.column.rating', { postProcess: 'upperCase', }) as string, diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 92ff22616..9ecfc09ed 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -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, item: unknown, itemType: LibraryItem) => void; - itemType: LibraryItem; - onItemClick?: (item: unknown, index: number, event: MouseEvent) => void; - onItemContextMenu?: (item: unknown, index: number, event: MouseEvent) => void; - onItemDoubleClick?: (item: unknown, index: number, event: MouseEvent) => 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>; + CellComponent: JSXElementConstructor>; columns: ItemTableListColumnConfig[]; data: unknown[]; enableExpansion?: boolean; @@ -61,18 +64,17 @@ interface ItemTableListProps { type: 'index' | 'offset'; }; itemType: LibraryItem; - onCellsRendered?: GridProps['onCellsRendered']; - onEndReached?: (index: number) => void; - onItemClick?: (item: unknown, index: number, event: MouseEvent) => void; - onItemContextMenu?: (item: unknown, index: number, event: MouseEvent) => void; - onItemDoubleClick?: (item: unknown, index: number, event: MouseEvent) => void; - onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void; - onScrollEnd?: (offset: number) => void; - onStartReached?: (index: number) => void; - ref?: Ref; - rowHeight: ((index: number, cellProps: CellProps) => number) | number; + onCellsRendered?: GridProps['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; + 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(null); const onScrollEndRef = useRef(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, 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 ( { + (cellProps: CellComponentProps & TableItemProps) => { return ; }, [pinnedRowCount, CellComponent], ); const PinnedRightColumnCell = useCallback( - (cellProps: CellComponentProps & CellProps) => { + (cellProps: CellComponentProps & TableItemProps) => { return ( { + (cellProps: CellComponentProps & TableItemProps) => { return ( ) => { + (cellProps: CellComponentProps) => { return ( { 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).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 ( 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`, }} > 0, - ).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`, + ).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`, } as React.CSSProperties } > { @@ -776,7 +761,7 @@ export const ItemTableList = ({
{ @@ -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`, }} > { @@ -851,7 +836,7 @@ export const ItemTableList = ({ > { diff --git a/src/renderer/components/item-list/types.tsx b/src/renderer/components/item-list/types.tsx new file mode 100644 index 000000000..ea97a4f07 --- /dev/null +++ b/src/renderer/components/item-list/types.tsx @@ -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, + ) => void; + onDoubleClick?: ( + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + e: MouseEvent, + ) => void; + onFavorite?: ( + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + e: MouseEvent, + ) => void; + onItemExpand?: ( + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + e: MouseEvent, + ) => void; + onMore?: ( + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + e: MouseEvent, + ) => void; + onPlay?: ( + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + playType: Play, + e: MouseEvent, + ) => void; + onRating?: ( + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + e: MouseEvent, + ) => void; +} + +export interface ItemListComponentProps { + itemsPerPage?: number; + query: Omit; + 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; +}