mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-15 21:16:17 +02:00
add list filter collections
This commit is contained in:
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate } from 'react-router';
|
||||
import { Link, NavLink, useNavigate } from 'react-router';
|
||||
|
||||
import styles from './collapsed-sidebar.module.css';
|
||||
|
||||
@@ -12,10 +12,12 @@ import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.p
|
||||
import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
|
||||
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
||||
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
|
||||
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import {
|
||||
SidebarItemType,
|
||||
useCollections,
|
||||
useCurrentServer,
|
||||
useSidebarCollapsedNavigation,
|
||||
useSidebarItems,
|
||||
@@ -26,12 +28,14 @@ import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { ServerType } from '/@/shared/types/domain-types';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
export const CollapsedSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const collections = useCollections();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
||||
const sidebarItems = useSidebarItems();
|
||||
@@ -45,6 +49,7 @@ export const CollapsedSidebar = () => {
|
||||
'\n',
|
||||
),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
@@ -110,17 +115,66 @@ export const CollapsedSidebar = () => {
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
{sidebarItemsWithRoute.map((item) => (
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={<SidebarIcon active route={item.route} size="25" />}
|
||||
component={NavLink}
|
||||
icon={<SidebarIcon route={item.route} size="25" />}
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
route={item.route}
|
||||
to={item.route}
|
||||
/>
|
||||
))}
|
||||
{sidebarItemsWithRoute.map((item) =>
|
||||
item.id === 'Collections' ? (
|
||||
collections && collections.length > 0 ? (
|
||||
<DropdownMenu key={item.id} offset={0} position="right-end">
|
||||
<DropdownMenu.Target>
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={null}
|
||||
component={Flex}
|
||||
icon={<Icon color="muted" icon="collection" size="3xl" />}
|
||||
label={item.label}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: 'var(--theme-spacing-md) 0',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<ScrollArea style={{ maxHeight: '50vh' }}>
|
||||
<Stack gap={0} p="xs">
|
||||
{collections.map((collection) => {
|
||||
const to = getCollectionTo(collection);
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
component={Link}
|
||||
key={collection.id}
|
||||
leftSection={
|
||||
<Icon
|
||||
color="muted"
|
||||
icon={
|
||||
collection.type ===
|
||||
LibraryItem.ALBUM
|
||||
? 'itemAlbum'
|
||||
: 'itemSong'
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
to={to}
|
||||
>
|
||||
{collection.name}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
) : null
|
||||
) : (
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={<SidebarIcon active route={item.route} size="25" />}
|
||||
component={NavLink}
|
||||
icon={<SidebarIcon route={item.route} size="25" />}
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
route={item.route}
|
||||
to={item.route}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{currentServer && (
|
||||
<DropdownMenu offset={0} position="right-end" width={240}>
|
||||
<DropdownMenu.Target>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.row:hover .more-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-active {
|
||||
background-color: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
.row-link {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||
color: var(--theme-colors-foreground);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 1px transparent solid;
|
||||
border-radius: var(--theme-radius-md);
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus-visible {
|
||||
@mixin dark {
|
||||
background-color: lighten(var(--theme-colors-background), 10%);
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: darken(var(--theme-colors-background), 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
}
|
||||
|
||||
.row-active .row-link {
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.row-active .row-link .name {
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.row-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import clsx from 'clsx';
|
||||
import { MouseEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import styles from './sidebar-collection-list.module.css';
|
||||
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCollections, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { getFilterQueryStringFromSearchParams } from '/@/renderer/utils/query-params';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import { LibraryItem, SavedCollection } from '/@/shared/types/domain-types';
|
||||
|
||||
export const getCollectionTo = (collection: SavedCollection) => {
|
||||
const pathname =
|
||||
collection.type === LibraryItem.ALBUM ? AppRoute.LIBRARY_ALBUMS : AppRoute.LIBRARY_SONGS;
|
||||
const search = collection.filterQueryString ? `?${collection.filterQueryString}` : '';
|
||||
return { pathname, search };
|
||||
};
|
||||
|
||||
const CollectionRow = ({
|
||||
collection,
|
||||
onRename,
|
||||
}: {
|
||||
collection: SavedCollection;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { removeCollection } = useSettingsStoreActions();
|
||||
const [isRenameOpen, renameHandlers] = useDisclosure(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: collection.name,
|
||||
},
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const to = getCollectionTo(collection);
|
||||
|
||||
const currentFilterQuery = getFilterQueryStringFromSearchParams(
|
||||
new URLSearchParams(location.search),
|
||||
);
|
||||
const collectionFilterQuery = collection.filterQueryString ?? '';
|
||||
const isActive =
|
||||
location.pathname === to.pathname && currentFilterQuery === collectionFilterQuery;
|
||||
|
||||
const handleRenameOpen = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.setValues({ name: collection.name });
|
||||
renameHandlers.open();
|
||||
},
|
||||
[collection.name, form, renameHandlers],
|
||||
);
|
||||
|
||||
const handleRenameSubmit = form.onSubmit((values) => {
|
||||
const trimmed = values.name.trim();
|
||||
if (trimmed) {
|
||||
onRename(collection.id, trimmed);
|
||||
renameHandlers.close();
|
||||
}
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeCollection(collection.id);
|
||||
},
|
||||
[collection.id, removeCollection],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onClose={renameHandlers.close}
|
||||
opened={isRenameOpen}
|
||||
position="right-start"
|
||||
width={280}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div className={clsx(styles.row, { [styles.rowActive]: isActive })}>
|
||||
<Link className={styles.rowLink} to={to}>
|
||||
<Group className={styles.rowContent}>
|
||||
<Icon
|
||||
color={isActive ? 'primary' : 'muted'}
|
||||
icon={
|
||||
collection.type === LibraryItem.ALBUM ? 'itemAlbum' : 'itemSong'
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
<Text className={styles.name} fw={500} size="md">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<DropdownMenu position="right-start" trigger="click">
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
className={styles.moreButton}
|
||||
icon="ellipsisVertical"
|
||||
iconProps={{ size: 'xs' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
size="compact-sm"
|
||||
variant="transparent"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item onClick={handleRenameOpen}>
|
||||
{t('common.rename', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item color="red" onClick={handleDelete}>
|
||||
{t('common.delete', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Link>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<form onSubmit={handleRenameSubmit}>
|
||||
<Stack gap="md" p="xs">
|
||||
<TextInput
|
||||
autoFocus
|
||||
maxLength={128}
|
||||
variant="filled"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button onClick={renameHandlers.close} type="button" variant="subtle">
|
||||
{t('common.cancel', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="filled">
|
||||
{t('common.save', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarCollectionList = () => {
|
||||
const { t } = useTranslation();
|
||||
const collections = useCollections();
|
||||
const { updateCollection } = useSettingsStoreActions();
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, name: string) => {
|
||||
updateCollection(id, { name });
|
||||
},
|
||||
[updateCollection],
|
||||
);
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item value="collections">
|
||||
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
||||
<Text fw={500}>{t('page.sidebar.collections', { postProcess: 'titleCase' })}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{collections.map((collection) => (
|
||||
<CollectionRow
|
||||
collection={collection}
|
||||
key={collection.id}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
|
||||
import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
|
||||
import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ export const Sidebar = () => {
|
||||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
@@ -84,6 +86,12 @@ export const Sidebar = () => {
|
||||
return items;
|
||||
}, [sidebarItems, translatedSidebarItemMap]);
|
||||
|
||||
/* Library accordion: only items with a route (exclude Collections section) */
|
||||
const libraryItemsWithRoute = useMemo(
|
||||
() => sidebarItemsWithRoute.filter((item) => item.id !== 'Collections' && item.route),
|
||||
[sidebarItemsWithRoute],
|
||||
);
|
||||
|
||||
const isCustomWindowBar =
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
|
||||
|
||||
@@ -105,7 +113,7 @@ export const Sidebar = () => {
|
||||
item: styles.accordionItem,
|
||||
root: styles.accordionRoot,
|
||||
}}
|
||||
defaultValue={['library', 'playlists']}
|
||||
defaultValue={['library', 'collections', 'playlists']}
|
||||
multiple
|
||||
>
|
||||
<Accordion.Item value="library">
|
||||
@@ -117,7 +125,7 @@ export const Sidebar = () => {
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{sidebarItemsWithRoute.map((item) => {
|
||||
{libraryItemsWithRoute.map((item) => {
|
||||
return (
|
||||
<SidebarItem key={`sidebar-${item.route}`} to={item.route}>
|
||||
<Group gap="md">
|
||||
@@ -129,6 +137,7 @@ export const Sidebar = () => {
|
||||
})}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<SidebarCollectionList />
|
||||
{sidebarPlaylistList && (
|
||||
<>
|
||||
<SidebarPlaylistList />
|
||||
|
||||
Reference in New Issue
Block a user