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
+6
View File
@@ -128,6 +128,7 @@
"releaseType": "release type",
"refresh": "refresh",
"reload": "reload",
"rename": "rename",
"reset": "reset",
"resetToDefault": "reset to default",
"restartRequired": "restart required",
@@ -560,6 +561,10 @@
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"collections": {
"overrideExisting": "override existing",
"saveAsCollection": "save as collection"
},
"setting": {
"advanced": "advanced",
"analytics": "analytics",
@@ -588,6 +593,7 @@
"sidebar": {
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})",
"collections": "collections",
"artists": "$t(entity.artist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"folders": "$t(entity.folder, {\"count\": 2})",
@@ -4,6 +4,7 @@ import { useListContext } from '/@/renderer/context/list-context';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner';
@@ -38,11 +39,14 @@ const AlbumListPaginatedTable = lazy(() =>
const AlbumListFilters = () => {
return (
<ListWithSidebarContainer.SidebarPortal>
<Stack h="100%">
<Stack h="100%" style={{ minHeight: 0 }}>
<ListFiltersTitle itemType={LibraryItem.ALBUM} />
<ScrollArea>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ListFilters itemType={LibraryItem.ALBUM} />
</ScrollArea>
<Stack p="sm">
<SaveAsCollectionButton fullWidth itemType={LibraryItem.ALBUM} />
</Stack>
</Stack>
</ListWithSidebarContainer.SidebarPortal>
);
@@ -19,6 +19,7 @@ const SIDEBAR_ITEMS: Array<[string, string]> = [
[SidebarItem.HOME, 'page.sidebar.home'],
[SidebarItem.NOW_PLAYING, 'page.sidebar.nowPlaying'],
[SidebarItem.PLAYLISTS, 'page.sidebar.playlists'],
[SidebarItem.COLLECTIONS, 'page.sidebar.collections'],
[SidebarItem.RADIO, 'page.sidebar.radio'],
[SidebarItem.SEARCH, 'page.sidebar.search'],
[SidebarItem.SETTINGS, 'page.sidebar.settings'],
@@ -1,7 +1,6 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
@@ -40,29 +39,25 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
return (
<Flex>
<PageHeader>
<LibraryHeaderBar>
<Flex align="center" justify="space-between" w="100%">
<Group wrap="nowrap">
<Icon icon="settings" size="5xl" />
<LibraryHeaderBar.Title>
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
</Group>
<Group>
<SearchInput
defaultValue={search}
onChange={(event) =>
setSearch(event.target.value.toLocaleLowerCase())
}
/>
<Button onClick={openResetConfirmModal} variant="default">
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
</Button>
</Group>
</Flex>
</LibraryHeaderBar>
</PageHeader>
<LibraryHeaderBar>
<Flex align="center" justify="space-between" w="100%">
<Group wrap="nowrap">
<Icon icon="settings" size="5xl" />
<LibraryHeaderBar.Title>
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
</Group>
<Group>
<SearchInput
defaultValue={search}
onChange={(event) => setSearch(event.target.value.toLocaleLowerCase())}
/>
<Button onClick={openResetConfirmModal} variant="default">
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
</Button>
</Group>
</Flex>
</LibraryHeaderBar>
</Flex>
);
};
@@ -47,10 +47,18 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
const [isOpen, handlers] = useDisclosure(false);
const albumListFilters = useAlbumListFilters(pageKey as ItemListKey);
const songListFilters = useSongListFilters(pageKey as ItemListKey);
const clear = itemType === LibraryItem.ALBUM ? albumListFilters.clear : songListFilters.clear;
const handlePin = () => {
setIsSidebarOpen?.(!isSidebarOpen);
};
const handleReset = () => {
clear();
};
const canPin = Boolean(setIsSidebarOpen);
const disableArtistFilter = pageKey === ItemListKey.ALBUM_ARTIST_ALBUM;
@@ -72,15 +80,21 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
},
}}
title={
<Group>
{canPin && (
<ActionIcon
icon={isSidebarOpen ? 'unpin' : 'pin'}
onClick={handlePin}
variant="subtle"
/>
)}
{t('common.filters', { postProcess: 'sentenceCase' })}
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
<Group>
{canPin && (
<ActionIcon
icon={isSidebarOpen ? 'unpin' : 'pin'}
onClick={handlePin}
variant="subtle"
/>
)}
{t('common.filters', { postProcess: 'sentenceCase' })}
</Group>
<Button onClick={handleReset} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
</Group>
}
>
@@ -0,0 +1,14 @@
.list {
flex: 0 0 200px;
height: 200px;
overflow: hidden;
border: 1px solid var(--theme-colors-border);
}
.row {
display: flex;
gap: var(--theme-spacing-xs);
align-items: center;
width: 100%;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
}
@@ -0,0 +1,160 @@
import { nanoid } from 'nanoid';
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router';
import styles from './save-as-collection-button.module.css';
import { useListContext } from '/@/renderer/context/list-context';
import { useCollections, useSettingsStoreActions } from '/@/renderer/store';
import { getFilterQueryStringFromSearchParams } from '/@/renderer/utils/query-params';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Popover } from '/@/shared/components/popover/popover';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
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';
interface SaveAsCollectionButtonProps {
fullWidth?: boolean;
itemType: LibraryItem.ALBUM | LibraryItem.SONG;
}
export const SaveAsCollectionButton = ({ fullWidth, itemType }: SaveAsCollectionButtonProps) => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const { customFilters } = useListContext();
const collections = useCollections();
const { addCollection, updateCollection } = useSettingsStoreActions();
const [isOpen, handlers] = useDisclosure(false);
const formRef = useRef<HTMLFormElement>(null);
const sameTypeCollections = useMemo(
() => collections?.filter((c): c is SavedCollection => c.type === itemType) ?? [],
[collections, itemType],
);
const form = useForm({
initialValues: {
name: '',
},
});
const handleOpen = useCallback(() => {
form.setValues({ name: '' });
handlers.open();
}, [form, handlers]);
const handleOverrideExisting = useCallback(
(collection: SavedCollection) => {
const filterQueryString = getFilterQueryStringFromSearchParams(
searchParams,
customFilters as Record<
string,
boolean | number | Record<string, unknown> | string | string[]
>,
);
updateCollection(collection.id, { filterQueryString });
handlers.close();
},
[customFilters, handlers, searchParams, updateCollection],
);
const handleSubmit = form.onSubmit((values) => {
const trimmed = values.name.trim();
if (!trimmed) return;
const filterQueryString = getFilterQueryStringFromSearchParams(
searchParams,
customFilters as Record<
string,
boolean | number | Record<string, unknown> | string | string[]
>,
);
addCollection({
filterQueryString,
id: nanoid(),
name: trimmed,
type: itemType,
});
handlers.close();
});
const handleFormKeyDown = useCallback((e: React.KeyboardEvent<HTMLFormElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
formRef.current?.requestSubmit();
}
}, []);
return (
<Popover onClose={handlers.close} opened={isOpen} position="bottom-start" width={320}>
<Popover.Target>
{fullWidth ? (
<Button fullWidth onClick={handleOpen} variant="default">
{t('page.collections.saveAsCollection', {
postProcess: 'sentenceCase',
})}
</Button>
) : (
<ActionIcon
icon="folder"
iconProps={{ size: 'lg' }}
onClick={handleOpen}
tooltip={{
label: t('page.collections.saveAsCollection', {
postProcess: 'sentenceCase',
}),
}}
variant="subtle"
/>
)}
</Popover.Target>
<Popover.Dropdown>
<form onKeyDown={handleFormKeyDown} onSubmit={handleSubmit} ref={formRef}>
<Stack gap="sm">
<Text fw={500} size="sm" ta="center">
{t('page.collections.overrideExisting', {
postProcess: 'sentenceCase',
})}
</Text>
<div className={styles.list}>
<ScrollArea>
<Stack gap={0}>
{sameTypeCollections.map((collection) => (
<Button
className={styles.row}
key={collection.id}
onClick={() => handleOverrideExisting(collection)}
type="button"
variant="subtle"
>
<Text className={styles['row-name']} size="sm">
{collection.name}
</Text>
</Button>
))}
</Stack>
</ScrollArea>
</div>
<TextInput autoFocus maxLength={128} {...form.getInputProps('name')} />
<Group gap="xs" justify="flex-end">
<Button onClick={handlers.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>
);
};
@@ -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 />
@@ -3,6 +3,7 @@ import { lazy, Suspense, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
@@ -44,11 +45,14 @@ export const SongListContent = () => {
const SongListFilters = () => {
return (
<ListWithSidebarContainer.SidebarPortal>
<Stack h="100%">
<Stack h="100%" style={{ minHeight: 0 }}>
<ListFiltersTitle itemType={LibraryItem.SONG} />
<ScrollArea>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ListFilters itemType={LibraryItem.SONG} />
</ScrollArea>
<Stack p="sm">
<SaveAsCollectionButton fullWidth itemType={LibraryItem.SONG} />
</Stack>
</Stack>
</ListWithSidebarContainer.SidebarPortal>
);
+53 -1
View File
@@ -1,6 +1,7 @@
import isElectron from 'is-electron';
import mergeWith from 'lodash/mergeWith';
import { nanoid } from 'nanoid';
import { useMemo } from 'react';
import { generatePath } from 'react-router';
import { z } from 'zod';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
@@ -26,7 +27,7 @@ import { FontValueSchema } from '/@/renderer/types/fonts';
import { randomString } from '/@/renderer/utils';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { AppTheme } from '/@/shared/themes/app-theme-types';
import { LibraryItem, LyricSource } from '/@/shared/types/domain-types';
import { LibraryItem, LyricSource, SavedCollection } from '/@/shared/types/domain-types';
import {
FontType,
ItemListKey,
@@ -154,6 +155,13 @@ const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
const CollectionSchema = z.object({
filterQueryString: z.string(),
id: z.string(),
name: z.string(),
type: z.enum([LibraryItem.ALBUM, LibraryItem.SONG]),
});
const SidebarItemTypeSchema = z.object({
disabled: z.boolean(),
id: z.string(),
@@ -408,6 +416,7 @@ export const GeneralSettingsSchema = z.object({
artistRadioCount: z.number(),
artistReleaseTypeItems: z.array(SortableItemSchema(ArtistReleaseTypeItemSchema)),
buttonSize: z.number(),
collections: z.array(CollectionSchema),
combinedLyricsAndVisualizer: z.boolean(),
disabledContextMenu: z.record(z.string(), z.boolean()),
enableGridMultiSelect: z.boolean(),
@@ -755,6 +764,7 @@ export enum SidebarItem {
ALBUMS = 'Albums',
ARTISTS = 'Artists',
ARTISTS_ALL = 'Artists-all',
COLLECTIONS = 'Collections',
FAVORITES = 'Favorites',
FOLDERS = 'Folders',
GENRES = 'Genres',
@@ -792,6 +802,8 @@ export type PlayerFilterOperator = z.infer<typeof PlayerFilterOperatorSchema>;
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
actions: {
addCollection: (collection: SavedCollection) => void;
removeCollection: (id: string) => void;
reset: () => void;
resetSampleRate: () => void;
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
@@ -806,6 +818,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
setTranscodingConfig: (config: TranscodingConfig) => void;
toggleMediaSession: () => void;
toggleSidebarCollapseShare: () => void;
updateCollection: (id: string, updates: Partial<Omit<SavedCollection, 'id'>>) => void;
};
}
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
@@ -884,6 +897,12 @@ export const sidebarItems: SidebarItemType[] = [
label: i18n.t('page.sidebar.playlists'),
route: AppRoute.PLAYLISTS,
},
{
disabled: false,
id: 'Collections',
label: i18n.t('page.sidebar.collections'),
route: '',
},
{
disabled: false,
id: 'Radio',
@@ -967,6 +986,7 @@ const initialState: SettingsState = {
artistRadioCount: 20,
artistReleaseTypeItems,
buttonSize: 15,
collections: [],
combinedLyricsAndVisualizer: false,
disabledContextMenu: {},
enableGridMultiSelect: false,
@@ -1659,6 +1679,18 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
subscribeWithSelector(
immer((set) => ({
actions: {
addCollection: (collection: SavedCollection) => {
set((state) => {
state.general.collections.push(collection);
});
},
removeCollection: (id: string) => {
set((state) => {
state.general.collections = state.general.collections.filter(
(c) => c.id !== id,
);
});
},
reset: () => {
localStorage.removeItem('store_settings');
window.location.reload();
@@ -1748,6 +1780,17 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
!state.general.sidebarCollapseShared;
});
},
updateCollection: (
id: string,
updates: Partial<Omit<SavedCollection, 'id'>>,
) => {
set((state) => {
const idx = state.general.collections.findIndex((c) => c.id === id);
if (idx !== -1) {
Object.assign(state.general.collections[idx], updates);
}
});
},
},
...initialState,
})),
@@ -2135,6 +2178,15 @@ export const useSideQueueType = () =>
export const useVolumeWheelStep = () =>
useSettingsStore((state) => state.general.volumeWheelStep, shallow);
export const useCollections = () => {
const collections = useSettingsStore((state) => state.general.collections, shallow);
return useMemo(
() => [...(collections ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
[collections],
);
};
export const useSidebarPlaylistList = () =>
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
+30
View File
@@ -178,3 +178,33 @@ export const parseCustomFiltersParam = (
return undefined;
}
};
const PAGINATION_KEYS = ['currentPage', 'scrollOffset'];
/**
* Build filter query string from current search params (minus pagination/scroll).
* Optionally merge customFilters (e.g. from ListContext) into the result.
*/
export const getFilterQueryStringFromSearchParams = (
searchParams: URLSearchParams,
customFilters?: Record<string, boolean | number | Record<string, unknown> | string | string[]>,
): string => {
const params = new URLSearchParams(searchParams);
for (const key of PAGINATION_KEYS) {
params.delete(key);
}
if (customFilters && Object.keys(customFilters).length > 0) {
for (const [key, value] of Object.entries(customFilters)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
params.delete(key);
value.forEach((v) => params.append(key, String(v)));
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
params.set(key, JSON.stringify(value));
} else {
params.set(key, String(value));
}
}
}
return params.toString();
};
+2
View File
@@ -75,6 +75,7 @@ import {
LuMoon,
LuMusic,
LuMusic2,
LuPackage2,
LuPanelRightClose,
LuPanelRightOpen,
LuPause,
@@ -152,6 +153,7 @@ export const AppIcon = {
cache: LuCloudDownload,
check: LuCheck,
clipboardCopy: LuClipboardCopy,
collection: LuPackage2,
delete: LuDelete,
disc: LuDisc,
download: LuDownload,
+5 -3
View File
@@ -2,7 +2,7 @@
display: flex;
justify-content: center;
margin-bottom: var(--theme-spacing-md);
background: var(--theme-colors-background);
background: none;
border-radius: 0;
}
@@ -14,14 +14,16 @@
}
.content {
padding: var(--theme-spacing-md);
padding: var(--theme-spacing-sm);
overflow: hidden;
background: var(--theme-colors-background);
border: 2px solid var(--theme-colors-border);
border-radius: var(--theme-radius-md);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
}
.close {
position: absolute;
top: var(--theme-spacing-md);
right: var(--theme-spacing-md);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export const Modal = ({ children, classNames, handlers, ...rest }: ModalProps) =
backgroundOpacity: 0.5,
blur: 1,
}}
radius="xl"
radius="md"
scrollAreaComponent={ScrollArea}
transitionProps={{
duration: 300,
@@ -1,8 +1,9 @@
.dropdown {
padding: var(--theme-spacing-lg);
padding: var(--theme-spacing-sm);
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
border: 1px solid var(--theme-colors-border);
border-radius: var(--theme-radius-xl);
border: 2px solid var(--theme-colors-border);
border-radius: var(--theme-radius-md);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
}
@@ -36,6 +36,8 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
classNames={{
dropdown: styles.dropdown,
}}
closeOnClickOutside={true}
closeOnEscape={true}
offset={10}
transitionProps={{ transition: getTransition(props.position) }}
withArrow={false}
+7
View File
@@ -76,6 +76,13 @@ export type QueueSong = Song & {
_uniqueId: string;
};
export interface SavedCollection {
filterQueryString: string;
id: string;
name: string;
type: LibraryItem.ALBUM | LibraryItem.SONG;
}
export type ServerListItem = {
features?: ServerFeatures;
id: string;