mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-16 05:36:00 +02:00
add folder browsing support (#315)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { FolderQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const folderQueries = {
|
||||
folder: (args: QueryHookArgs<FolderQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getFolder({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.folders.folder(args.serverId, args.query),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Suspense, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { DefaultItemControlProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
|
||||
import { FolderTreeBrowser } from '/@/renderer/features/folders/components/folder-tree-browser';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { useCurrentServerId, useListSettings, usePlayerSong } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Folder, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||
|
||||
export const FolderListContent = () => {
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<FolderListInnerContent />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const FolderListInnerContent = () => {
|
||||
const serverId = useCurrentServerId();
|
||||
const queryClient = useQueryClient();
|
||||
const { currentFolderId, query } = useFolderListFilters();
|
||||
|
||||
const getFolderQueryOptions = useCallback(
|
||||
(folderId: string) => {
|
||||
return folderQueries.folder({
|
||||
query: {
|
||||
id: folderId,
|
||||
searchTerm: query[FILTER_KEYS.SHARED.SEARCH_TERM] as string | undefined,
|
||||
sortBy:
|
||||
(query[FILTER_KEYS.SHARED.SORT_BY] as SongListSort) || SongListSort.NAME,
|
||||
sortOrder: (query[FILTER_KEYS.SHARED.SORT_ORDER] as SortOrder) || SortOrder.ASC,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
},
|
||||
[serverId, query],
|
||||
);
|
||||
|
||||
const rootFolderQuery = useQuery({
|
||||
...getFolderQueryOptions('0'),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const currentFolderQuery = useSuspenseQuery({
|
||||
...getFolderQueryOptions(currentFolderId),
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
|
||||
const fetchFolder = useCallback(
|
||||
async (folderId: string) => {
|
||||
const queryOptions = getFolderQueryOptions(folderId);
|
||||
return queryClient.fetchQuery({
|
||||
...queryOptions,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
},
|
||||
[getFolderQueryOptions, queryClient],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWithSidebarContainer.SidebarPortal>
|
||||
<FolderTreeBrowser fetchFolder={fetchFolder} rootFolderQuery={rootFolderQuery} />
|
||||
</ListWithSidebarContainer.SidebarPortal>
|
||||
<FolderListView folderQuery={currentFolderQuery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface FolderListViewProps {
|
||||
folderQuery: ReturnType<typeof useSuspenseQuery<Folder>>;
|
||||
}
|
||||
|
||||
export const FolderListView = ({ folderQuery }: FolderListViewProps) => {
|
||||
const { display, table } = useListSettings(ItemListKey.SONG);
|
||||
const { setItemCount } = useListContext();
|
||||
const { navigateToFolder } = useFolderListFilters();
|
||||
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
if (!folderQuery.data?.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { folders = [], songs = [] } = folderQuery.data.children;
|
||||
return [...folders, ...songs];
|
||||
}, [folderQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemCount?.(allItems.length);
|
||||
}, [allItems.length, setItemCount]);
|
||||
|
||||
const player = usePlayer();
|
||||
|
||||
const overrideControls = useMemo(() => {
|
||||
return {
|
||||
onDoubleClick: ({ index, internalState, item }: DefaultItemControlProps) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((item as unknown as Folder)._itemType === LibraryItem.FOLDER) {
|
||||
const folder = item as unknown as Folder;
|
||||
return navigateToFolder(folder.id, folder.name);
|
||||
}
|
||||
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
const songCount = items.filter(
|
||||
(item) => item._itemType === LibraryItem.SONG,
|
||||
).length;
|
||||
|
||||
const indexesToSkip = items.length - songCount;
|
||||
|
||||
const startIndex = indexesToSkip + (index ?? 0);
|
||||
player.addToQueueByData(items, Play.NOW);
|
||||
player.mediaPlayByIndex(startIndex);
|
||||
},
|
||||
};
|
||||
}, [navigateToFolder, player]);
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
switch (display) {
|
||||
// case ListDisplayType.GRID: {
|
||||
// return (
|
||||
// <ItemGridList
|
||||
// data={allItems}
|
||||
// gap={grid.itemGap}
|
||||
// initialTop={{
|
||||
// to: scrollOffset ?? 0,
|
||||
// type: 'offset',
|
||||
// }}
|
||||
// itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
|
||||
// itemType={LibraryItem.FOLDER}
|
||||
// onScrollEnd={handleOnScrollEnd}
|
||||
// overrideControls={overrideControls}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
case ListDisplayType.TABLE: {
|
||||
return (
|
||||
<ItemTableList
|
||||
activeRowId={currentSong?.id}
|
||||
autoFitColumns={table.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={table.columns}
|
||||
data={allItems}
|
||||
enableAlternateRowColors={table.enableAlternateRowColors}
|
||||
enableDrag={true}
|
||||
enableExpansion={false}
|
||||
enableHorizontalBorders={table.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={table.enableRowHoverHighlight}
|
||||
enableVerticalBorders={table.enableVerticalBorders}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
}}
|
||||
itemType={LibraryItem.FOLDER}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
overrideControls={overrideControls}
|
||||
size={table.size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { truncateMiddle } from '/@/renderer/utils';
|
||||
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const MAX_BREADCRUMB_TEXT_LENGTH = 26;
|
||||
|
||||
export const FolderListHeaderFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
const { folderPath, navigateToPathIndex, setFolderPath } = useFolderListFilters();
|
||||
const {
|
||||
is2xl,
|
||||
isLg,
|
||||
isMd,
|
||||
isSm,
|
||||
isXl,
|
||||
isXs,
|
||||
ref: breadcrumbContainerRef,
|
||||
} = useContainerQuery();
|
||||
|
||||
const maxItems = useMemo(() => {
|
||||
if (is2xl) return 8;
|
||||
if (isXl) return 6;
|
||||
if (isLg) return 4;
|
||||
if (isMd) return 3;
|
||||
if (isSm) return 2;
|
||||
if (isXs) return 2;
|
||||
return 1;
|
||||
}, [is2xl, isLg, isMd, isSm, isXl, isXs]);
|
||||
|
||||
const allBreadcrumbItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
fullLabel: string;
|
||||
id: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}> = [];
|
||||
|
||||
const homeLabel = t('common.home', { postProcess: 'titleCase' });
|
||||
items.push({
|
||||
fullLabel: homeLabel,
|
||||
id: 'folder-root',
|
||||
label: homeLabel,
|
||||
onClick: () => {
|
||||
setFolderPath([]);
|
||||
},
|
||||
});
|
||||
|
||||
folderPath.forEach((folder, index) => {
|
||||
items.push({
|
||||
fullLabel: folder.name,
|
||||
id: `folder-${folder.id}`,
|
||||
label: truncateMiddle(folder.name, MAX_BREADCRUMB_TEXT_LENGTH),
|
||||
onClick: () => navigateToPathIndex(index),
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [folderPath, navigateToPathIndex, setFolderPath, t]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const firstItem = allBreadcrumbItems[0];
|
||||
|
||||
if (maxItems === 1) {
|
||||
return [firstItem];
|
||||
}
|
||||
|
||||
if (allBreadcrumbItems.length <= maxItems) {
|
||||
return allBreadcrumbItems;
|
||||
}
|
||||
|
||||
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
|
||||
const middleItems = allBreadcrumbItems.slice(1, -1);
|
||||
const availableSlots = maxItems - 2;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
return [firstItem, lastItem];
|
||||
}
|
||||
|
||||
if (middleItems.length <= availableSlots) {
|
||||
return [firstItem, ...middleItems, lastItem];
|
||||
}
|
||||
|
||||
const startCount = Math.floor(availableSlots / 2);
|
||||
const endCount = availableSlots - startCount;
|
||||
const startMiddle = middleItems.slice(0, startCount);
|
||||
const endMiddle = middleItems.slice(-endCount);
|
||||
|
||||
return [firstItem, ...startMiddle, ...endMiddle, lastItem];
|
||||
}, [allBreadcrumbItems, maxItems]);
|
||||
|
||||
const collapsedItems = useMemo(() => {
|
||||
if (maxItems === 1) {
|
||||
return allBreadcrumbItems.slice(1);
|
||||
}
|
||||
|
||||
if (allBreadcrumbItems.length <= maxItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const middleItems = allBreadcrumbItems.slice(1, -1);
|
||||
const availableSlots = maxItems - 2;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
return middleItems;
|
||||
}
|
||||
|
||||
if (middleItems.length <= availableSlots) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const startCount = Math.floor(availableSlots / 2);
|
||||
const endCount = availableSlots - startCount;
|
||||
const visibleStart = middleItems.slice(0, startCount);
|
||||
const visibleEnd = middleItems.slice(-endCount);
|
||||
|
||||
return middleItems.filter(
|
||||
(item) => !visibleStart.includes(item) && !visibleEnd.includes(item),
|
||||
);
|
||||
}, [allBreadcrumbItems, maxItems]);
|
||||
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
const items: React.ReactNode[] = [];
|
||||
const firstItem = allBreadcrumbItems[0];
|
||||
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
|
||||
const hasCollapsedItems = collapsedItems.length > 0;
|
||||
|
||||
const renderDropdown = () => (
|
||||
<DropdownMenu key="breadcrumb-dropdown" position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button size="compact-sm" variant="subtle">
|
||||
<Icon icon="ellipsisHorizontal" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{collapsedItems.map((collapsedItem) => (
|
||||
<DropdownMenu.Item key={collapsedItem.id} onClick={collapsedItem.onClick}>
|
||||
{collapsedItem.fullLabel}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (hasCollapsedItems && maxItems === 1) {
|
||||
items.push(
|
||||
<Button
|
||||
key={firstItem.id}
|
||||
onClick={firstItem.onClick}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{firstItem.label}
|
||||
</Button>,
|
||||
);
|
||||
items.push(renderDropdown());
|
||||
return items;
|
||||
}
|
||||
|
||||
if (hasCollapsedItems) {
|
||||
const middleItems = allBreadcrumbItems.slice(1, -1);
|
||||
const availableSlots = maxItems - 2;
|
||||
const startCount = Math.floor(availableSlots / 2);
|
||||
const visibleStartMiddle = middleItems.slice(0, startCount);
|
||||
const visibleEndMiddle = middleItems.slice(-(availableSlots - startCount));
|
||||
|
||||
visibleItems.forEach((item, index) => {
|
||||
items.push(
|
||||
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
|
||||
{item.label}
|
||||
</Button>,
|
||||
);
|
||||
|
||||
if (index < visibleItems.length - 1) {
|
||||
const nextItem = visibleItems[index + 1];
|
||||
const isFirstItem = item.id === firstItem.id;
|
||||
const isLastStartMiddle =
|
||||
item.id !== firstItem.id &&
|
||||
item.id !== lastItem.id &&
|
||||
visibleStartMiddle.length > 0 &&
|
||||
item.id === visibleStartMiddle[visibleStartMiddle.length - 1].id;
|
||||
|
||||
const shouldInsertDropdown =
|
||||
(isFirstItem && nextItem.id === lastItem.id) ||
|
||||
(isLastStartMiddle &&
|
||||
(nextItem.id === lastItem.id ||
|
||||
(visibleEndMiddle.length > 0 &&
|
||||
nextItem.id === visibleEndMiddle[0].id)));
|
||||
|
||||
if (shouldInsertDropdown) {
|
||||
items.push(renderDropdown());
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
visibleItems.forEach((item) => {
|
||||
items.push(
|
||||
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
|
||||
{item.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [visibleItems, collapsedItems, allBreadcrumbItems, maxItems]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Flex justify="space-between">
|
||||
<Group gap="sm" w="100%">
|
||||
<ListSortByDropdown
|
||||
defaultSortByValue={SongListSort.ID}
|
||||
itemType={LibraryItem.FOLDER}
|
||||
listKey={ItemListKey.SONG}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<ListSortOrderToggleButton
|
||||
defaultSortOrder={SortOrder.ASC}
|
||||
listKey={ItemListKey.SONG}
|
||||
/>
|
||||
<ListRefreshButton listKey={ItemListKey.SONG} />
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ListConfigMenu
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
grid: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
table: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
}}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
<div ref={breadcrumbContainerRef}>
|
||||
<Breadcrumb separator={<Icon icon="arrowRight" />}>{breadcrumbItems}</Breadcrumb>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { FolderListHeaderFilters } from '/@/renderer/features/folders/components/folder-list-header-filters';
|
||||
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
interface FolderListHeaderProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FolderListHeader = ({ title }: FolderListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { itemCount } = useListContext();
|
||||
const pageTitle = title || t('page.folderList.title', { postProcess: 'titleCase' });
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar ignoreMaxWidth>
|
||||
<Stack>
|
||||
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
|
||||
</Stack>
|
||||
<LibraryHeaderBar.Badge isLoading={itemCount === undefined}>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<ListSearchInput />
|
||||
</Group>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<FolderListHeaderFilters />
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--theme-spacing-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--theme-radius-md);
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
.row.active {
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.row.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.expand-icon-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--theme-colors-foreground);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.expand-icon-placeholder {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.folder-icon-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-colors-foreground);
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--theme-font-size-md);
|
||||
color: var(--theme-colors-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row.active .folder-name {
|
||||
font-weight: 500;
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md) var(--theme-spacing-sm) 0;
|
||||
font-size: var(--theme-font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { type UseQueryResult } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { List, RowComponentProps } from 'react-window-v2';
|
||||
|
||||
import styles from './folder-tree-browser.module.css';
|
||||
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
|
||||
interface FlattenedNode {
|
||||
depth: number;
|
||||
folder: Folder;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
path: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
childrenLoaded: boolean;
|
||||
depth: number;
|
||||
folder: Folder;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 32;
|
||||
const INDENT_SIZE = 16;
|
||||
|
||||
interface FolderTreeBrowserProps {
|
||||
fetchFolder: (folderId: string) => Promise<Folder>;
|
||||
rootFolderQuery: UseQueryResult<Folder, Error>;
|
||||
}
|
||||
|
||||
export const FolderTreeBrowser = ({ fetchFolder, rootFolderQuery }: FolderTreeBrowserProps) => {
|
||||
const { currentFolderId, setFolderPath } = useFolderListFilters();
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [loadedNodes, setLoadedNodes] = useState<Map<string, Folder[]>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initialize root folder children when data is loaded
|
||||
useEffect(() => {
|
||||
if (rootFolderQuery.data?.children?.folders && !loadedNodes.has('0')) {
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set('0', rootFolderQuery.data?.children?.folders || []);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [rootFolderQuery.data, loadedNodes]);
|
||||
|
||||
// Fetch folder when expanding a node
|
||||
const fetchFolderChildren = useCallback(
|
||||
async (folderId: string) => {
|
||||
if (loadedNodes.has(folderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchFolder(folderId);
|
||||
|
||||
if (result?.children?.folders) {
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const folders = result?.children?.folders || [];
|
||||
newMap.set(folderId, folders);
|
||||
return newMap;
|
||||
});
|
||||
} else {
|
||||
// Even if no children, mark as loaded to avoid refetching
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(folderId, []);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(folderId, []);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchFolder, loadedNodes],
|
||||
);
|
||||
|
||||
// Get children for a folder
|
||||
const getFolderChildren = useCallback(
|
||||
(folder: Folder): Folder[] => {
|
||||
// First check if we have explicitly loaded children in loadedNodes
|
||||
const loaded = loadedNodes.get(folder.id);
|
||||
if (loaded !== undefined) {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// Otherwise, use children from the folder object itself (if available)
|
||||
// This handles cases where children came with the parent folder's response
|
||||
return folder.children?.folders || [];
|
||||
},
|
||||
[loadedNodes],
|
||||
);
|
||||
|
||||
// Build tree structure from root
|
||||
const buildTree = useCallback(
|
||||
(folder: Folder, depth: number = 0): TreeNode => {
|
||||
const folderId = folder.id;
|
||||
const isExpanded = expandedNodes.has(folderId);
|
||||
const children = getFolderChildren(folder);
|
||||
const hasChildren = children.length > 0;
|
||||
const childrenLoaded =
|
||||
loadedNodes.has(folderId) || (folder.children?.folders?.length ?? 0) > 0;
|
||||
|
||||
return {
|
||||
childrenLoaded,
|
||||
depth,
|
||||
folder,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
};
|
||||
},
|
||||
[expandedNodes, loadedNodes, getFolderChildren],
|
||||
);
|
||||
|
||||
// Flatten tree to list for virtualization
|
||||
const flattenedNodes = useMemo((): FlattenedNode[] => {
|
||||
if (!rootFolderQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: FlattenedNode[] = [];
|
||||
const rootFolder = rootFolderQuery.data;
|
||||
|
||||
const traverse = (
|
||||
folder: Folder,
|
||||
depth: number,
|
||||
path: Array<{ id: string; name: string }> = [],
|
||||
) => {
|
||||
const node = buildTree(folder, depth);
|
||||
const currentPath = [...path, { id: folder.id, name: folder.name }];
|
||||
const isRoot = folder.id === '0';
|
||||
|
||||
// Skip the root folder (id: '0')
|
||||
if (!isRoot) {
|
||||
result.push({
|
||||
depth: node.depth - 1,
|
||||
folder: node.folder,
|
||||
hasChildren: node.hasChildren,
|
||||
isExpanded: node.isExpanded,
|
||||
path: currentPath,
|
||||
});
|
||||
}
|
||||
|
||||
// For root folder, always traverse children
|
||||
const shouldTraverseChildren = isRoot
|
||||
? node.hasChildren
|
||||
: node.isExpanded && node.hasChildren;
|
||||
|
||||
if (shouldTraverseChildren) {
|
||||
const children = getFolderChildren(folder);
|
||||
// Recursively traverse each child - this supports infinite nesting
|
||||
children.forEach((child) => {
|
||||
traverse(child, depth + 1, currentPath);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
traverse(rootFolder, 0);
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rootFolderQuery.data, expandedNodes, loadedNodes, buildTree, getFolderChildren]);
|
||||
|
||||
const toggleNode = useCallback(
|
||||
(folderId: string, hasChildren: boolean, folder?: Folder) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
// Fetch children if not loaded and has children
|
||||
// Check both loadedNodes and folder.children to determine if we need to fetch
|
||||
const needsFetch =
|
||||
hasChildren &&
|
||||
!loadedNodes.has(folderId) &&
|
||||
!(folder?.children?.folders && folder.children.folders.length > 0);
|
||||
if (needsFetch) {
|
||||
fetchFolderChildren(folderId);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[fetchFolderChildren, loadedNodes],
|
||||
);
|
||||
|
||||
// Expand a node (doesn't collapse if already expanded)
|
||||
const expandNode = useCallback(
|
||||
(folderId: string, hasChildren: boolean, folder?: Folder) => {
|
||||
setExpandedNodes((prev) => {
|
||||
if (prev.has(folderId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Expand the node
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(folderId);
|
||||
|
||||
// Fetch children if not loaded and has children
|
||||
const needsFetch =
|
||||
hasChildren &&
|
||||
!loadedNodes.has(folderId) &&
|
||||
!(folder?.children?.folders && folder.children.folders.length > 0);
|
||||
if (needsFetch) {
|
||||
fetchFolderChildren(folderId);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[fetchFolderChildren, loadedNodes],
|
||||
);
|
||||
|
||||
// Handle node click - toggle expand/collapse and set current folder
|
||||
const handleNodeClick = useCallback(
|
||||
(
|
||||
folder: Folder,
|
||||
path: Array<{ id: string; name: string }>,
|
||||
isExpanded: boolean,
|
||||
isCurrentFolder: boolean,
|
||||
) => {
|
||||
// Only toggle close if the node is expanded AND it's the current folder
|
||||
if (isExpanded && isCurrentFolder) {
|
||||
toggleNode(folder.id, true, folder);
|
||||
} else if (!isExpanded) {
|
||||
// Node is not expanded - check if we should expand it
|
||||
const childrenLoaded = loadedNodes.has(folder.id);
|
||||
const hasChildrenFromFolder = (folder.children?.folders?.length ?? 0) > 0;
|
||||
|
||||
// Determine if we should expand:
|
||||
// - If children are loaded and empty, don't expand (we know it has no children)
|
||||
// - Otherwise, try to expand/fetch (either has children or we don't know yet)
|
||||
let shouldExpand = false;
|
||||
let mightHaveChildren = false;
|
||||
|
||||
if (childrenLoaded) {
|
||||
// Children are loaded - check if there are any
|
||||
const loadedChildren = loadedNodes.get(folder.id) || [];
|
||||
shouldExpand = loadedChildren.length > 0;
|
||||
mightHaveChildren = loadedChildren.length > 0;
|
||||
} else {
|
||||
// Children not loaded yet - assume it might have children and try to expand
|
||||
shouldExpand = true;
|
||||
mightHaveChildren = true;
|
||||
}
|
||||
|
||||
// Override with folder's children if available (from parent response)
|
||||
if (hasChildrenFromFolder) {
|
||||
shouldExpand = true;
|
||||
mightHaveChildren = true;
|
||||
}
|
||||
|
||||
if (shouldExpand) {
|
||||
expandNode(folder.id, mightHaveChildren, folder);
|
||||
}
|
||||
}
|
||||
|
||||
// Set current folder path (full path from root to clicked folder)
|
||||
// Skip the root folder (id: '0') from the path
|
||||
const pathWithoutRoot = path.filter((item) => item.id !== '0');
|
||||
setFolderPath(pathWithoutRoot);
|
||||
},
|
||||
[expandNode, loadedNodes, setFolderPath, toggleNode],
|
||||
);
|
||||
|
||||
const rowProps = useMemo(
|
||||
() => ({
|
||||
currentFolderId,
|
||||
data: flattenedNodes,
|
||||
handleNodeClick,
|
||||
toggleNode,
|
||||
}),
|
||||
[currentFolderId, flattenedNodes, handleNodeClick, toggleNode],
|
||||
);
|
||||
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: false,
|
||||
events: {
|
||||
initialized(osInstance) {
|
||||
const { viewport } = osInstance.elements();
|
||||
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
|
||||
},
|
||||
},
|
||||
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: container } = containerRef;
|
||||
|
||||
if (!container || !container.firstElementChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = container.firstElementChild as HTMLElement;
|
||||
|
||||
initialize({
|
||||
elements: { viewport },
|
||||
target: container,
|
||||
});
|
||||
|
||||
return () => osInstance()?.destroy();
|
||||
}, [initialize, osInstance]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<List
|
||||
rowComponent={RowComponent}
|
||||
rowCount={flattenedNodes.length}
|
||||
rowHeight={ITEM_HEIGHT}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RowComponent = ({
|
||||
currentFolderId,
|
||||
data,
|
||||
handleNodeClick,
|
||||
index,
|
||||
style,
|
||||
toggleNode,
|
||||
}: RowComponentProps<{
|
||||
currentFolderId: null | string;
|
||||
data: FlattenedNode[];
|
||||
handleNodeClick: (
|
||||
folder: Folder,
|
||||
path: Array<{ id: string; name: string }>,
|
||||
isExpanded: boolean,
|
||||
isCurrentFolder: boolean,
|
||||
) => void;
|
||||
toggleNode: (folderId: string, hasChildren: boolean, folder?: Folder) => void;
|
||||
}>) => {
|
||||
const item = data[index];
|
||||
const folderNameRef = useRef<HTMLSpanElement>(null);
|
||||
const folderIconRef = useRef<HTMLDivElement>(null);
|
||||
const expandIconRef = useRef<HTMLDivElement | null>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltipOffset, setTooltipOffset] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const calculateOffset = () => {
|
||||
if (rowRef.current && folderIconRef.current && expandIconRef.current) {
|
||||
const width = rowRef.current.offsetWidth;
|
||||
const paddingLeft = item.depth * INDENT_SIZE;
|
||||
const folderIconWidth = folderIconRef.current.offsetWidth;
|
||||
const expandIconWidth = expandIconRef.current.offsetWidth;
|
||||
const itemPadding = 8;
|
||||
setTooltipOffset(
|
||||
-width + paddingLeft + folderIconWidth + expandIconWidth + itemPadding,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
calculateOffset();
|
||||
|
||||
const handleResize = () => {
|
||||
calculateOffset();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [item]);
|
||||
|
||||
const { isDragging, ref: dragRef } = useDragDrop<HTMLDivElement>({
|
||||
drag: {
|
||||
getId: () => (item ? [item.folder.id] : []),
|
||||
getItem: () => (item ? [item.folder] : []),
|
||||
itemType: LibraryItem.FOLDER,
|
||||
operation: [DragOperation.ADD],
|
||||
target: DragTarget.FOLDER,
|
||||
},
|
||||
isEnabled: !!item,
|
||||
});
|
||||
|
||||
// Use dragRef for the element and also update rowRef for tooltip calculations
|
||||
useEffect(() => {
|
||||
if (dragRef && 'current' in dragRef && dragRef.current) {
|
||||
rowRef.current = dragRef.current;
|
||||
}
|
||||
}, [dragRef]);
|
||||
|
||||
if (!item) {
|
||||
return <div style={style} />;
|
||||
}
|
||||
|
||||
const isActive = currentFolderId === item.folder.id;
|
||||
const paddingLeft = item.depth * INDENT_SIZE;
|
||||
|
||||
const handleExpandClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleNode(item.folder.id, item.hasChildren, item.folder);
|
||||
};
|
||||
|
||||
const handleRowClick = () => {
|
||||
handleNodeClick(item.folder, item.path, item.isExpanded, isActive);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ContextMenuController.call({
|
||||
cmd: {
|
||||
items: [item.folder],
|
||||
type: LibraryItem.FOLDER,
|
||||
},
|
||||
event: e,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
classNames={{
|
||||
tooltip: styles.tooltip,
|
||||
}}
|
||||
label={item.folder.name}
|
||||
offset={tooltipOffset}
|
||||
openDelay={0}
|
||||
position="right"
|
||||
withArrow={false}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.row, {
|
||||
[styles.active]: isActive,
|
||||
[styles.dragging]: isDragging,
|
||||
})}
|
||||
onClick={handleRowClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
ref={dragRef}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.rowContent}>
|
||||
{item.hasChildren ? (
|
||||
<div className={styles.expandIconContainer} ref={expandIconRef}>
|
||||
<Icon
|
||||
className={clsx(styles.expandIcon, {
|
||||
[styles.expanded]: item.isExpanded,
|
||||
})}
|
||||
icon="arrowRightS"
|
||||
onClick={handleExpandClick}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.expandIconPlaceholder} ref={expandIconRef} />
|
||||
)}
|
||||
<div className={styles.folderIconContainer} ref={folderIconRef}>
|
||||
<Icon className={styles.folderIcon} icon="folder" size="md" />
|
||||
</div>
|
||||
<span className={styles.folderName} ref={folderNameRef}>
|
||||
{item.folder.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
|
||||
import { SongListSort } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export type FolderPathItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const useFolderListFilters = () => {
|
||||
const { sortBy } = useSortByFilter<SongListSort>(null, ItemListKey.SONG);
|
||||
|
||||
const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG);
|
||||
|
||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const folderPath = useMemo(() => {
|
||||
const path = parseJsonParam<FolderPathItem[]>(searchParams, FILTER_KEYS.FOLDER.FOLDER_PATH);
|
||||
return path || [];
|
||||
}, [searchParams]);
|
||||
|
||||
const setFolderPath = (path: FolderPathItem[]) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);
|
||||
return newParams;
|
||||
},
|
||||
{ replace: false },
|
||||
);
|
||||
};
|
||||
|
||||
// Navigate to a folder (adds to path)
|
||||
const navigateToFolder = (folderId: string, folderName: string) => {
|
||||
setFolderPath([...folderPath, { id: folderId, name: folderName }]);
|
||||
};
|
||||
|
||||
// Navigate back to a specific folder in the path (truncates path)
|
||||
const navigateToPathIndex = (index: number) => {
|
||||
setFolderPath(folderPath.slice(0, index + 1));
|
||||
};
|
||||
|
||||
// Get current folder ID (last item in path, or '0' for root)
|
||||
const currentFolderId = useMemo(() => {
|
||||
return folderPath.length > 0 ? folderPath[folderPath.length - 1].id : '0';
|
||||
}, [folderPath]);
|
||||
|
||||
const query = {
|
||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
currentFolderId,
|
||||
folderPath,
|
||||
navigateToFolder,
|
||||
navigateToPathIndex,
|
||||
query,
|
||||
setFolderPath,
|
||||
setSearchTerm,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';
|
||||
import { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const FolderListRoute = () => {
|
||||
const pageKey = ItemListKey.SONG;
|
||||
|
||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
id: undefined,
|
||||
itemCount,
|
||||
pageKey,
|
||||
setItemCount,
|
||||
};
|
||||
}, [itemCount, pageKey, setItemCount]);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<FolderListHeader />
|
||||
<ListWithSidebarContainer>
|
||||
<FolderListContent />
|
||||
</ListWithSidebarContainer>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderListRouteWithBoundary = () => {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<FolderListRoute />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderListRouteWithBoundary;
|
||||
Reference in New Issue
Block a user