From a14e5f08ee39cab2d84f33ce319186f06074404e Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 13 Dec 2025 02:03:04 -0800 Subject: [PATCH] allow user to unpin list sidebar --- src/renderer/context/list-context.tsx | 2 + .../albums/components/album-list-content.tsx | 12 ++-- .../components/album-list-header-filters.tsx | 27 ++++++-- .../albums/routes/album-list-route.tsx | 14 ++++- .../folders/routes/folder-list-route.tsx | 4 +- .../shared/components/list-filters.tsx | 61 ++++++++++++++++++- .../list-with-sidebar-container.module.css | 10 ++- .../list-with-sidebar-container.tsx | 11 +++- .../songs/components/song-list-content.tsx | 12 ++-- .../components/song-list-header-filters.tsx | 22 ++++++- .../features/songs/routes/song-list-route.tsx | 14 ++++- src/renderer/store/app.store.ts | 23 +++++++ src/shared/components/icon/icon.tsx | 4 ++ 13 files changed, 193 insertions(+), 23 deletions(-) diff --git a/src/renderer/context/list-context.tsx b/src/renderer/context/list-context.tsx index 5ab406a0f..89ec02867 100644 --- a/src/renderer/context/list-context.tsx +++ b/src/renderer/context/list-context.tsx @@ -5,11 +5,13 @@ import { ItemListKey } from '/@/shared/types/types'; interface ListContextProps { customFilters?: Record; id?: string; + isSidebarOpen?: boolean; isSmartPlaylist?: boolean; itemCount?: number; listData?: unknown[]; mode?: 'edit' | 'view'; pageKey: ItemListKey | string; + setIsSidebarOpen?: (isSidebarOpen: boolean) => void; setItemCount?: (itemCount: number) => void; setListData?: (items: unknown[]) => void; setMode?: (mode: 'edit' | 'view') => void; diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index 251f91b9c..650304d6d 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -2,11 +2,12 @@ import { lazy, Suspense, useMemo } from 'react'; import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; -import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; +import { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters'; import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container'; import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; import { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types'; import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types'; @@ -37,9 +38,12 @@ const AlbumListPaginatedTable = lazy(() => const AlbumListFilters = () => { return ( - - - + + + + + + ); }; diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index f4f222d7d..8329e1da1 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -6,10 +6,14 @@ import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button'; -import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters'; +import { + isFilterValueSet, + ListFiltersModal, +} from '/@/renderer/features/shared/components/list-filters'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { GenreTarget, useGenreTarget, useSettingsStoreActions } from '/@/renderer/store'; import { Button } from '/@/shared/components/button/button'; @@ -36,14 +40,29 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge }, [target, t]); const handleToggleGenreTarget = useCallback(() => { - // Clear all filter query states albumFilters.clear(); songFilters.clear(); - // Toggle the genre target setGenreBehavior(target === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM); }, [target, setGenreBehavior, albumFilters, songFilters]); + const hasActiveFilters = useMemo(() => { + const query = albumFilters.query; + + return Boolean( + isFilterValueSet(query[FILTER_KEYS.ALBUM._CUSTOM]) || + isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) || + query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined || + query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined || + isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) || + query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined || + isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) || + isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) || + query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined || + isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]), + ); + }, [albumFilters.query]); + return ( @@ -69,7 +88,7 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge defaultSortOrder={SortOrder.ASC} listKey={pageKey as ItemListKey} /> - + diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index 101a6b8b8..d3223bc3f 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -7,6 +7,7 @@ import { AlbumListHeader } from '/@/renderer/features/albums/components/album-li import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; +import { usePageSidebar } from '/@/renderer/store/app.store'; import { AlbumListQuery } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; @@ -27,6 +28,7 @@ const AlbumListRoute = () => { const pageKey = getPageKey({ albumArtistId, genreId }); const [itemCount, setItemCount] = useState(undefined); + const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(pageKey); const customFilters: Partial = useMemo(() => { if (albumArtistId) { @@ -48,11 +50,21 @@ const AlbumListRoute = () => { return { customFilters, id: albumArtistId ?? genreId, + isSidebarOpen, itemCount, pageKey, + setIsSidebarOpen, setItemCount, }; - }, [albumArtistId, customFilters, genreId, itemCount, pageKey]); + }, [ + albumArtistId, + customFilters, + genreId, + isSidebarOpen, + itemCount, + pageKey, + setIsSidebarOpen, + ]); return ( diff --git a/src/renderer/features/folders/routes/folder-list-route.tsx b/src/renderer/features/folders/routes/folder-list-route.tsx index 62acf5a53..6d200b97e 100644 --- a/src/renderer/features/folders/routes/folder-list-route.tsx +++ b/src/renderer/features/folders/routes/folder-list-route.tsx @@ -20,13 +20,13 @@ const FolderListRoute = () => { pageKey, setItemCount, }; - }, [itemCount, pageKey, setItemCount]); + }, [itemCount, pageKey]); return ( - + diff --git a/src/renderer/features/shared/components/list-filters.tsx b/src/renderer/features/shared/components/list-filters.tsx index 5cd97ad93..8321844b8 100644 --- a/src/renderer/features/shared/components/list-filters.tsx +++ b/src/renderer/features/shared/components/list-filters.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; +import { useListContext } from '/@/renderer/context/list-context'; import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters'; import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters'; @@ -10,8 +11,11 @@ import { JellyfinSongFilters } from '/@/renderer/features/songs/components/jelly import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters'; import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filters'; import { useCurrentServer } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; import { Modal } from '/@/shared/components/modal/modal'; import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Text } from '/@/shared/components/text/text'; import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { LibraryItem, ServerType } from '/@/shared/types/domain-types'; @@ -20,9 +24,18 @@ interface ListFiltersProps { itemType: LibraryItem; } +export const isFilterValueSet = (value: unknown): boolean => { + if (value === undefined || value === null) return false; + if (typeof value === 'string' && value.trim() === '') return false; + if (Array.isArray(value) && value.length === 0) return false; + if (typeof value === 'object' && Object.keys(value).length === 0) return false; + return true; +}; + export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => { const { t } = useTranslation(); const server = useCurrentServer(); + const { isSidebarOpen, setIsSidebarOpen } = useListContext(); const serverType = server.type; @@ -30,13 +43,39 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => { const [isOpen, handlers] = useDisclosure(false); + const handlePin = () => { + setIsSidebarOpen?.(!isSidebarOpen); + }; + + const canPin = Boolean(setIsSidebarOpen); + return ( <> + {canPin && ( + + )} + {t('common.filters', { postProcess: 'sentenceCase' })} + + } > @@ -58,6 +97,26 @@ export const ListFilters = ({ itemType }: ListFiltersProps) => { ); }; +export const ListFiltersTitle = () => { + const { t } = useTranslation(); + const { setIsSidebarOpen } = useListContext(); + + const handleUnpin = () => { + setIsSidebarOpen?.(false); + }; + + const canUnpin = Boolean(setIsSidebarOpen); + + return ( + + + {t('common.filters', { postProcess: 'sentenceCase' })} + + {canUnpin && } + + ); +}; + const FILTERS = { [ServerType.JELLYFIN]: { [LibraryItem.ALBUM]: JellyfinAlbumFilters, diff --git a/src/renderer/features/shared/components/list-with-sidebar-container.module.css b/src/renderer/features/shared/components/list-with-sidebar-container.module.css index 727dc5c42..93091fb9e 100644 --- a/src/renderer/features/shared/components/list-with-sidebar-container.module.css +++ b/src/renderer/features/shared/components/list-with-sidebar-container.module.css @@ -21,10 +21,16 @@ border-right: 1px solid var(--theme-colors-border); } -@container (min-width: $mantine-breakpoint-lg) { - .sidebar-container { +@container (min-width: $mantine-breakpoint-xs) { + .container[data-sidebar-open='true'] .sidebar-container { display: block; } + + @container (min-width: $mantine-breakpoint-lg) { + .container[data-use-breakpoint='true'] .sidebar-container { + display: block; + } + } } .content-container { diff --git a/src/renderer/features/shared/components/list-with-sidebar-container.tsx b/src/renderer/features/shared/components/list-with-sidebar-container.tsx index 06f95b3d4..a447cb462 100644 --- a/src/renderer/features/shared/components/list-with-sidebar-container.tsx +++ b/src/renderer/features/shared/components/list-with-sidebar-container.tsx @@ -3,6 +3,7 @@ import { createContext, ReactNode, useContext, useMemo, useRef } from 'react'; import styles from './list-with-sidebar-container.module.css'; +import { useListContext } from '/@/renderer/context/list-context'; import { animationProps } from '/@/shared/components/animations/animation-props'; import { Portal } from '/@/shared/components/portal/portal'; @@ -17,6 +18,7 @@ const ListWithSidebarContainerContext = createContext { const sidebarRef = useRef(null); + const { isSidebarOpen = false } = useListContext(); const contextValue = useMemo( () => ({ @@ -76,7 +79,11 @@ export const ListWithSidebarContainer = ({ return ( -
+
{children}
diff --git a/src/renderer/features/songs/components/song-list-content.tsx b/src/renderer/features/songs/components/song-list-content.tsx index a6297341e..d10eb20ce 100644 --- a/src/renderer/features/songs/components/song-list-content.tsx +++ b/src/renderer/features/songs/components/song-list-content.tsx @@ -1,12 +1,13 @@ import { lazy, Suspense, useMemo } from 'react'; import { useListContext } from '/@/renderer/context/list-context'; -import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; +import { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters'; import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container'; 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'; import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; import { LibraryItem, SongListQuery } from '/@/shared/types/domain-types'; import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types'; @@ -43,9 +44,12 @@ export const SongListContent = () => { const SongListFilters = () => { return ( - - - + + + + + + ); }; diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index f015c99f0..8c5b38a0f 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -6,10 +6,14 @@ import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button'; -import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters'; +import { + isFilterValueSet, + ListFiltersModal, +} from '/@/renderer/features/shared/components/list-filters'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { GenreTarget, useGenreTarget, useSettingsStoreActions } from '/@/renderer/store'; import { Button } from '/@/shared/components/button/button'; @@ -44,6 +48,20 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget : t('entity.track_other', { postProcess: 'titleCase' }); }, [target, t]); + const hasActiveFilters = useMemo(() => { + const query = songFilters.query; + return Boolean( + isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) || + isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_IDS]) || + isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) || + query[FILTER_KEYS.SONG.FAVORITE] !== undefined || + isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) || + isFilterValueSet(query[FILTER_KEYS.SONG.MAX_YEAR]) || + isFilterValueSet(query[FILTER_KEYS.SONG.MIN_YEAR]) || + isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]), + ); + }, [songFilters.query]); + return ( @@ -69,7 +87,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget defaultSortOrder={SortOrder.ASC} listKey={pageKey as ItemListKey} /> - + diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index a6034763f..1a05da4be 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -7,6 +7,7 @@ import { ListWithSidebarContainer } from '/@/renderer/features/shared/components import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { SongListContent } from '/@/renderer/features/songs/components/song-list-content'; import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header'; +import { usePageSidebar } from '/@/renderer/store/app.store'; import { SongListQuery } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; @@ -27,6 +28,7 @@ const SongListRoute = () => { const pageKey = getPageKey({ albumArtistId, genreId }); const [itemCount, setItemCount] = useState(undefined); + const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(pageKey); const customFilters: Partial = useMemo(() => { if (albumArtistId) { @@ -48,11 +50,21 @@ const SongListRoute = () => { return { customFilters, id: albumArtistId ?? genreId, + isSidebarOpen, itemCount, pageKey, + setIsSidebarOpen, setItemCount, }; - }, [albumArtistId, customFilters, genreId, itemCount, pageKey]); + }, [ + albumArtistId, + customFilters, + genreId, + isSidebarOpen, + itemCount, + pageKey, + setIsSidebarOpen, + ]); return ( diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 3f3e08f6a..6e0f5549f 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -8,6 +8,7 @@ import { Platform } from '/@/shared/types/types'; export interface AppSlice extends AppState { actions: { setAppStore: (data: Partial) => void; + setPageSidebar: (key: string, value: boolean) => void; setPrivateMode: (enabled: boolean) => void; setShowTimeRemaining: (enabled: boolean) => void; setSideBar: (options: Partial) => void; @@ -18,6 +19,7 @@ export interface AppSlice extends AppState { export interface AppState { commandPalette: CommandPaletteProps; isReorderingQueue: boolean; + pageSidebar: Record; platform: Platform; privateMode: boolean; showTimeRemaining: boolean; @@ -54,6 +56,15 @@ export const useAppStore = createWithEqualityFn()( setAppStore: (data) => { set({ ...get(), ...data }); }, + setPageSidebar: (key, value) => { + set((state) => { + if (value) { + state.pageSidebar[key] = value; + } else { + delete state.pageSidebar[key]; + } + }); + }, setPrivateMode: (privateMode) => { set((state) => { state.privateMode = privateMode; @@ -94,6 +105,7 @@ export const useAppStore = createWithEqualityFn()( }, }, isReorderingQueue: false, + pageSidebar: {}, platform: Platform.WINDOWS, privateMode: false, showTimeRemaining: false, @@ -140,3 +152,14 @@ export const useSetTitlebar = () => useAppStore((state) => state.actions.setTitl export const useTitlebarStore = () => useAppStore((state) => state.titlebar); export const useCommandPalette = () => useAppStore((state) => state.commandPalette); + +export const usePageSidebar = (key: string): [boolean, (value: boolean) => void] => { + const isOpen = useAppStore((state) => state.pageSidebar[key] ?? false); + const setPageSidebar = useAppStore((state) => state.actions.setPageSidebar); + + const setIsOpen = (value: boolean) => { + setPageSidebar(key, value); + }; + + return [isOpen, setIsOpen]; +}; diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index 000fb21e2..a724dca8a 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -77,6 +77,8 @@ import { LuPanelRightOpen, LuPause, LuPencilLine, + LuPin, + LuPinOff, LuPlay, LuPlus, LuRadio, @@ -200,6 +202,7 @@ export const AppIcon = { minus: LuMinus, panelRightClose: LuPanelRightClose, panelRightOpen: LuPanelRightOpen, + pin: LuPin, playlist: LuListMusic, playlistAdd: LuListPlus, playlistDelete: LuListMinus, @@ -228,6 +231,7 @@ export const AppIcon = { themeLight: LuSun, track: LuMusic2, unfavorite: LuHeartCrack, + unpin: LuPinOff, upload: LuUpload, user: LuUser, userManage: LuUserRoundCog,