diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 4dd257855..ea78154c8 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -34,6 +34,8 @@ interface ItemCardProps { data: Album | AlbumArtist | Artist | Playlist | Song; isRound?: boolean; onClick?: () => void; + onItemExpand?: () => void; + onItemSelect?: () => void; type?: 'compact' | 'default' | 'poster'; withControls?: boolean; } @@ -42,6 +44,8 @@ export const ItemCard = ({ data, isRound, onClick, + onItemExpand, + onItemSelect, type = 'poster', withControls, }: ItemCardProps) => { @@ -58,6 +62,8 @@ export const ItemCard = ({ imageUrl={imageUrl} isRound={isRound} onClick={onClick} + onItemExpand={onItemExpand} + onItemSelect={onItemSelect} rows={rows} setShowControls={setShowControls} showControls={showControls} @@ -71,6 +77,8 @@ export const ItemCard = ({ imageUrl={imageUrl} isRound={isRound} onClick={onClick} + onItemExpand={onItemExpand} + onItemSelect={onItemSelect} rows={rows} setShowControls={setShowControls} showControls={showControls} @@ -85,6 +93,8 @@ export const ItemCard = ({ imageUrl={imageUrl} isRound={isRound} onClick={onClick} + onItemExpand={onItemExpand} + onItemSelect={onItemSelect} rows={rows} setShowControls={setShowControls} showControls={showControls} @@ -106,6 +116,8 @@ const CompactItemCard = ({ imageUrl, isRound, onClick, + onItemExpand, + onItemSelect, rows, setShowControls, showControls, @@ -141,6 +153,8 @@ const DefaultItemCard = ({ imageUrl, isRound, onClick, + onItemExpand, + onItemSelect, rows, setShowControls, showControls, @@ -151,6 +165,7 @@ const DefaultItemCard = ({
withControls && setShowControls(true)} onMouseLeave={() => withControls && setShowControls(false)} > @@ -178,6 +193,8 @@ const PosterItemCard = ({ imageUrl, isRound, onClick, + onItemExpand, + onItemSelect, rows, setShowControls, showControls, diff --git a/src/renderer/components/item-grid/item-grid.tsx b/src/renderer/components/item-grid/item-grid.tsx deleted file mode 100644 index c1e56ff0b..000000000 --- a/src/renderer/components/item-grid/item-grid.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import clsx from 'clsx'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { - CSSProperties, - forwardRef, - memo, - ReactNode, - Ref, - RefObject, - useEffect, - useRef, - useState, -} from 'react'; -import { GridComponents, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso'; - -import styles from './item-grid.module.css'; - -import { ItemCard } from '/@/renderer/components/item-card/item-card'; - -const gridComponents: GridComponents = { - Item: forwardRef< - HTMLDivElement, - { - children?: ReactNode; - className?: string; - context?: Record; - 'data-index': number; - enableExpanded?: boolean; - style?: CSSProperties; - virtuosoRef?: RefObject; - } - >((props, ref) => { - const { children, 'data-index': index, enableExpanded, virtuosoRef } = props; - - return ( -
- {children} -
- ); - }), - List: forwardRef< - HTMLDivElement, - { children?: ReactNode; className?: string; style?: CSSProperties } - >((props, ref) => { - const { children, className, style, ...rest } = props; - - return ( -
- {children} -
- ); - }), -}; - -interface ItemGridProps { - data: TData[]; - ref: Ref; - totalItemCount?: number; -} - -export const ItemGrid = ({ data, ref, totalItemCount }: ItemGridProps) => { - const rootRef = useRef(null); - - const [scroller, setScroller] = useState(null); - - const [initialize, osInstance] = useOverlayScrollbars({ - defer: true, - options: { - overflow: { x: 'hidden', y: 'scroll' }, - paddingAbsolute: true, - scrollbars: { - autoHide: 'leave', - autoHideDelay: 500, - pointers: ['mouse', 'pen', 'touch'], - theme: 'feishin-os-scrollbar', - visibility: 'visible', - }, - }, - }); - - useEffect(() => { - const { current: root } = rootRef; - - if (scroller && root) { - initialize({ - elements: { viewport: scroller }, - target: root, - }); - } - - return () => osInstance()?.destroy(); - }, [scroller, initialize, osInstance]); - - return ( -
- -
- ); -}; - -const itemContent = (index: number, item: any) => { - return ; -}; - -const InnerItem = memo(({ index, item }: { index: number; item: any }) => { - return ; -}); diff --git a/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts b/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts new file mode 100644 index 000000000..bca85dd58 --- /dev/null +++ b/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts @@ -0,0 +1,135 @@ +import { ItemListAction, ItemListItem, ItemListState } from './item-list-state'; + +/** + * Action creators for item grid state management + * These can be reused across different components and contexts + */ +export const itemGridActions = { + clearAll: (): ItemListAction => ({ + type: 'CLEAR_ALL', + }), + + clearExpanded: (): ItemListAction => ({ + type: 'CLEAR_EXPANDED', + }), + + clearSelected: (): ItemListAction => ({ + type: 'CLEAR_SELECTED', + }), + + setExpanded: (items: ItemListItem[]): ItemListAction => ({ + payload: items, + type: 'SET_EXPANDED', + }), + + setSelected: (items: ItemListItem[]): ItemListAction => ({ + payload: items, + type: 'SET_SELECTED', + }), + + toggleExpanded: (item: ItemListItem): ItemListAction => ({ + payload: item, + type: 'TOGGLE_EXPANDED', + }), + + toggleSelected: (item: ItemListItem): ItemListAction => ({ + payload: item, + type: 'TOGGLE_SELECTED', + }), +}; + +/** + * Selector functions for item grid state + * These can be reused to extract specific data from state + */ +export const itemGridSelectors = { + getExpanded: (state: ItemListState): ItemListItem[] => { + return Array.from(state.expandedItems.values()); + }, + + getExpandedCount: (state: ItemListState): number => { + return state.expanded.size; + }, + + getExpandedIds: (state: ItemListState): string[] => { + return Array.from(state.expanded); + }, + + getSelected: (state: ItemListState): ItemListItem[] => { + return Array.from(state.selectedItems.values()); + }, + + getSelectedCount: (state: ItemListState): number => { + return state.selected.size; + }, + + getSelectedIds: (state: ItemListState): string[] => { + return Array.from(state.selected); + }, + + getVersion: (state: ItemListState): number => { + return state.version; + }, + + hasAnyExpanded: (state: ItemListState): boolean => { + return state.expanded.size > 0; + }, + + hasAnySelected: (state: ItemListState): boolean => { + return state.selected.size > 0; + }, + + isExpanded: (state: ItemListState, itemId: string): boolean => { + return state.expanded.has(itemId); + }, + + isSelected: (state: ItemListState, itemId: string): boolean => { + return state.selected.has(itemId); + }, +}; + +export const itemListUtils = { + /** + * Check if all items in a list are selected + */ + areAllSelected: (state: ItemListState, itemIds: string[]): boolean => { + return itemIds.every((id) => state.selected.has(id)); + }, + + /** + * Check if any items in a list are selected + */ + areAnySelected: (state: ItemListState, itemIds: string[]): boolean => { + return itemIds.some((id) => state.selected.has(id)); + }, + + /** + * Check if multiple items are expanded + */ + isMultiExpand: (state: ItemListState): boolean => { + return state.expanded.size > 1; + }, + + /** + * Check if multiple items are selected + */ + isMultiSelect: (state: ItemListState): boolean => { + return state.selected.size > 1; + }, + + /** + * Toggle expansion of all items in a list + */ + toggleAllExpanded: (items: ItemListItem[], currentState: ItemListState): ItemListAction => { + const allExpanded = items.every((item) => currentState.expanded.has(item.id)); + return allExpanded ? itemGridActions.clearExpanded() : itemGridActions.setExpanded(items); + }, + + /** + * Toggle selection of all items in a list + */ + toggleAllSelected: (items: ItemListItem[], currentState: ItemListState): ItemListAction => { + const allSelected = items.every((item) => currentState.selected.has(item.id)); + return allSelected ? itemGridActions.clearSelected() : itemGridActions.setSelected(items); + }, +}; diff --git a/src/renderer/components/item-list/helpers/item-list-state.ts b/src/renderer/components/item-list/helpers/item-list-state.ts new file mode 100644 index 000000000..e9c2900b1 --- /dev/null +++ b/src/renderer/components/item-list/helpers/item-list-state.ts @@ -0,0 +1,262 @@ +import { useCallback, useReducer } from 'react'; + +import { itemGridSelectors } from '/@/renderer/components/item-list/helpers/item-list-reducer-utils'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +export type ItemListAction = + | { payload: ItemListItem; type: 'TOGGLE_EXPANDED' } + | { payload: ItemListItem; type: 'TOGGLE_SELECTED' } + | { payload: ItemListItem[]; type: 'SET_EXPANDED' } + | { payload: ItemListItem[]; type: 'SET_SELECTED' } + | { type: 'CLEAR_ALL' } + | { type: 'CLEAR_EXPANDED' } + | { type: 'CLEAR_SELECTED' }; + +export interface ItemListItem { + id: string; + itemType: LibraryItem; +} + +export interface ItemListState { + expanded: Set; + expandedItems: Map; + selected: Set; + selectedItems: Map; + version: number; +} + +export interface ItemListStateActions { + clearAll: () => void; + clearExpanded: () => void; + clearSelected: () => void; + getExpanded: () => ItemListItem[]; + getExpandedIds: () => string[]; + getSelected: () => ItemListItem[]; + getSelectedIds: () => string[]; + getVersion: () => number; + hasExpanded: () => boolean; + hasSelected: () => boolean; + isExpanded: (itemId: string) => boolean; + isSelected: (itemId: string) => boolean; + setExpanded: (items: ItemListItem[]) => void; + setSelected: (items: ItemListItem[]) => void; + toggleExpanded: (item: ItemListItem) => void; + toggleSelected: (item: ItemListItem) => void; +} + +/** + * Reusable reducer for item grid state management + * Can be used in different components or contexts + */ +export const itemListReducer = (state: ItemListState, action: ItemListAction): ItemListState => { + switch (action.type) { + case 'CLEAR_ALL': + return { + ...state, + expanded: new Set(), + expandedItems: new Map(), + selected: new Set(), + selectedItems: new Map(), + version: state.version + 1, + }; + + case 'CLEAR_EXPANDED': + return { + ...state, + expanded: new Set(), + expandedItems: new Map(), + version: state.version + 1, + }; + + case 'CLEAR_SELECTED': + return { + ...state, + selected: new Set(), + selectedItems: new Map(), + version: state.version + 1, + }; + + case 'SET_EXPANDED': { + const newExpanded = new Set(); + const newExpandedItems = new Map(); + + if (action.payload.length > 0) { + const firstItem = action.payload[0]; + newExpanded.add(firstItem.id); + newExpandedItems.set(firstItem.id, firstItem); + } + + return { + ...state, + expanded: newExpanded, + expandedItems: newExpandedItems, + version: state.version + 1, + }; + } + + case 'SET_SELECTED': { + const newSelected = new Set(); + const newSelectedItems = new Map(); + + action.payload.forEach((item) => { + newSelected.add(item.id); + newSelectedItems.set(item.id, item); + }); + + return { + ...state, + selected: newSelected, + selectedItems: newSelectedItems, + version: state.version + 1, + }; + } + + case 'TOGGLE_EXPANDED': { + const newExpanded = new Set(); + const newExpandedItems = new Map(); + + // If the item is already expanded, collapse it + if (state.expanded.has(action.payload.id)) { + // Item is expanded, so collapse it (leave sets empty) + } else { + // Item is not expanded, so expand it (clear others first for single expansion) + newExpanded.add(action.payload.id); + newExpandedItems.set(action.payload.id, action.payload); + } + + return { + ...state, + expanded: newExpanded, + expandedItems: newExpandedItems, + version: state.version + 1, + }; + } + + case 'TOGGLE_SELECTED': { + const newSelected = new Set(state.selected); + const newSelectedItems = new Map(state.selectedItems); + + if (newSelected.has(action.payload.id)) { + newSelected.delete(action.payload.id); + newSelectedItems.delete(action.payload.id); + } else { + newSelected.add(action.payload.id); + newSelectedItems.set(action.payload.id, action.payload); + } + + return { + ...state, + selected: newSelected, + selectedItems: newSelectedItems, + version: state.version + 1, + }; + } + + default: + return state; + } +}; + +/** + * Initial state for item grid + */ +export const initialItemListState: ItemListState = { + expanded: new Set(), + expandedItems: new Map(), + selected: new Set(), + selectedItems: new Map(), + version: 0, +}; + +export const useItemListState = (): ItemListStateActions => { + const [state, dispatch] = useReducer(itemListReducer, initialItemListState); + + const setExpanded = useCallback((items: ItemListItem[]) => { + dispatch({ payload: items, type: 'SET_EXPANDED' }); + }, []); + + const setSelected = useCallback((items: ItemListItem[]) => { + dispatch({ payload: items, type: 'SET_SELECTED' }); + }, []); + + const toggleExpanded = useCallback((item: ItemListItem) => { + dispatch({ payload: item, type: 'TOGGLE_EXPANDED' }); + }, []); + + const toggleSelected = useCallback((item: ItemListItem) => { + dispatch({ payload: item, type: 'TOGGLE_SELECTED' }); + }, []); + + const isExpanded = useCallback( + (itemId: string) => { + return itemGridSelectors.isExpanded(state, itemId); + }, + [state], + ); + + const isSelected = useCallback( + (itemId: string) => { + return itemGridSelectors.isSelected(state, itemId); + }, + [state], + ); + + const getExpanded = useCallback(() => { + return itemGridSelectors.getExpanded(state); + }, [state]); + + const getSelected = useCallback(() => { + return itemGridSelectors.getSelected(state); + }, [state]); + + const getExpandedIds = useCallback(() => { + return Array.from(state.expanded); + }, [state.expanded]); + + const getSelectedIds = useCallback(() => { + return Array.from(state.selected); + }, [state.selected]); + + const clearExpanded = useCallback(() => { + dispatch({ type: 'CLEAR_EXPANDED' }); + }, []); + + const clearSelected = useCallback(() => { + dispatch({ type: 'CLEAR_SELECTED' }); + }, []); + + const clearAll = useCallback(() => { + dispatch({ type: 'CLEAR_ALL' }); + }, []); + + const getVersion = useCallback(() => { + return itemGridSelectors.getVersion(state); + }, [state]); + + const hasExpanded = useCallback(() => { + return itemGridSelectors.hasAnyExpanded(state); + }, [state]); + + const hasSelected = useCallback(() => { + return itemGridSelectors.hasAnySelected(state); + }, [state]); + + return { + clearAll, + clearExpanded, + clearSelected, + getExpanded, + getExpandedIds, + getSelected, + getSelectedIds, + getVersion, + hasExpanded, + hasSelected, + isExpanded, + isSelected, + setExpanded, + setSelected, + toggleExpanded, + toggleSelected, + }; +}; diff --git a/src/renderer/components/item-grid/item-grid.module.css b/src/renderer/components/item-list/item-grid/item-grid.module.css similarity index 94% rename from src/renderer/components/item-grid/item-grid.module.css rename to src/renderer/components/item-list/item-grid/item-grid.module.css index 80e816f11..d0a6b6170 100644 --- a/src/renderer/components/item-grid/item-grid.module.css +++ b/src/renderer/components/item-list/item-grid/item-grid.module.css @@ -1,10 +1,16 @@ .item-grid-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.grid-list-container { width: 100%; height: 100%; padding-right: var(--theme-spacing-md); container-name: grid-list; container-type: inline-size; - scrollbar-gutter: stable; } .grid-list-component { diff --git a/src/renderer/components/item-list/item-grid/item-grid.tsx b/src/renderer/components/item-list/item-grid/item-grid.tsx new file mode 100644 index 000000000..e1c06998c --- /dev/null +++ b/src/renderer/components/item-list/item-grid/item-grid.tsx @@ -0,0 +1,217 @@ +import clsx from 'clsx'; +import { AnimatePresence, motion, Variants } from 'motion/react'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { + CSSProperties, + forwardRef, + memo, + ReactNode, + Ref, + RefObject, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { GridComponents, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso'; + +import { ItemListItem, ItemListStateActions, useItemListState } from '../helpers/item-list-state'; +import styles from './item-grid.module.css'; + +import { ItemCard } from '/@/renderer/components/item-card/item-card'; + +const gridComponents: GridComponents = { + Item: forwardRef< + HTMLDivElement, + { + children?: ReactNode; + className?: string; + context?: ItemContext; + 'data-index': number; + enableExpanded?: boolean; + style?: CSSProperties; + virtuosoRef?: RefObject; + } + >((props, ref) => { + const { children, context, 'data-index': index } = props; + + return ( +
+ {children} +
+ ); + }), + List: forwardRef< + HTMLDivElement, + { children?: ReactNode; className?: string; style?: CSSProperties } + >((props, ref) => { + const { children, className, style, ...rest } = props; + + return ( +
+ {children} +
+ ); + }), +}; + +interface ItemContext { + actions: ItemListStateActions; + enableExpansion?: boolean; + enableSelection?: boolean; + onItemClick?: (item: unknown, index: number) => void; + onItemContextMenu?: (item: unknown, index: number) => void; + onItemDoubleClick?: (item: unknown, index: number) => void; +} + +interface ItemGridProps { + data: unknown[]; + enableExpansion?: boolean; + enableSelection?: boolean; + onItemClick?: (item: unknown, index: number) => void; + onItemContextMenu?: (item: unknown, index: number) => void; + onItemDoubleClick?: (item: unknown, index: number) => void; + ref: Ref; + totalItemCount?: number; +} + +const expandedAnimationVariants: Variants = { + hidden: { + height: 0, + maxHeight: 0, + }, + show: { + height: '40dvh', + maxHeight: '500px', + transition: { + duration: 0.3, + ease: 'easeInOut', + }, + }, +}; + +export const ItemGrid = ({ + data, + enableExpansion = false, + enableSelection = false, + onItemClick, + onItemContextMenu, + onItemDoubleClick, + ref, + totalItemCount, +}: ItemGridProps) => { + const rootRef = useRef(null); + + const [scroller, setScroller] = useState(null); + + const actions = useItemListState(); + + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + options: { + overflow: { x: 'hidden', y: 'scroll' }, + paddingAbsolute: true, + scrollbars: { + autoHide: 'leave', + autoHideDelay: 500, + pointers: ['mouse', 'pen', 'touch'], + theme: 'feishin-os-scrollbar', + visibility: 'visible', + }, + }, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (scroller && root) { + initialize({ + elements: { viewport: scroller }, + target: root, + }); + } + + return () => osInstance()?.destroy(); + }, [scroller, initialize, osInstance]); + + const itemContext = useMemo( + () => ({ + actions, + enableExpansion, + enableSelection, + onItemClick, + onItemContextMenu, + onItemDoubleClick, + }), + [ + actions, + enableExpansion, + enableSelection, + onItemClick, + onItemDoubleClick, + onItemContextMenu, + ], + ); + + const hasExpanded = actions.hasExpanded(); + + return ( +
+
+ +
+ + {hasExpanded && ( + + Hello World + + )} + +
+ ); +}; + +const itemContent = (index: number, item: any, context: ItemContext) => { + return ; +}; + +const InnerItem = memo( + ({ context, index, item }: { context: ItemContext; index: number; item: ItemListItem }) => { + const handleClick = () => { + context.actions.toggleExpanded({ id: item.id, itemType: item.itemType }); + }; + + return ( + context.onItemDoubleClick?.(item, index)} + withControls + /> + ); + }, +);