refactor search into individual sections by itemtype, add infinite loader

This commit is contained in:
jeffvli
2026-03-18 00:59:04 -07:00
parent b7cbdb4d6c
commit 615f9c3515
9 changed files with 593 additions and 211 deletions
+1
View File
@@ -114,6 +114,7 @@
"no": "no",
"none": "none",
"noResultsFromQuery": "the query returned no results",
"numberOfResults": "{{numberOfResults}} results",
"noFilters": "no filters configured",
"note": "note",
"ok": "ok",
+5
View File
@@ -347,6 +347,11 @@ export const queryKeys: Record<
list: (serverId: string) => [serverId, 'roles'] as const,
},
search: {
infiniteList: (
serverId: string,
type: 'albumArtists' | 'albums' | 'songs',
searchTerm: string,
) => [serverId, 'search', 'infiniteList', type, searchTerm] as const,
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'search', 'list'] as const;
+103 -2
View File
@@ -1,9 +1,11 @@
import { queryOptions } from '@tanstack/react-query';
import { infiniteQueryOptions, 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 { SearchQuery } from '/@/shared/types/domain-types';
import { SearchQuery, SearchResponse } from '/@/shared/types/domain-types';
const SEARCH_PAGE_SIZE = 4;
export const searchQueries = {
search: (args: QueryHookArgs<SearchQuery>) => {
@@ -18,4 +20,103 @@ export const searchQueries = {
...args.options,
});
},
searchAlbumArtistsInfinite: (args: {
enabled?: boolean;
searchTerm: string;
serverId: string | undefined;
}) => {
const { enabled = true, searchTerm, serverId } = args;
return infiniteQueryOptions({
enabled: Boolean(serverId && searchTerm && enabled),
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
const len = lastPage.albumArtists.length;
if (len < SEARCH_PAGE_SIZE) return undefined;
return allPages.length * SEARCH_PAGE_SIZE;
},
initialPageParam: 0,
queryFn: ({ pageParam, signal }) => {
if (!serverId) throw new Error('serverId required');
const startIndex = (pageParam ?? 0) as number;
return api.controller.search({
apiClientProps: { serverId, signal },
query: {
albumArtistLimit: SEARCH_PAGE_SIZE,
albumArtistStartIndex: startIndex,
albumLimit: 0,
albumStartIndex: 0,
query: searchTerm,
songLimit: 0,
songStartIndex: 0,
},
});
},
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albumArtists', searchTerm),
});
},
searchAlbumsInfinite: (args: {
enabled?: boolean;
searchTerm: string;
serverId: string | undefined;
}) => {
const { enabled = true, searchTerm, serverId } = args;
return infiniteQueryOptions({
enabled: Boolean(serverId && searchTerm && enabled),
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
const len = lastPage.albums.length;
if (len < SEARCH_PAGE_SIZE) return undefined;
return allPages.length * SEARCH_PAGE_SIZE;
},
initialPageParam: 0,
queryFn: ({ pageParam, signal }) => {
if (!serverId) throw new Error('serverId required');
const startIndex = (pageParam ?? 0) as number;
return api.controller.search({
apiClientProps: { serverId, signal },
query: {
albumArtistLimit: 0,
albumArtistStartIndex: 0,
albumLimit: SEARCH_PAGE_SIZE,
albumStartIndex: startIndex,
query: searchTerm,
songLimit: 0,
songStartIndex: 0,
},
});
},
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albums', searchTerm),
});
},
searchSongsInfinite: (args: {
enabled?: boolean;
searchTerm: string;
serverId: string | undefined;
}) => {
const { enabled = true, searchTerm, serverId } = args;
return infiniteQueryOptions({
enabled: Boolean(serverId && searchTerm && enabled),
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
const len = lastPage.songs.length;
if (len < SEARCH_PAGE_SIZE) return undefined;
return allPages.length * SEARCH_PAGE_SIZE;
},
initialPageParam: 0,
queryFn: ({ pageParam, signal }) => {
if (!serverId) throw new Error('serverId required');
const startIndex = (pageParam ?? 0) as number;
return api.controller.search({
apiClientProps: { serverId, signal },
query: {
albumArtistLimit: 0,
albumArtistStartIndex: 0,
albumLimit: 0,
albumStartIndex: 0,
query: searchTerm,
songLimit: SEARCH_PAGE_SIZE,
songStartIndex: startIndex,
},
});
},
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'songs', searchTerm),
});
},
};
@@ -1,11 +1,8 @@
.root {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
&:not(:last-child) {
margin-bottom: var(--theme-spacing-xs);
}
}
@@ -38,5 +35,4 @@
.items {
display: flex;
flex-direction: column;
gap: 0;
}
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useState } from 'react';
import styles from './collapsible-command-group.module.css';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Paper } from '/@/shared/components/paper/paper';
@@ -11,6 +12,7 @@ interface CollapsibleCommandGroupProps {
expanded?: boolean;
heading: string;
onToggle?: () => void;
subtitle?: string;
}
export function CollapsibleCommandGroup({
@@ -19,6 +21,7 @@ export function CollapsibleCommandGroup({
expanded: controlledExpanded,
heading,
onToggle,
subtitle,
}: CollapsibleCommandGroupProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
@@ -54,7 +57,10 @@ export function CollapsibleCommandGroup({
tabIndex={0}
>
<Icon className={styles.chevron} icon={expanded ? 'dropdown' : 'arrowRightS'} />
<span>{heading}</span>
<Group justify="space-between" w="100%">
<span>{heading}</span>
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
</Group>
</div>
</Paper>
{expanded && <div className={styles.items}>{children}</div>}
@@ -1,18 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { searchQueries } from '/@/renderer/features/search/api/search-api';
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
import { HomeCommands } from '/@/renderer/features/search/components/home-commands';
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
import { SearchAlbumArtistsSection } from '/@/renderer/features/search/components/search-album-artists-section';
import { SearchAlbumsSection } from '/@/renderer/features/search/components/search-albums-section';
import { SearchSongsSection } from '/@/renderer/features/search/components/search-songs-section';
import { ServerCommands } from '/@/renderer/features/search/components/server-commands';
import { AppRoute } from '/@/renderer/router/routes';
import { useAppStore, useCurrentServer } from '/@/renderer/store';
import { useAppStore } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
import { Button } from '/@/shared/components/button/button';
@@ -21,11 +16,10 @@ import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Kbd } from '/@/shared/components/kbd/kbd';
import { Modal } from '/@/shared/components/modal/modal';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { LibraryItem } from '/@/shared/types/domain-types';
interface CommandPaletteProps {
modalProps: (typeof useDisclosure)['arguments'];
@@ -37,23 +31,110 @@ const SEARCH_SECTION_IDS = {
tracks: 'tracks',
} as const;
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
const navigate = useNavigate();
const server = useCurrentServer();
interface CommandPaletteSearchProps {
children?: React.ReactNode;
isHome: boolean;
onSelectResult: () => void;
query: string;
searchInputRef: React.RefObject<HTMLInputElement | null>;
setQuery: (query: string) => void;
}
function CommandPaletteSearch({
children,
isHome,
onSelectResult,
query,
searchInputRef,
setQuery,
}: CommandPaletteSearchProps) {
const [debouncedQuery] = useDebouncedValue(query, 400);
const searchSectionsExpanded = useAppStore(
(state) => state.commandPaletteSearchSectionsExpanded,
);
const setSearchSectionExpanded = useAppStore(
(state) => state.actions.setCommandPaletteSearchSectionExpanded,
);
return (
<>
<TextInput
data-autofocus
leftSection={<Icon icon="search" />}
onChange={(e) => setQuery(e.currentTarget.value)}
ref={searchInputRef}
rightSection={
query && (
<ActionIcon
onClick={() => {
setQuery('');
searchInputRef.current?.focus();
}}
variant="transparent"
>
<Icon icon="x" />
</ActionIcon>
)
}
size="sm"
value={query}
/>
<Divider my="sm" />
<Command.List>
<Stack gap="xs">
<SearchAlbumsSection
debouncedQuery={debouncedQuery ?? ''}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}
isHome={isHome}
onSelectResult={onSelectResult}
onToggle={() =>
setSearchSectionExpanded(
SEARCH_SECTION_IDS.albums,
!(searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true),
)
}
query={query}
/>
<SearchAlbumArtistsSection
debouncedQuery={debouncedQuery ?? ''}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}
isHome={isHome}
onSelectResult={onSelectResult}
onToggle={() =>
setSearchSectionExpanded(
SEARCH_SECTION_IDS.artists,
!(searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true),
)
}
query={query}
/>
<SearchSongsSection
debouncedQuery={debouncedQuery ?? ''}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}
isHome={isHome}
onSelectResult={onSelectResult}
onToggle={() =>
setSearchSectionExpanded(
SEARCH_SECTION_IDS.tracks,
!(searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true),
)
}
query={query}
/>
</Stack>
{children}
</Command.List>
</>
);
}
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
const [value, setValue] = useState('');
const [query, setQuery] = useState('');
const [debouncedQuery] = useDebouncedValue(query, 400);
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
const activePage = pages[pages.length - 1];
const isHome = activePage === CommandPalettePages.HOME;
const searchInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const popPage = useCallback(() => {
setPages((pages) => {
@@ -63,25 +144,10 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
});
}, []);
const { data, isLoading } = useQuery(
searchQueries.search({
options: { enabled: isHome && debouncedQuery !== '' && query !== '' },
query: {
albumArtistLimit: 4,
albumArtistStartIndex: 0,
albumLimit: 4,
albumStartIndex: 0,
query: debouncedQuery,
songLimit: 4,
songStartIndex: 0,
},
serverId: server?.id,
}),
);
const showAlbumGroup = isHome && Boolean(query && data && data?.albums?.length > 0);
const showArtistGroup = isHome && Boolean(query && data && data?.albumArtists?.length > 0);
const showTrackGroup = isHome && Boolean(query && data && data?.songs?.length > 0);
const handleSelectResult = useCallback(() => {
modalProps.handlers.close();
setQuery('');
}, [modalProps.handlers]);
return (
<Modal
@@ -120,8 +186,6 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
}}
label="Global Command Menu"
onKeyDown={(e) => {
// Focus the search input when navigating with arrow keys
// to prevent the focus from staying on the command-item ActionIcon
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
searchInputRef.current?.focus();
}
@@ -129,171 +193,13 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
onValueChange={setValue}
value={value}
>
<TextInput
data-autofocus
leftSection={<Icon icon="search" />}
onChange={(e) => setQuery(e.currentTarget.value)}
ref={searchInputRef}
rightSection={
isLoading ? (
<Spinner />
) : (
query && (
<ActionIcon
onClick={() => {
setQuery('');
searchInputRef.current?.focus();
}}
variant="transparent"
>
<Icon icon="x" />
</ActionIcon>
)
)
}
size="sm"
value={query}
/>
<Divider my="sm" />
<Command.List>
<Command.Empty>
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
</Command.Empty>
{showAlbumGroup && (
<CollapsibleCommandGroup
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}
heading="Albums"
onToggle={() =>
setSearchSectionExpanded(
SEARCH_SECTION_IDS.albums,
!(searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true),
)
}
>
{data?.albums?.map((album) => (
<CommandItemSelectable
key={`search-album-${album.id}`}
onSelect={() => {
navigate(
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: album.id,
}),
);
modalProps.handlers.close();
setQuery('');
}}
value={`search-${album.id}`}
>
{({ isHighlighted }) => (
<LibraryCommandItem
explicitStatus={album.explicitStatus}
id={album.id}
imageId={album.imageId}
imageUrl={album.imageUrl}
isHighlighted={isHighlighted}
itemType={LibraryItem.ALBUM}
subtitle={album.albumArtists
.map((artist) => artist.name)
.join(', ')}
title={album.name}
/>
)}
</CommandItemSelectable>
))}
</CollapsibleCommandGroup>
)}
{showArtistGroup && (
<CollapsibleCommandGroup
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}
heading="Artists"
onToggle={() =>
setSearchSectionExpanded(
SEARCH_SECTION_IDS.artists,
!(searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true),
)
}
>
{data?.albumArtists.map((artist) => (
<CommandItemSelectable
key={`artist-${artist.id}`}
onSelect={() => {
navigate(
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
}),
);
modalProps.handlers.close();
setQuery('');
}}
value={`search-${artist.id}`}
>
{({ isHighlighted }) => (
<LibraryCommandItem
disabled={artist?.albumCount === 0}
id={artist.id}
imageId={artist.imageId}
imageUrl={artist.imageUrl}
isHighlighted={isHighlighted}
itemType={LibraryItem.ALBUM_ARTIST}
subtitle={
artist?.albumCount !== undefined &&
artist?.albumCount !== null
? t('entity.albumWithCount', {
count: artist.albumCount,
})
: undefined
}
title={artist.name}
/>
)}
</CommandItemSelectable>
))}
</CollapsibleCommandGroup>
)}
{showTrackGroup && (
<CollapsibleCommandGroup
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}
heading="Tracks"
onToggle={() =>
setSearchSectionExpanded(
SEARCH_SECTION_IDS.tracks,
!(searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true),
)
}
>
{data?.songs.map((song) => (
<CommandItemSelectable
key={`artist-${song.id}`}
onSelect={() => {
navigate(
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: song.albumId,
}),
);
modalProps.handlers.close();
setQuery('');
}}
value={`search-${song.id}`}
>
{({ isHighlighted }) => (
<LibraryCommandItem
explicitStatus={song.explicitStatus}
id={song.id}
imageId={song.imageId}
imageUrl={song.imageUrl}
isHighlighted={isHighlighted}
itemType={LibraryItem.SONG}
song={song}
subtitle={song.artists
.map((artist) => artist.name)
.join(', ')}
title={song.name}
/>
)}
</CommandItemSelectable>
))}
</CollapsibleCommandGroup>
)}
<CommandPaletteSearch
isHome={isHome}
onSelectResult={handleSelectResult}
query={query}
searchInputRef={searchInputRef}
setQuery={setQuery}
>
{activePage === CommandPalettePages.HOME && (
<HomeCommands
handleClose={modalProps.handlers.close}
@@ -317,7 +223,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
setQuery={setQuery}
/>
)}
</Command.List>
</CommandPaletteSearch>
</Command>
<Divider my="sm" />
<Group justify="space-between">
@@ -0,0 +1,122 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { searchQueries } from '/@/renderer/features/search/api/search-api';
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { Box } from '/@/shared/components/box/box';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
interface SearchAlbumArtistsSectionProps {
debouncedQuery: string;
expanded: boolean;
isHome: boolean;
onSelectResult: () => void;
onToggle: () => void;
query: string;
}
export function SearchAlbumArtistsSection({
debouncedQuery,
expanded,
isHome,
onSelectResult,
onToggle,
query,
}: SearchAlbumArtistsSectionProps) {
const navigate = useNavigate();
const server = useCurrentServer();
const { t } = useTranslation();
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
useInfiniteQuery(
searchQueries.searchAlbumArtistsInfinite({
enabled: isHome && debouncedQuery !== '' && query !== '',
searchTerm: debouncedQuery,
serverId: server?.id,
}),
);
const artists = data?.pages.flatMap((p) => p.albumArtists) ?? [];
const showSection = isHome;
const numberOfResults = hasNextPage ? `${artists.length}+` : artists.length;
if (!showSection) return null;
return (
<CollapsibleCommandGroup
expanded={expanded}
heading="Artists"
onToggle={onToggle}
subtitle={isFetched ? t('common.numberOfResults', { numberOfResults }) : undefined}
>
{isLoading ? (
<Box p="md">
<Spinner container />
</Box>
) : (
<>
{artists.map((artist) => (
<CommandItemSelectable
key={`search-artist-${artist.id}`}
onSelect={() => {
navigate(
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
}),
);
onSelectResult();
}}
value={`search-artist-${artist.id}`}
>
{({ isHighlighted }) => (
<LibraryCommandItem
disabled={artist?.albumCount === 0}
id={artist.id}
imageId={artist.imageId}
imageUrl={artist.imageUrl}
isHighlighted={isHighlighted}
itemType={LibraryItem.ALBUM_ARTIST}
subtitle={
artist?.albumCount !== undefined &&
artist?.albumCount !== null
? t('entity.albumWithCount', {
count: artist.albumCount,
})
: undefined
}
title={artist.name}
/>
)}
</CommandItemSelectable>
))}
{hasNextPage && (
<CommandItemSelectable
disabled={isFetchingNextPage}
onSelect={() => fetchNextPage()}
value="search-artists-load-more"
>
{() => (
<Text>
{isFetchingNextPage ? (
<Spinner />
) : (
<Text size="sm">
{t('action.viewMore', { postProcess: 'titleCase' })}
</Text>
)}
</Text>
)}
</CommandItemSelectable>
)}
</>
)}
</CollapsibleCommandGroup>
);
}
@@ -0,0 +1,123 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { searchQueries } from '/@/renderer/features/search/api/search-api';
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { Box } from '/@/shared/components/box/box';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
interface SearchAlbumsSectionProps {
debouncedQuery: string;
expanded: boolean;
isHome: boolean;
onSelectResult: () => void;
onToggle: () => void;
query: string;
}
export function SearchAlbumsSection({
debouncedQuery,
expanded,
isHome,
onSelectResult,
onToggle,
query,
}: SearchAlbumsSectionProps) {
const navigate = useNavigate();
const server = useCurrentServer();
const { t } = useTranslation();
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
useInfiniteQuery(
searchQueries.searchAlbumsInfinite({
enabled: isHome && debouncedQuery !== '' && query !== '',
searchTerm: debouncedQuery,
serverId: server?.id,
}),
);
const albums = data?.pages.flatMap((p) => p.albums) ?? [];
const showSection = isHome;
const numberOfResults = hasNextPage ? `${albums.length}+` : albums.length;
if (!showSection) return null;
return (
<CollapsibleCommandGroup
expanded={expanded}
heading="Albums"
onToggle={onToggle}
subtitle={isFetched ? t('common.numberOfResults', { numberOfResults }) : undefined}
>
{isLoading ? (
<Box p="md">
<Spinner container />
</Box>
) : (
<>
{albums.map((album) => (
<CommandItemSelectable
key={`search-album-${album.id}`}
onSelect={() => {
navigate(
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: album.id,
}),
);
onSelectResult();
}}
value={`search-album-${album.id}`}
>
{({ isHighlighted }) => (
<LibraryCommandItem
explicitStatus={album.explicitStatus}
id={album.id}
imageId={album.imageId}
imageUrl={album.imageUrl}
isHighlighted={isHighlighted}
itemType={LibraryItem.ALBUM}
subtitle={album.albumArtists
.map((artist) => artist.name)
.join(', ')}
title={album.name}
/>
)}
</CommandItemSelectable>
))}
{hasNextPage && (
<CommandItemSelectable
disabled={isFetchingNextPage}
onSelect={() => fetchNextPage()}
value="search-albums-load-more"
>
{() => (
<div
style={{
display: 'flex',
justifyContent: 'center',
width: '100%',
}}
>
{isFetchingNextPage ? (
<Spinner />
) : (
<Text size="sm">
{t('action.viewMore', { postProcess: 'titleCase' })}
</Text>
)}
</div>
)}
</CommandItemSelectable>
)}
</>
)}
</CollapsibleCommandGroup>
);
}
@@ -0,0 +1,122 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { searchQueries } from '/@/renderer/features/search/api/search-api';
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { Box } from '/@/shared/components/box/box';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
interface SearchSongsSectionProps {
debouncedQuery: string;
expanded: boolean;
isHome: boolean;
onSelectResult: () => void;
onToggle: () => void;
query: string;
}
export function SearchSongsSection({
debouncedQuery,
expanded,
isHome,
onSelectResult,
onToggle,
query,
}: SearchSongsSectionProps) {
const navigate = useNavigate();
const server = useCurrentServer();
const { t } = useTranslation();
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
useInfiniteQuery(
searchQueries.searchSongsInfinite({
enabled: isHome && debouncedQuery !== '' && query !== '',
searchTerm: debouncedQuery,
serverId: server?.id,
}),
);
const songs = data?.pages.flatMap((p) => p.songs) ?? [];
const showSection = isHome;
const numberOfResults = hasNextPage ? `${songs.length}+` : songs.length;
if (!showSection) return null;
return (
<CollapsibleCommandGroup
expanded={expanded}
heading="Tracks"
onToggle={onToggle}
subtitle={isFetched ? t('common.numberOfResults', { numberOfResults }) : undefined}
>
{isLoading ? (
<Box p="md">
<Spinner container />
</Box>
) : (
<>
{songs.map((song) => (
<CommandItemSelectable
key={`search-song-${song.id}`}
onSelect={() => {
navigate(
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: song.albumId,
}),
);
onSelectResult();
}}
value={`search-song-${song.id}`}
>
{({ isHighlighted }) => (
<LibraryCommandItem
explicitStatus={song.explicitStatus}
id={song.id}
imageId={song.imageId}
imageUrl={song.imageUrl}
isHighlighted={isHighlighted}
itemType={LibraryItem.SONG}
song={song}
subtitle={song.artists.map((artist) => artist.name).join(', ')}
title={song.name}
/>
)}
</CommandItemSelectable>
))}
{hasNextPage && (
<CommandItemSelectable
disabled={isFetchingNextPage}
onSelect={() => fetchNextPage()}
value="search-songs-load-more"
>
{() => (
<div
style={{
display: 'flex',
justifyContent: 'center',
width: '100%',
}}
>
{isFetchingNextPage ? (
<Spinner />
) : (
<Text size="sm">
{t('action.viewMore', { postProcess: 'titleCase' })}
</Text>
)}
</div>
)}
</CommandItemSelectable>
)}
</>
)}
</CollapsibleCommandGroup>
);
}