add list filter collections

This commit is contained in:
jeffvli
2026-01-30 20:03:27 -08:00
parent ad83e95a46
commit 1a5e513526
20 changed files with 681 additions and 60 deletions
@@ -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 />