mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-14 20:40:21 +02:00
add global music folder selector
This commit is contained in:
@@ -3,11 +3,8 @@ import { useNavigate } from 'react-router';
|
||||
|
||||
import styles from './action-bar.module.css';
|
||||
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useCommandPalette } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Grid } from '/@/shared/components/grid/grid';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
@@ -15,14 +12,18 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
|
||||
export const ActionBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { ref, ...cq } = useContainerQuery({ md: 300 });
|
||||
const navigate = useNavigate();
|
||||
const { open } = useCommandPalette();
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={ref}>
|
||||
<Grid display="flex" gutter="sm" px="1rem" w="100%">
|
||||
<Grid.Col span={6}>
|
||||
<div className={styles.container}>
|
||||
<Grid
|
||||
display="flex"
|
||||
gutter="sm"
|
||||
style={{ padding: '0 var(--theme-spacing-md)' }}
|
||||
w="100%"
|
||||
>
|
||||
<Grid.Col span={8}>
|
||||
<TextInput
|
||||
leftSection={<Icon icon="search" />}
|
||||
onClick={open}
|
||||
@@ -35,18 +36,8 @@ export const ActionBar = () => {
|
||||
readOnly
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Grid.Col span={4}>
|
||||
<Group gap="sm" grow wrap="nowrap">
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button p="0.5rem">
|
||||
<Icon icon="menu" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button onClick={() => navigate(-1)} p="0.5rem">
|
||||
<Icon icon="arrowLeftS" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { ServerList } from '/@/renderer/features/servers/components/server-list';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
export const ServerSelectorItems = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const currentServer = useCurrentServer();
|
||||
const serverList = useServerList();
|
||||
const { setCurrentServer, setMusicFolderId } = useAuthStoreActions();
|
||||
|
||||
const { data: musicFolders } = useQuery(
|
||||
currentServer
|
||||
? sharedQueries.musicFolders({ query: null, serverId: currentServer.id })
|
||||
: { enabled: false, queryKey: ['disabled'] },
|
||||
);
|
||||
|
||||
const handleSetCurrentServer = (server: ServerListItemWithCredential) => {
|
||||
navigate(AppRoute.HOME);
|
||||
setCurrentServer(server);
|
||||
setMusicFolderId(undefined);
|
||||
};
|
||||
|
||||
const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleToggleMusicFolder = (musicFolderId: string) => {
|
||||
if (supportsMultiSelect) {
|
||||
const currentIds = currentServer.musicFolderId || [];
|
||||
const isSelected = currentIds.includes(musicFolderId);
|
||||
|
||||
if (isSelected) {
|
||||
// Remove from selection
|
||||
const newIds = currentIds.filter((id) => id !== musicFolderId);
|
||||
setMusicFolderId(newIds.length > 0 ? newIds : undefined);
|
||||
} else {
|
||||
// Add to selection
|
||||
setMusicFolderId([...currentIds, musicFolderId]);
|
||||
}
|
||||
} else {
|
||||
const currentId = Array.isArray(currentServer.musicFolderId)
|
||||
? currentServer.musicFolderId[0]
|
||||
: currentServer.musicFolderId;
|
||||
const isSelected = currentId === musicFolderId;
|
||||
|
||||
if (isSelected) {
|
||||
setMusicFolderId(undefined);
|
||||
} else {
|
||||
setMusicFolderId([musicFolderId]);
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.resetQueries();
|
||||
};
|
||||
|
||||
const handleClearMusicFolders = () => {
|
||||
setMusicFolderId(undefined);
|
||||
queryClient.resetQueries();
|
||||
};
|
||||
|
||||
if (!currentServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedMusicFolders =
|
||||
musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) ||
|
||||
[];
|
||||
|
||||
const handleManageServersModal = () => {
|
||||
openModal({
|
||||
children: <ServerList />,
|
||||
title: t('page.manageServers.title', { postProcess: 'titleCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('page.appMenu.selectServer', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Label>
|
||||
{Object.values(serverList).map((server) => {
|
||||
const isNavidromeExpired =
|
||||
server.type === ServerType.NAVIDROME && !server.ndCredential;
|
||||
const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential;
|
||||
const isSessionExpired = isNavidromeExpired || isJellyfinExpired;
|
||||
|
||||
const logo =
|
||||
server.type === ServerType.NAVIDROME
|
||||
? NavidromeLogo
|
||||
: server.type === ServerType.JELLYFIN
|
||||
? JellyfinLogo
|
||||
: OpenSubsonicLogo;
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
isSelected={currentServer?.id === server.id}
|
||||
key={`server-${server.id}`}
|
||||
leftSection={<img src={logo} style={{ height: '1rem', width: '1rem' }} />}
|
||||
onClick={() => {
|
||||
if (!isSessionExpired) {
|
||||
handleSetCurrentServer(server);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{server.name}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="edit" />}
|
||||
onClick={handleManageServersModal}
|
||||
>
|
||||
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{musicFolders && musicFolders.items.length > 0 && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('page.appMenu.selectMusicFolder', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
isSelected={selectedMusicFolders.length === 0}
|
||||
leftSection={<Icon icon="minus" />}
|
||||
onClick={handleClearMusicFolders}
|
||||
>
|
||||
{t('common.none', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{musicFolders.items.map((folder) => {
|
||||
const isSelected = supportsMultiSelect
|
||||
? currentServer.musicFolderId?.includes(folder.id) || false
|
||||
: (Array.isArray(currentServer.musicFolderId)
|
||||
? currentServer.musicFolderId[0]
|
||||
: currentServer.musicFolderId) === folder.id;
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
isSelected={isSelected}
|
||||
key={`musicFolder-${folder.id}`}
|
||||
leftSection={<Icon icon={isSelected ? 'check' : 'folder'} />}
|
||||
onClick={() => handleToggleMusicFolder(folder.id)}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
.button-container {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--theme-spacing-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-container-no-bottom-padding {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
padding: var(--theme-spacing-sm);
|
||||
background: var(--theme-colors-surface);
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
object-fit: cover;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.button-stack {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.popover-target {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroll-area {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.server-logo {
|
||||
width: var(--theme-font-size-md);
|
||||
height: var(--theme-font-size-md);
|
||||
}
|
||||
|
||||
.server-button {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './server-selector.module.css';
|
||||
|
||||
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { ServerType } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
interface ServerSelectorProps {
|
||||
showImage?: boolean;
|
||||
}
|
||||
|
||||
export const ServerSelector = ({ showImage = false }: ServerSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const currentServer = useCurrentServer();
|
||||
|
||||
const { data: musicFolders } = useQuery(
|
||||
currentServer
|
||||
? sharedQueries.musicFolders({ query: null, serverId: currentServer.id })
|
||||
: { enabled: false, queryKey: ['disabled'] },
|
||||
);
|
||||
|
||||
if (!currentServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT);
|
||||
|
||||
const selectedMusicFolders =
|
||||
musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) ||
|
||||
[];
|
||||
|
||||
const musicFolderDisplayText = (() => {
|
||||
if (selectedMusicFolders.length === 0) {
|
||||
return t('page.appMenu.noMusicFolder', { postProcess: 'sentenceCase' });
|
||||
}
|
||||
|
||||
if (supportsMultiSelect && selectedMusicFolders.length > 1) {
|
||||
return t('page.appMenu.multipleMusicFolders', {
|
||||
count: selectedMusicFolders.length,
|
||||
postProcess: 'sentenceCase',
|
||||
});
|
||||
}
|
||||
|
||||
return selectedMusicFolders[0].name;
|
||||
})();
|
||||
|
||||
const logo =
|
||||
currentServer.type === ServerType.NAVIDROME
|
||||
? NavidromeLogo
|
||||
: currentServer.type === ServerType.JELLYFIN
|
||||
? JellyfinLogo
|
||||
: OpenSubsonicLogo;
|
||||
|
||||
return (
|
||||
<DropdownMenu position="right">
|
||||
<DropdownMenu.Target>
|
||||
<div className={styles.popoverTarget}>
|
||||
<Box
|
||||
className={`${styles.buttonContainer} ${
|
||||
showImage ? styles.buttonContainerNoBottomPadding : ''
|
||||
}`}
|
||||
>
|
||||
<Group className={styles.buttonGroup} gap="sm">
|
||||
<img className={styles.logo} src={logo} />
|
||||
<Stack className={styles.buttonStack} gap={2}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{currentServer.name}
|
||||
</Text>
|
||||
<Text isMuted size="xs" truncate>
|
||||
{musicFolderDisplayText}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Icon icon="ellipsisVertical" size="sm" />
|
||||
</Group>
|
||||
</Box>
|
||||
</div>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,8 @@
|
||||
}
|
||||
|
||||
.scroll-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
@@ -27,10 +29,11 @@
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: var(--sidebar-image-height);
|
||||
height: var(--sidebar-image-height);
|
||||
padding: var(--theme-spacing-md);
|
||||
cursor: pointer;
|
||||
animation: fade-in 0.2s ease-in-out;
|
||||
|
||||
@@ -48,7 +51,7 @@
|
||||
height: 100%;
|
||||
object-fit: var(--theme-image-fit);
|
||||
background: var(--theme-colors-foreground-muted);
|
||||
border-radius: 0;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.accordion-root {
|
||||
|
||||
@@ -8,6 +8,7 @@ import styles from './sidebar.module.css';
|
||||
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
@@ -106,15 +107,6 @@ export const Sidebar = () => {
|
||||
return items;
|
||||
}, [sidebarItems, translatedSidebarItemMap]);
|
||||
|
||||
const scrollAreaHeight = useMemo(() => {
|
||||
if (showImage) {
|
||||
// Subtract the height of the top bar and padding
|
||||
return `calc(100% - 65px - var(--mantine-spacing-xs) - ${sidebar.leftWidth})`;
|
||||
}
|
||||
|
||||
return '100%';
|
||||
}, [showImage, sidebar.leftWidth]);
|
||||
|
||||
const isCustomWindowBar =
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
|
||||
|
||||
@@ -125,16 +117,10 @@ export const Sidebar = () => {
|
||||
})}
|
||||
id="left-sidebar"
|
||||
>
|
||||
<Group grow id="global-search-container">
|
||||
<Group grow id="global-search-container" style={{ flexShrink: 0 }}>
|
||||
<ActionBar />
|
||||
</Group>
|
||||
<ScrollArea
|
||||
allowDragScroll
|
||||
className={styles.scrollArea}
|
||||
style={{
|
||||
height: scrollAreaHeight,
|
||||
}}
|
||||
>
|
||||
<ScrollArea allowDragScroll className={styles.scrollArea}>
|
||||
<Accordion
|
||||
classNames={{
|
||||
content: styles.accordionContent,
|
||||
@@ -177,6 +163,9 @@ export const Sidebar = () => {
|
||||
)}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
<div style={{ flexShrink: 0, position: 'relative', zIndex: 1 }}>
|
||||
<ServerSelector showImage={showImage} />
|
||||
</div>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{showImage && (
|
||||
<motion.div
|
||||
@@ -221,8 +210,8 @@ export const Sidebar = () => {
|
||||
style={{
|
||||
cursor: 'default',
|
||||
position: 'absolute',
|
||||
right: 5,
|
||||
top: 5,
|
||||
right: '1rem',
|
||||
top: '1rem',
|
||||
}}
|
||||
tooltip={{
|
||||
label: t('common.collapse', {
|
||||
|
||||
Reference in New Issue
Block a user