Add search route

This commit is contained in:
jeffvli 2023-05-19 22:26:43 -07:00 committed by Jeff
parent ba0543f861
commit fff1315fa5
4 changed files with 442 additions and 0 deletions

View file

@ -0,0 +1,143 @@
import {
ColDef,
GridReadyEvent,
RowDoubleClickedEvent,
IDatasource,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { MutableRefObject, useMemo, useCallback } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { generatePath, useNavigate } from 'react-router';
import { AppRoute } from '../../../router/routes';
import {
useCurrentServer,
useSongListStore,
usePlayButtonBehavior,
useAlbumListStore,
useAlbumArtistListStore,
} from '/@/renderer/store';
interface SearchContentProps {
getDatasource: (searchQuery: string, itemType: LibraryItem) => IDatasource | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const SearchContent = ({ tableRef, getDatasource }: SearchContentProps) => {
const navigate = useNavigate();
const server = useCurrentServer();
const { itemType } = useParams() as { itemType: LibraryItem };
const [searchParams] = useSearchParams();
const songListStore = useSongListStore();
const albumListStore = useAlbumListStore();
const albumArtistListStore = useAlbumArtistListStore();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const isPaginationEnabled = true;
const getTable = useCallback(
(itemType: string) => {
switch (itemType) {
case LibraryItem.SONG:
return songListStore.table;
case LibraryItem.ALBUM:
return albumListStore.table;
case LibraryItem.ALBUM_ARTIST:
return albumArtistListStore.table;
default:
return undefined;
}
},
[albumArtistListStore.table, albumListStore.table, songListStore.table],
);
const table = getTable(itemType)!;
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const datasource = getDatasource(searchParams.get('query') || '', itemType);
if (!datasource) return;
params.api.setDatasource(datasource);
params.api.ensureIndexVisible(table.scrollOffset, 'top');
},
[getDatasource, itemType, searchParams, table.scrollOffset],
);
const handleGridSizeChange = () => {
if (table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
switch (itemType) {
case LibraryItem.ALBUM:
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
break;
case LibraryItem.ALBUM_ARTIST:
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: e.data.id }));
break;
case LibraryItem.SONG:
handlePlayQueueAdd?.({
byData: [e.data],
play: playButtonBehavior,
});
break;
}
};
return (
<Stack
h="100%"
spacing={0}
>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${itemType}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={table.autoFit}
blockLoadDebounceMillis={200}
cacheBlockSize={25}
cacheOverflowSize={1}
columnDefs={columnDefs}
context={{
query: searchParams.get('query'),
}}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={25}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
// onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
// onColumnMoved={handleColumnChange}
// onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
</Stack>
);
};

View file

@ -0,0 +1,106 @@
import { ChangeEvent, MutableRefObject } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack, Flex, Group } from '@mantine/core';
import debounce from 'lodash/debounce';
import { generatePath, Link, useParams, useSearchParams } from 'react-router-dom';
import { LibraryItem } from '/@/renderer/api/types';
import { Button, PageHeader, SearchInput } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
interface SearchHeaderProps {
getDatasource: (searchQuery: string, itemType: LibraryItem) => IDatasource | undefined;
navigationId: string;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const SearchHeader = ({ tableRef, getDatasource, navigationId }: SearchHeaderProps) => {
const { itemType } = useParams() as { itemType: LibraryItem };
const [searchParams, setSearchParams] = useSearchParams();
const cq = useContainerQuery();
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.value) return;
setSearchParams({ query: e.target.value }, { replace: true, state: { navigationId } });
const datasource = getDatasource(e.target.value, itemType);
if (!datasource) return;
tableRef.current?.api.setDatasource(datasource);
}, 200);
return (
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Search</LibraryHeaderBar.Title>
</LibraryHeaderBar>
<Group>
<SearchInput
// key={`search-input-${initialQuery}`}
defaultValue={searchParams.get('query') || ''}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<Group>
<Button
compact
replace
component={Link}
fw={600}
size="md"
state={{ navigationId }}
to={{
pathname: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }),
search: searchParams.toString(),
}}
variant={itemType === LibraryItem.SONG ? 'filled' : 'subtle'}
>
Tracks
</Button>
<Button
compact
replace
component={Link}
fw={600}
size="md"
state={{ navigationId }}
to={{
pathname: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.ALBUM }),
search: searchParams.toString(),
}}
variant={itemType === LibraryItem.ALBUM ? 'filled' : 'subtle'}
>
Albums
</Button>
<Button
compact
replace
component={Link}
fw={600}
size="md"
state={{ navigationId }}
to={{
pathname: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.ALBUM_ARTIST }),
search: searchParams.toString(),
}}
variant={itemType === LibraryItem.ALBUM_ARTIST ? 'filled' : 'subtle'}
>
Artists
</Button>
</Group>
</FilterBar>
</Stack>
);
};

View file

@ -0,0 +1,186 @@
import { useCallback, useId, useRef } from 'react';
import { SearchContent } from '/@/renderer/features/search/components/search-content';
import { SearchHeader } from '/@/renderer/features/search/components/search-header';
import { AnimatedPage } from '/@/renderer/features/shared';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useCurrentServer } from '/@/renderer/store';
import { useQueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, SearchQuery } from '/@/renderer/api/types';
import { useLocation, useParams } from 'react-router';
const SearchRoute = () => {
const { state: locationState } = useLocation();
const localNavigationId = useId();
const navigationId = locationState?.navigationId || localNavigationId;
const { itemType } = useParams() as { itemType: string };
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const queryClient = useQueryClient();
const getDatasource = useCallback(
(searchQuery: string, itemType: LibraryItem) => {
let dataSource: IDatasource | undefined;
switch (itemType) {
case LibraryItem.ALBUM:
dataSource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: SearchQuery = {
albumArtistLimit: 0,
albumArtistStartIndex: 0,
albumLimit: limit,
albumStartIndex: startIndex,
query: searchQuery || ' ',
songLimit: 0,
songStartIndex: 0,
};
const queryKey = queryKeys.search.list(server?.id || '', query);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.search({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 },
);
if (!res) return;
const items = res.albums || [];
const numOfItems = items.length;
let lastRow = -1;
if (numOfItems < limit) {
lastRow = startIndex + numOfItems;
}
params.successCallback(items, lastRow);
},
};
break;
case LibraryItem.ALBUM_ARTIST:
dataSource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: SearchQuery = {
albumArtistLimit: limit,
albumArtistStartIndex: startIndex,
albumLimit: 0,
albumStartIndex: 0,
query: searchQuery || ' ',
songLimit: 0,
songStartIndex: 0,
};
const queryKey = queryKeys.search.list(server?.id || '', query);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.search({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 },
);
if (!res) return;
const items = res.albumArtists || [];
const numOfItems = items.length;
let lastRow = -1;
if (numOfItems < limit) {
lastRow = startIndex + numOfItems;
}
params.successCallback(items, lastRow);
},
};
break;
case LibraryItem.SONG:
dataSource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: SearchQuery = {
albumArtistLimit: 0,
albumArtistStartIndex: 0,
albumLimit: 0,
albumStartIndex: 0,
query: searchQuery || ' ',
songLimit: limit,
songStartIndex: startIndex,
};
const queryKey = queryKeys.search.list(server?.id || '', query);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.search({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 },
);
if (!res) return;
const items = res.songs || [];
const numOfItems = items.length;
let lastRow = -1;
if (numOfItems < limit) {
lastRow = startIndex + numOfItems;
}
params.successCallback(items, lastRow);
},
};
break;
}
return dataSource;
},
[queryClient, server],
);
return (
<AnimatedPage key={`search-${navigationId}`}>
<SearchHeader
getDatasource={getDatasource}
navigationId={navigationId}
tableRef={tableRef}
/>
<SearchContent
key={`page-${itemType}`}
getDatasource={getDatasource}
tableRef={tableRef}
/>
</AnimatedPage>
);
};
export default SearchRoute;

View file

@ -60,6 +60,8 @@ const AlbumDetailRoute = lazy(
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route')); const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
const RouteErrorBoundary = lazy( const RouteErrorBoundary = lazy(
() => import('/@/renderer/features/action-required/components/route-error-boundary'), () => import('/@/renderer/features/action-required/components/route-error-boundary'),
); );
@ -86,6 +88,11 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}
path={AppRoute.HOME} path={AppRoute.HOME}
/> />
<Route
element={<SearchRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.SEARCH}
/>
<Route <Route
element={<SettingsRoute />} element={<SettingsRoute />}
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}