From fff1315fa5e7b389364e234869f5d29880b6bb97 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 19 May 2023 22:26:43 -0700 Subject: [PATCH] Add search route --- .../search/components/search-content.tsx | 143 ++++++++++++++ .../search/components/search-header.tsx | 106 ++++++++++ .../features/search/routes/search-route.tsx | 186 ++++++++++++++++++ src/renderer/router/app-router.tsx | 7 + 4 files changed, 442 insertions(+) create mode 100644 src/renderer/features/search/components/search-content.tsx create mode 100644 src/renderer/features/search/components/search-header.tsx create mode 100644 src/renderer/features/search/routes/search-route.tsx diff --git a/src/renderer/features/search/components/search-content.tsx b/src/renderer/features/search/components/search-content.tsx new file mode 100644 index 00000000..b9ae58b7 --- /dev/null +++ b/src/renderer/features/search/components/search-content.tsx @@ -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; +} + +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) => { + 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 ( + + + 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} + /> + + + ); +}; diff --git a/src/renderer/features/search/components/search-header.tsx b/src/renderer/features/search/components/search-header.tsx new file mode 100644 index 00000000..12ad4e04 --- /dev/null +++ b/src/renderer/features/search/components/search-header.tsx @@ -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; +} + +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) => { + 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 ( + + + + + Search + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/search/routes/search-route.tsx b/src/renderer/features/search/routes/search-route.tsx new file mode 100644 index 00000000..f4e7a46f --- /dev/null +++ b/src/renderer/features/search/routes/search-route.tsx @@ -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(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 ( + + + + + ); +}; + +export default SearchRoute; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 6acc50ea..55eff48c 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -60,6 +60,8 @@ const AlbumDetailRoute = lazy( const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route')); +const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route')); + const RouteErrorBoundary = lazy( () => import('/@/renderer/features/action-required/components/route-error-boundary'), ); @@ -86,6 +88,11 @@ export const AppRouter = () => { errorElement={} path={AppRoute.HOME} /> + } + errorElement={} + path={AppRoute.SEARCH} + /> } errorElement={}