From 1a5e513526700021e0e4df6fa16a1f91e8093d45 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 30 Jan 2026 20:03:27 -0800 Subject: [PATCH] add list filter collections --- src/i18n/locales/en.json | 6 + .../albums/components/album-list-content.tsx | 8 +- .../components/general/sidebar-reorder.tsx | 1 + .../settings/components/settings-header.tsx | 43 ++-- .../shared/components/list-filters.tsx | 32 ++- .../save-as-collection-button.module.css | 14 ++ .../components/save-as-collection-button.tsx | 160 +++++++++++++++ .../sidebar/components/collapsed-sidebar.tsx | 80 ++++++-- .../sidebar-collection-list.module.css | 74 +++++++ .../components/sidebar-collection-list.tsx | 190 ++++++++++++++++++ .../features/sidebar/components/sidebar.tsx | 13 +- .../songs/components/song-list-content.tsx | 8 +- src/renderer/store/settings.store.ts | 54 ++++- src/renderer/utils/query-params.ts | 30 +++ src/shared/components/icon/icon.tsx | 2 + src/shared/components/modal/modal.module.css | 8 +- src/shared/components/modal/modal.tsx | 2 +- .../components/popover/popover.module.css | 7 +- src/shared/components/popover/popover.tsx | 2 + src/shared/types/domain-types.ts | 7 + 20 files changed, 681 insertions(+), 60 deletions(-) create mode 100644 src/renderer/features/shared/components/save-as-collection-button.module.css create mode 100644 src/renderer/features/shared/components/save-as-collection-button.tsx create mode 100644 src/renderer/features/sidebar/components/sidebar-collection-list.module.css create mode 100644 src/renderer/features/sidebar/components/sidebar-collection-list.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ba19a0bb8..debdddd01 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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})", diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index 8d84cc424..e530772d8 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -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 ( - + - + + + + ); diff --git a/src/renderer/features/settings/components/general/sidebar-reorder.tsx b/src/renderer/features/settings/components/general/sidebar-reorder.tsx index 68f18fc40..95c43e0aa 100644 --- a/src/renderer/features/settings/components/general/sidebar-reorder.tsx +++ b/src/renderer/features/settings/components/general/sidebar-reorder.tsx @@ -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'], diff --git a/src/renderer/features/settings/components/settings-header.tsx b/src/renderer/features/settings/components/settings-header.tsx index 5ff1f3804..0a3f5e90b 100644 --- a/src/renderer/features/settings/components/settings-header.tsx +++ b/src/renderer/features/settings/components/settings-header.tsx @@ -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 ( - - - - - - - {t('common.setting', { count: 2, postProcess: 'titleCase' })} - - - - - setSearch(event.target.value.toLocaleLowerCase()) - } - /> - - - - - + + + + + + {t('common.setting', { count: 2, postProcess: 'titleCase' })} + + + + setSearch(event.target.value.toLocaleLowerCase())} + /> + + + + ); }; diff --git a/src/renderer/features/shared/components/list-filters.tsx b/src/renderer/features/shared/components/list-filters.tsx index 8f1f7106f..a236c2965 100644 --- a/src/renderer/features/shared/components/list-filters.tsx +++ b/src/renderer/features/shared/components/list-filters.tsx @@ -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={ - - {canPin && ( - - )} - {t('common.filters', { postProcess: 'sentenceCase' })} + + + {canPin && ( + + )} + + {t('common.filters', { postProcess: 'sentenceCase' })} + + } > diff --git a/src/renderer/features/shared/components/save-as-collection-button.module.css b/src/renderer/features/shared/components/save-as-collection-button.module.css new file mode 100644 index 000000000..3e4b1abe9 --- /dev/null +++ b/src/renderer/features/shared/components/save-as-collection-button.module.css @@ -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); +} diff --git a/src/renderer/features/shared/components/save-as-collection-button.tsx b/src/renderer/features/shared/components/save-as-collection-button.tsx new file mode 100644 index 000000000..86ec96fe3 --- /dev/null +++ b/src/renderer/features/shared/components/save-as-collection-button.tsx @@ -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(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 | 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 | string[] + >, + ); + + addCollection({ + filterQueryString, + id: nanoid(), + name: trimmed, + type: itemType, + }); + handlers.close(); + }); + + const handleFormKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + formRef.current?.requestSubmit(); + } + }, []); + + return ( + + + {fullWidth ? ( + + ) : ( + + )} + + +
+ + + {t('page.collections.overrideExisting', { + postProcess: 'sentenceCase', + })} + +
+ + + {sameTypeCollections.map((collection) => ( + + ))} + + +
+ + + + + +
+
+
+
+ ); +}; diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index 1d9ab597d..ea330c277 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -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 = () => { - {sidebarItemsWithRoute.map((item) => ( - } - component={NavLink} - icon={} - key={item.id} - label={item.label} - route={item.route} - to={item.route} - /> - ))} + {sidebarItemsWithRoute.map((item) => + item.id === 'Collections' ? ( + collections && collections.length > 0 ? ( + + + } + label={item.label} + style={{ + cursor: 'pointer', + padding: 'var(--theme-spacing-md) 0', + }} + /> + + + + + {collections.map((collection) => { + const to = getCollectionTo(collection); + return ( + + } + to={to} + > + {collection.name} + + ); + })} + + + + + ) : null + ) : ( + } + component={NavLink} + icon={} + key={item.id} + label={item.label} + route={item.route} + to={item.route} + /> + ), + )} {currentServer && ( diff --git a/src/renderer/features/sidebar/components/sidebar-collection-list.module.css b/src/renderer/features/sidebar/components/sidebar-collection-list.module.css new file mode 100644 index 000000000..be2c25364 --- /dev/null +++ b/src/renderer/features/sidebar/components/sidebar-collection-list.module.css @@ -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; +} diff --git a/src/renderer/features/sidebar/components/sidebar-collection-list.tsx b/src/renderer/features/sidebar/components/sidebar-collection-list.tsx new file mode 100644 index 000000000..4bb7e1ca1 --- /dev/null +++ b/src/renderer/features/sidebar/components/sidebar-collection-list.tsx @@ -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 ( + + +
+ + + + + {collection.name} + + + + + { + e.preventDefault(); + e.stopPropagation(); + }} + size="compact-sm" + variant="transparent" + /> + + + + {t('common.rename', { postProcess: 'sentenceCase' })} + + + {t('common.delete', { postProcess: 'sentenceCase' })} + + + + +
+
+ +
+ + + + + + + +
+
+
+ ); +}; + +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 ( + + + {t('page.sidebar.collections', { postProcess: 'titleCase' })} + + + {collections.map((collection) => ( + + ))} + + + ); +}; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 0dad8993c..bd67ce1a9 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -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 > @@ -117,7 +125,7 @@ export const Sidebar = () => { - {sidebarItemsWithRoute.map((item) => { + {libraryItemsWithRoute.map((item) => { return ( @@ -129,6 +137,7 @@ export const Sidebar = () => { })} + {sidebarPlaylistList && ( <> diff --git a/src/renderer/features/songs/components/song-list-content.tsx b/src/renderer/features/songs/components/song-list-content.tsx index 94edaa65f..db77813ba 100644 --- a/src/renderer/features/songs/components/song-list-content.tsx +++ b/src/renderer/features/songs/components/song-list-content.tsx @@ -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 ( - + - + + + + ); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 5cecbb1fa..8a989f0d6 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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; export interface SettingsSlice extends z.infer { actions: { + addCollection: (collection: SavedCollection) => void; + removeCollection: (id: string) => void; reset: () => void; resetSampleRate: () => void; setArtistItems: (item: SortableItem[]) => void; @@ -806,6 +818,7 @@ export interface SettingsSlice extends z.infer { setTranscodingConfig: (config: TranscodingConfig) => void; toggleMediaSession: () => void; toggleSidebarCollapseShare: () => void; + updateCollection: (id: string, updates: Partial>) => void; }; } export interface SettingsState extends z.infer {} @@ -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()( 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()( !state.general.sidebarCollapseShared; }); }, + updateCollection: ( + id: string, + updates: Partial>, + ) => { + 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); diff --git a/src/renderer/utils/query-params.ts b/src/renderer/utils/query-params.ts index 63a24c618..c6e38ae46 100644 --- a/src/renderer/utils/query-params.ts +++ b/src/renderer/utils/query-params.ts @@ -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 | 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(); +}; diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index 780ae2285..4097ea999 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -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, diff --git a/src/shared/components/modal/modal.module.css b/src/shared/components/modal/modal.module.css index 1d2d349e5..fdf7621ac 100644 --- a/src/shared/components/modal/modal.module.css +++ b/src/shared/components/modal/modal.module.css @@ -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); } diff --git a/src/shared/components/modal/modal.tsx b/src/shared/components/modal/modal.tsx index 6bbdb42ee..785243f9e 100644 --- a/src/shared/components/modal/modal.tsx +++ b/src/shared/components/modal/modal.tsx @@ -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, diff --git a/src/shared/components/popover/popover.module.css b/src/shared/components/popover/popover.module.css index 0e88ce669..c37880af4 100644 --- a/src/shared/components/popover/popover.module.css +++ b/src/shared/components/popover/popover.module.css @@ -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%)); } diff --git a/src/shared/components/popover/popover.tsx b/src/shared/components/popover/popover.tsx index f3bc29024..e66b518c4 100644 --- a/src/shared/components/popover/popover.tsx +++ b/src/shared/components/popover/popover.tsx @@ -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} diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index f55466e32..a4309ffbd 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -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;