Add library search
This commit is contained in:
parent
cf9ed31dfd
commit
c12c1bad73
3 changed files with 337 additions and 18 deletions
|
@ -1,11 +1,20 @@
|
||||||
/* eslint-disable react/no-unknown-property */
|
/* eslint-disable react/no-unknown-property */
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { Group, Kbd, ScrollArea } from '@mantine/core';
|
||||||
|
import { useDisclosure, useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { generatePath, useNavigate } from 'react-router';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { GoToCommands } from './go-to-commands';
|
import { GoToCommands } from './go-to-commands';
|
||||||
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
||||||
import { Modal } from '/@/renderer/components';
|
import { Modal, Paper, Spinner } from '/@/renderer/components';
|
||||||
import { HomeCommands } from './home-commands';
|
import { HomeCommands } from './home-commands';
|
||||||
|
import { ServerCommands } from '/@/renderer/features/search/components/server-commands';
|
||||||
|
import { useSearch } from '/@/renderer/features/search/queries/search-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
modalProps: typeof useDisclosure['arguments'];
|
modalProps: typeof useDisclosure['arguments'];
|
||||||
|
@ -18,8 +27,11 @@ const CustomModal = styled(Modal)`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 400);
|
||||||
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
|
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
|
||||||
const activePage = pages[pages.length - 1];
|
const activePage = pages[pages.length - 1];
|
||||||
const isHome = activePage === CommandPalettePages.HOME;
|
const isHome = activePage === CommandPalettePages.HOME;
|
||||||
|
@ -32,6 +44,26 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { data, isLoading } = useSearch({
|
||||||
|
options: { enabled: debouncedQuery !== '' && query !== '' },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: 4,
|
||||||
|
albumArtistStartIndex: 0,
|
||||||
|
albumLimit: 4,
|
||||||
|
albumStartIndex: 0,
|
||||||
|
query: debouncedQuery,
|
||||||
|
songLimit: 4,
|
||||||
|
songStartIndex: 0,
|
||||||
|
},
|
||||||
|
serverId: server?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAlbumGroup = Boolean(query && data && data?.albums?.length > 0);
|
||||||
|
const showArtistGroup = Boolean(query && data && data?.albumArtists?.length > 0);
|
||||||
|
const showTrackGroup = Boolean(query && data && data?.songs?.length > 0);
|
||||||
|
|
||||||
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomModal
|
<CustomModal
|
||||||
{...modalProps}
|
{...modalProps}
|
||||||
|
@ -47,7 +79,6 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
console.log('toggle');
|
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
modalProps.handlers.toggle();
|
modalProps.handlers.toggle();
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
@ -56,11 +87,13 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
scrollAreaComponent={ScrollArea.Autosize}
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
<Command
|
<Command
|
||||||
filter={(value, search) => {
|
filter={(value, search) => {
|
||||||
if (value.includes(search)) return 1;
|
if (value.includes(search)) return 1;
|
||||||
if (value === 'search') return 1;
|
if (value.includes('search')) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}}
|
}}
|
||||||
label="Global Command Menu"
|
label="Global Command Menu"
|
||||||
|
@ -69,14 +102,89 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
>
|
>
|
||||||
<Command.Input
|
<Command.Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Enter your search..."
|
placeholder="Enter search..."
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
/>
|
/>
|
||||||
<Command.Separator />
|
<Command.Separator />
|
||||||
<Command.List>
|
<Command.List>
|
||||||
<Command.Empty>No results found.</Command.Empty>
|
<Command.Empty>No results found.</Command.Empty>
|
||||||
|
{showAlbumGroup && (
|
||||||
|
<Command.Group heading="Albums">
|
||||||
|
{data?.albums?.map((album) => (
|
||||||
|
<Command.Item
|
||||||
|
key={`search-album-${album.id}`}
|
||||||
|
value={`search-${album.id}`}
|
||||||
|
onSelect={() =>
|
||||||
|
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: album.id }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LibraryCommandItem
|
||||||
|
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||||
|
id={album.id}
|
||||||
|
imageUrl={album.imageUrl}
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
subtitle={album.albumArtists.map((artist) => artist.name).join(', ')}
|
||||||
|
title={album.name}
|
||||||
|
/>
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
{showArtistGroup && (
|
||||||
|
<Command.Group heading="Artists">
|
||||||
|
{data?.albumArtists.map((artist) => (
|
||||||
|
<Command.Item
|
||||||
|
key={`artist-${artist.id}`}
|
||||||
|
value={`search-${artist.id}`}
|
||||||
|
onSelect={() =>
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||||
|
albumArtistId: artist.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LibraryCommandItem
|
||||||
|
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||||
|
id={artist.id}
|
||||||
|
imageUrl={artist.imageUrl}
|
||||||
|
itemType={LibraryItem.ALBUM_ARTIST}
|
||||||
|
subtitle={
|
||||||
|
(artist?.albumCount || 0) > 0 ? `${artist.albumCount} albums` : undefined
|
||||||
|
}
|
||||||
|
title={artist.name}
|
||||||
|
/>
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
{showTrackGroup && (
|
||||||
|
<Command.Group heading="Tracks">
|
||||||
|
{data?.songs.map((song) => (
|
||||||
|
<Command.Item
|
||||||
|
key={`artist-${song.id}`}
|
||||||
|
value={`search-${song.id}`}
|
||||||
|
onSelect={() =>
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: song.albumId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LibraryCommandItem
|
||||||
|
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||||
|
id={song.id}
|
||||||
|
imageUrl={song.imageUrl}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
subtitle={song.artists.map((artist) => artist.name).join(', ')}
|
||||||
|
title={song.name}
|
||||||
|
/>
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
{activePage === CommandPalettePages.HOME && (
|
{activePage === CommandPalettePages.HOME && (
|
||||||
<HomeCommands
|
<HomeCommands
|
||||||
handleClose={modalProps.handlers.close}
|
handleClose={modalProps.handlers.close}
|
||||||
|
@ -90,10 +198,32 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
<GoToCommands
|
<GoToCommands
|
||||||
handleClose={modalProps.handlers.close}
|
handleClose={modalProps.handlers.close}
|
||||||
setPages={setPages}
|
setPages={setPages}
|
||||||
|
setQuery={setQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activePage === CommandPalettePages.MANAGE_SERVERS && (
|
||||||
|
<ServerCommands
|
||||||
|
handleClose={modalProps.handlers.close}
|
||||||
|
setPages={setPages}
|
||||||
|
setQuery={setQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Command.List>
|
</Command.List>
|
||||||
</Command>
|
</Command>
|
||||||
|
<Paper
|
||||||
|
mt="0.5rem"
|
||||||
|
p="0.5rem"
|
||||||
|
>
|
||||||
|
<Group position="apart">
|
||||||
|
<Command.Loading>{isLoading && query !== '' && <Spinner />}</Command.Loading>
|
||||||
|
<Group spacing="sm">
|
||||||
|
<Kbd size="md">ESC</Kbd>
|
||||||
|
<Kbd size="md">↑</Kbd>
|
||||||
|
<Kbd size="md">↓</Kbd>
|
||||||
|
<Kbd size="md">⏎</Kbd>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
</CustomModal>
|
</CustomModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,45 +2,55 @@ import { Command as Cmdk } from 'cmdk';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
export enum CommandPalettePages {
|
export enum CommandPalettePages {
|
||||||
GO_TO = 'go to',
|
GO_TO = 'go',
|
||||||
HOME = 'home',
|
HOME = 'home',
|
||||||
|
MANAGE_SERVERS = 'servers',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Command = styled(Cmdk)`
|
export const Command = styled(Cmdk)`
|
||||||
[cmdk-root] {
|
[cmdk-root] {
|
||||||
font-family: var(--content-font-family);
|
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[cmdk-input] {
|
input[cmdk-input] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2rem;
|
height: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 0 0.5rem;
|
padding: 1.3rem 0.5rem;
|
||||||
color: var(--input-fg);
|
color: var(--input-fg);
|
||||||
font-size: 1.1rem;
|
font-size: 1.2rem;
|
||||||
background: transparent;
|
font-family: var(--content-font-family);
|
||||||
|
background: var(--input-bg);
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--input-placeholder-fg);
|
color: var(--input-placeholder-fg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div[cmdk-group-heading] {
|
[cmdk-group-heading] {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
div[cmdk-item] {
|
[cmdk-group-items] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[cmdk-item] {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
color: var(--btn-subtle-fg);
|
color: var(--btn-default-fg);
|
||||||
background: var(--btn-subtle-bg);
|
font-family: var(--content-font-family);
|
||||||
|
background: var(--btn-default-bg);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
|
@ -48,12 +58,12 @@ export const Command = styled(Cmdk)`
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-selected] {
|
&[data-selected] {
|
||||||
color: var(--btn-subtle-fg-hover);
|
color: var(--btn-default-fg-hover);
|
||||||
background: rgba(255, 255, 255, 10%);
|
background: rgba(255, 255, 255, 10%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div[cmdk-separator] {
|
[cmdk-separator] {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
background: var(--generic-border-color);
|
background: var(--generic-border-color);
|
||||||
|
|
179
src/renderer/features/search/components/library-command-item.tsx
Normal file
179
src/renderer/features/search/components/library-command-item.tsx
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import { Center, Flex } from '@mantine/core';
|
||||||
|
import { useCallback, MouseEvent } from 'react';
|
||||||
|
import {
|
||||||
|
RiAddBoxFill,
|
||||||
|
RiAddCircleFill,
|
||||||
|
RiAlbumFill,
|
||||||
|
RiPlayFill,
|
||||||
|
RiPlayListFill,
|
||||||
|
RiUserVoiceFill,
|
||||||
|
} from 'react-icons/ri';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { Button, MotionFlex, Text } from '/@/renderer/components';
|
||||||
|
import { Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const ItemGrid = styled.div<{ height: number }>`
|
||||||
|
display: grid;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
grid-template-areas: 'image info';
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ImageWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
grid-area: image;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetadataWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
grid-area: info;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledImage = styled.img`
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface LibraryCommandItemProps {
|
||||||
|
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||||
|
id: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
itemType: LibraryItem;
|
||||||
|
subtitle?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LibraryCommandItem = ({
|
||||||
|
id,
|
||||||
|
imageUrl,
|
||||||
|
subtitle,
|
||||||
|
title,
|
||||||
|
itemType,
|
||||||
|
handlePlayQueueAdd,
|
||||||
|
}: LibraryCommandItemProps) => {
|
||||||
|
let Placeholder = RiAlbumFill;
|
||||||
|
|
||||||
|
switch (itemType) {
|
||||||
|
case LibraryItem.ALBUM:
|
||||||
|
Placeholder = RiAlbumFill;
|
||||||
|
break;
|
||||||
|
case LibraryItem.ARTIST:
|
||||||
|
Placeholder = RiUserVoiceFill;
|
||||||
|
break;
|
||||||
|
case LibraryItem.ALBUM_ARTIST:
|
||||||
|
Placeholder = RiUserVoiceFill;
|
||||||
|
break;
|
||||||
|
case LibraryItem.PLAYLIST:
|
||||||
|
Placeholder = RiPlayListFill;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Placeholder = RiAlbumFill;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlay = useCallback(
|
||||||
|
(e: MouseEvent, id: string, play: Play) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePlayQueueAdd?.({
|
||||||
|
byItemType: {
|
||||||
|
id,
|
||||||
|
type: itemType,
|
||||||
|
},
|
||||||
|
play,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handlePlayQueueAdd, itemType],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
gap="xl"
|
||||||
|
justify="space-between"
|
||||||
|
style={{ height: '40px', width: '100%' }}
|
||||||
|
>
|
||||||
|
<ItemGrid height={40}>
|
||||||
|
<ImageWrapper>
|
||||||
|
{imageUrl ? (
|
||||||
|
<StyledImage
|
||||||
|
alt="cover"
|
||||||
|
height={40}
|
||||||
|
placeholder="var(--placeholder-bg)"
|
||||||
|
src={imageUrl}
|
||||||
|
style={{}}
|
||||||
|
width={40}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
background: 'var(--placeholder-bg)',
|
||||||
|
borderRadius: 'var(--card-default-radius)',
|
||||||
|
height: `${40}px`,
|
||||||
|
width: `${40}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Placeholder
|
||||||
|
color="var(--placeholder-fg)"
|
||||||
|
size={35}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</ImageWrapper>
|
||||||
|
<MetadataWrapper>
|
||||||
|
<Text overflow="hidden">{title}</Text>
|
||||||
|
<Text
|
||||||
|
$secondary
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</MetadataWrapper>
|
||||||
|
</ItemGrid>
|
||||||
|
<MotionFlex
|
||||||
|
align="center"
|
||||||
|
gap="sm"
|
||||||
|
justify="flex-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
size="md"
|
||||||
|
tooltip={{ label: 'Play', openDelay: 500 }}
|
||||||
|
variant="default"
|
||||||
|
onClick={(e) => handlePlay(e, id, Play.NOW)}
|
||||||
|
>
|
||||||
|
<RiPlayFill />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
size="md"
|
||||||
|
tooltip={{ label: 'Add to queue', openDelay: 500 }}
|
||||||
|
variant="default"
|
||||||
|
onClick={(e) => handlePlay(e, id, Play.LAST)}
|
||||||
|
>
|
||||||
|
<RiAddBoxFill />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
size="md"
|
||||||
|
tooltip={{ label: 'Play next', openDelay: 500 }}
|
||||||
|
variant="default"
|
||||||
|
onClick={(e) => handlePlay(e, id, Play.NEXT)}
|
||||||
|
>
|
||||||
|
<RiAddCircleFill />
|
||||||
|
</Button>
|
||||||
|
</MotionFlex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
Reference in a new issue