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
+ />
+ );
+ },
+);