mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add list filter collections
This commit is contained in:
@@ -128,6 +128,7 @@
|
|||||||
"releaseType": "release type",
|
"releaseType": "release type",
|
||||||
"refresh": "refresh",
|
"refresh": "refresh",
|
||||||
"reload": "reload",
|
"reload": "reload",
|
||||||
|
"rename": "rename",
|
||||||
"reset": "reset",
|
"reset": "reset",
|
||||||
"resetToDefault": "reset to default",
|
"resetToDefault": "reset to default",
|
||||||
"restartRequired": "restart required",
|
"restartRequired": "restart required",
|
||||||
@@ -560,6 +561,10 @@
|
|||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
|
"collections": {
|
||||||
|
"overrideExisting": "override existing",
|
||||||
|
"saveAsCollection": "save as collection"
|
||||||
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"advanced": "advanced",
|
"advanced": "advanced",
|
||||||
"analytics": "analytics",
|
"analytics": "analytics",
|
||||||
@@ -588,6 +593,7 @@
|
|||||||
"sidebar": {
|
"sidebar": {
|
||||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||||
"albums": "$t(entity.album, {\"count\": 2})",
|
"albums": "$t(entity.album, {\"count\": 2})",
|
||||||
|
"collections": "collections",
|
||||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||||
"folders": "$t(entity.folder, {\"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 { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { ListFilters, ListFiltersTitle } 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 { 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 { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
@@ -38,11 +39,14 @@ const AlbumListPaginatedTable = lazy(() =>
|
|||||||
const AlbumListFilters = () => {
|
const AlbumListFilters = () => {
|
||||||
return (
|
return (
|
||||||
<ListWithSidebarContainer.SidebarPortal>
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
<Stack h="100%">
|
<Stack h="100%" style={{ minHeight: 0 }}>
|
||||||
<ListFiltersTitle itemType={LibraryItem.ALBUM} />
|
<ListFiltersTitle itemType={LibraryItem.ALBUM} />
|
||||||
<ScrollArea>
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
<ListFilters itemType={LibraryItem.ALBUM} />
|
<ListFilters itemType={LibraryItem.ALBUM} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<Stack p="sm">
|
||||||
|
<SaveAsCollectionButton fullWidth itemType={LibraryItem.ALBUM} />
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ListWithSidebarContainer.SidebarPortal>
|
</ListWithSidebarContainer.SidebarPortal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const SIDEBAR_ITEMS: Array<[string, string]> = [
|
|||||||
[SidebarItem.HOME, 'page.sidebar.home'],
|
[SidebarItem.HOME, 'page.sidebar.home'],
|
||||||
[SidebarItem.NOW_PLAYING, 'page.sidebar.nowPlaying'],
|
[SidebarItem.NOW_PLAYING, 'page.sidebar.nowPlaying'],
|
||||||
[SidebarItem.PLAYLISTS, 'page.sidebar.playlists'],
|
[SidebarItem.PLAYLISTS, 'page.sidebar.playlists'],
|
||||||
|
[SidebarItem.COLLECTIONS, 'page.sidebar.collections'],
|
||||||
[SidebarItem.RADIO, 'page.sidebar.radio'],
|
[SidebarItem.RADIO, 'page.sidebar.radio'],
|
||||||
[SidebarItem.SEARCH, 'page.sidebar.search'],
|
[SidebarItem.SEARCH, 'page.sidebar.search'],
|
||||||
[SidebarItem.SETTINGS, 'page.sidebar.settings'],
|
[SidebarItem.SETTINGS, 'page.sidebar.settings'],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals, openModal } from '@mantine/modals';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
|
||||||
import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';
|
import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';
|
||||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||||
@@ -40,29 +39,25 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex>
|
<Flex>
|
||||||
<PageHeader>
|
<LibraryHeaderBar>
|
||||||
<LibraryHeaderBar>
|
<Flex align="center" justify="space-between" w="100%">
|
||||||
<Flex align="center" justify="space-between" w="100%">
|
<Group wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Icon icon="settings" size="5xl" />
|
||||||
<Icon icon="settings" size="5xl" />
|
<LibraryHeaderBar.Title>
|
||||||
<LibraryHeaderBar.Title>
|
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
|
||||||
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
|
</LibraryHeaderBar.Title>
|
||||||
</LibraryHeaderBar.Title>
|
</Group>
|
||||||
</Group>
|
<Group>
|
||||||
<Group>
|
<SearchInput
|
||||||
<SearchInput
|
defaultValue={search}
|
||||||
defaultValue={search}
|
onChange={(event) => setSearch(event.target.value.toLocaleLowerCase())}
|
||||||
onChange={(event) =>
|
/>
|
||||||
setSearch(event.target.value.toLocaleLowerCase())
|
<Button onClick={openResetConfirmModal} variant="default">
|
||||||
}
|
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
|
||||||
/>
|
</Button>
|
||||||
<Button onClick={openResetConfirmModal} variant="default">
|
</Group>
|
||||||
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
|
</Flex>
|
||||||
</Button>
|
</LibraryHeaderBar>
|
||||||
</Group>
|
|
||||||
</Flex>
|
|
||||||
</LibraryHeaderBar>
|
|
||||||
</PageHeader>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,10 +47,18 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
|
|||||||
|
|
||||||
const [isOpen, handlers] = useDisclosure(false);
|
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 = () => {
|
const handlePin = () => {
|
||||||
setIsSidebarOpen?.(!isSidebarOpen);
|
setIsSidebarOpen?.(!isSidebarOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
|
||||||
const canPin = Boolean(setIsSidebarOpen);
|
const canPin = Boolean(setIsSidebarOpen);
|
||||||
|
|
||||||
const disableArtistFilter = pageKey === ItemListKey.ALBUM_ARTIST_ALBUM;
|
const disableArtistFilter = pageKey === ItemListKey.ALBUM_ARTIST_ALBUM;
|
||||||
@@ -72,15 +80,21 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
<Group>
|
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
|
||||||
{canPin && (
|
<Group>
|
||||||
<ActionIcon
|
{canPin && (
|
||||||
icon={isSidebarOpen ? 'unpin' : 'pin'}
|
<ActionIcon
|
||||||
onClick={handlePin}
|
icon={isSidebarOpen ? 'unpin' : 'pin'}
|
||||||
variant="subtle"
|
onClick={handlePin}
|
||||||
/>
|
variant="subtle"
|
||||||
)}
|
/>
|
||||||
{t('common.filters', { postProcess: 'sentenceCase' })}
|
)}
|
||||||
|
|
||||||
|
{t('common.filters', { postProcess: 'sentenceCase' })}
|
||||||
|
</Group>
|
||||||
|
<Button onClick={handleReset} size="compact-sm" variant="subtle">
|
||||||
|
{t('common.reset', { postProcess: 'sentenceCase' })}
|
||||||
|
</Button>
|
||||||
</Group>
|
</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 { motion } from 'motion/react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
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 { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
|
||||||
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
||||||
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
|
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 { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||||
import {
|
import {
|
||||||
SidebarItemType,
|
SidebarItemType,
|
||||||
|
useCollections,
|
||||||
useCurrentServer,
|
useCurrentServer,
|
||||||
useSidebarCollapsedNavigation,
|
useSidebarCollapsedNavigation,
|
||||||
useSidebarItems,
|
useSidebarItems,
|
||||||
@@ -26,12 +28,14 @@ import { Flex } from '/@/shared/components/flex/flex';
|
|||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
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';
|
import { Platform } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const CollapsedSidebar = () => {
|
export const CollapsedSidebar = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const collections = useCollections();
|
||||||
const { windowBarStyle } = useWindowSettings();
|
const { windowBarStyle } = useWindowSettings();
|
||||||
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
||||||
const sidebarItems = useSidebarItems();
|
const sidebarItems = useSidebarItems();
|
||||||
@@ -45,6 +49,7 @@ export const CollapsedSidebar = () => {
|
|||||||
'\n',
|
'\n',
|
||||||
),
|
),
|
||||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||||
|
Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),
|
||||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||||
@@ -110,17 +115,66 @@ export const CollapsedSidebar = () => {
|
|||||||
<AppMenu />
|
<AppMenu />
|
||||||
</DropdownMenu.Dropdown>
|
</DropdownMenu.Dropdown>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{sidebarItemsWithRoute.map((item) => (
|
{sidebarItemsWithRoute.map((item) =>
|
||||||
<CollapsedSidebarItem
|
item.id === 'Collections' ? (
|
||||||
activeIcon={<SidebarIcon active route={item.route} size="25" />}
|
collections && collections.length > 0 ? (
|
||||||
component={NavLink}
|
<DropdownMenu key={item.id} offset={0} position="right-end">
|
||||||
icon={<SidebarIcon route={item.route} size="25" />}
|
<DropdownMenu.Target>
|
||||||
key={item.id}
|
<CollapsedSidebarItem
|
||||||
label={item.label}
|
activeIcon={null}
|
||||||
route={item.route}
|
component={Flex}
|
||||||
to={item.route}
|
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 && (
|
{currentServer && (
|
||||||
<DropdownMenu offset={0} position="right-end" width={240}>
|
<DropdownMenu offset={0} position="right-end" width={240}>
|
||||||
<DropdownMenu.Target>
|
<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 { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||||
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
|
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 { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,7 @@ export const Sidebar = () => {
|
|||||||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||||
|
Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),
|
||||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||||
@@ -84,6 +86,12 @@ export const Sidebar = () => {
|
|||||||
return items;
|
return items;
|
||||||
}, [sidebarItems, translatedSidebarItemMap]);
|
}, [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 =
|
const isCustomWindowBar =
|
||||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
|
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@ export const Sidebar = () => {
|
|||||||
item: styles.accordionItem,
|
item: styles.accordionItem,
|
||||||
root: styles.accordionRoot,
|
root: styles.accordionRoot,
|
||||||
}}
|
}}
|
||||||
defaultValue={['library', 'playlists']}
|
defaultValue={['library', 'collections', 'playlists']}
|
||||||
multiple
|
multiple
|
||||||
>
|
>
|
||||||
<Accordion.Item value="library">
|
<Accordion.Item value="library">
|
||||||
@@ -117,7 +125,7 @@ export const Sidebar = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{sidebarItemsWithRoute.map((item) => {
|
{libraryItemsWithRoute.map((item) => {
|
||||||
return (
|
return (
|
||||||
<SidebarItem key={`sidebar-${item.route}`} to={item.route}>
|
<SidebarItem key={`sidebar-${item.route}`} to={item.route}>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
@@ -129,6 +137,7 @@ export const Sidebar = () => {
|
|||||||
})}
|
})}
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
<SidebarCollectionList />
|
||||||
{sidebarPlaylistList && (
|
{sidebarPlaylistList && (
|
||||||
<>
|
<>
|
||||||
<SidebarPlaylistList />
|
<SidebarPlaylistList />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { lazy, Suspense, useMemo } from 'react';
|
|||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { ListFilters, ListFiltersTitle } 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 { 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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||||
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||||
@@ -44,11 +45,14 @@ export const SongListContent = () => {
|
|||||||
const SongListFilters = () => {
|
const SongListFilters = () => {
|
||||||
return (
|
return (
|
||||||
<ListWithSidebarContainer.SidebarPortal>
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
<Stack h="100%">
|
<Stack h="100%" style={{ minHeight: 0 }}>
|
||||||
<ListFiltersTitle itemType={LibraryItem.SONG} />
|
<ListFiltersTitle itemType={LibraryItem.SONG} />
|
||||||
<ScrollArea>
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
<ListFilters itemType={LibraryItem.SONG} />
|
<ListFilters itemType={LibraryItem.SONG} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<Stack p="sm">
|
||||||
|
<SaveAsCollectionButton fullWidth itemType={LibraryItem.SONG} />
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ListWithSidebarContainer.SidebarPortal>
|
</ListWithSidebarContainer.SidebarPortal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import mergeWith from 'lodash/mergeWith';
|
import mergeWith from 'lodash/mergeWith';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { generatePath } from 'react-router';
|
import { generatePath } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
|
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
|
||||||
@@ -26,7 +27,7 @@ import { FontValueSchema } from '/@/renderer/types/fonts';
|
|||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||||
import { AppTheme } from '/@/shared/themes/app-theme-types';
|
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 {
|
import {
|
||||||
FontType,
|
FontType,
|
||||||
ItemListKey,
|
ItemListKey,
|
||||||
@@ -154,6 +155,13 @@ const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
|
|||||||
|
|
||||||
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
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({
|
const SidebarItemTypeSchema = z.object({
|
||||||
disabled: z.boolean(),
|
disabled: z.boolean(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -408,6 +416,7 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
artistRadioCount: z.number(),
|
artistRadioCount: z.number(),
|
||||||
artistReleaseTypeItems: z.array(SortableItemSchema(ArtistReleaseTypeItemSchema)),
|
artistReleaseTypeItems: z.array(SortableItemSchema(ArtistReleaseTypeItemSchema)),
|
||||||
buttonSize: z.number(),
|
buttonSize: z.number(),
|
||||||
|
collections: z.array(CollectionSchema),
|
||||||
combinedLyricsAndVisualizer: z.boolean(),
|
combinedLyricsAndVisualizer: z.boolean(),
|
||||||
disabledContextMenu: z.record(z.string(), z.boolean()),
|
disabledContextMenu: z.record(z.string(), z.boolean()),
|
||||||
enableGridMultiSelect: z.boolean(),
|
enableGridMultiSelect: z.boolean(),
|
||||||
@@ -755,6 +764,7 @@ export enum SidebarItem {
|
|||||||
ALBUMS = 'Albums',
|
ALBUMS = 'Albums',
|
||||||
ARTISTS = 'Artists',
|
ARTISTS = 'Artists',
|
||||||
ARTISTS_ALL = 'Artists-all',
|
ARTISTS_ALL = 'Artists-all',
|
||||||
|
COLLECTIONS = 'Collections',
|
||||||
FAVORITES = 'Favorites',
|
FAVORITES = 'Favorites',
|
||||||
FOLDERS = 'Folders',
|
FOLDERS = 'Folders',
|
||||||
GENRES = 'Genres',
|
GENRES = 'Genres',
|
||||||
@@ -792,6 +802,8 @@ export type PlayerFilterOperator = z.infer<typeof PlayerFilterOperatorSchema>;
|
|||||||
|
|
||||||
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
||||||
actions: {
|
actions: {
|
||||||
|
addCollection: (collection: SavedCollection) => void;
|
||||||
|
removeCollection: (id: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
resetSampleRate: () => void;
|
resetSampleRate: () => void;
|
||||||
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
|
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
|
||||||
@@ -806,6 +818,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
|||||||
setTranscodingConfig: (config: TranscodingConfig) => void;
|
setTranscodingConfig: (config: TranscodingConfig) => void;
|
||||||
toggleMediaSession: () => void;
|
toggleMediaSession: () => void;
|
||||||
toggleSidebarCollapseShare: () => void;
|
toggleSidebarCollapseShare: () => void;
|
||||||
|
updateCollection: (id: string, updates: Partial<Omit<SavedCollection, 'id'>>) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
||||||
@@ -884,6 +897,12 @@ export const sidebarItems: SidebarItemType[] = [
|
|||||||
label: i18n.t('page.sidebar.playlists'),
|
label: i18n.t('page.sidebar.playlists'),
|
||||||
route: AppRoute.PLAYLISTS,
|
route: AppRoute.PLAYLISTS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
id: 'Collections',
|
||||||
|
label: i18n.t('page.sidebar.collections'),
|
||||||
|
route: '',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
disabled: false,
|
disabled: false,
|
||||||
id: 'Radio',
|
id: 'Radio',
|
||||||
@@ -967,6 +986,7 @@ const initialState: SettingsState = {
|
|||||||
artistRadioCount: 20,
|
artistRadioCount: 20,
|
||||||
artistReleaseTypeItems,
|
artistReleaseTypeItems,
|
||||||
buttonSize: 15,
|
buttonSize: 15,
|
||||||
|
collections: [],
|
||||||
combinedLyricsAndVisualizer: false,
|
combinedLyricsAndVisualizer: false,
|
||||||
disabledContextMenu: {},
|
disabledContextMenu: {},
|
||||||
enableGridMultiSelect: false,
|
enableGridMultiSelect: false,
|
||||||
@@ -1659,6 +1679,18 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
subscribeWithSelector(
|
subscribeWithSelector(
|
||||||
immer((set) => ({
|
immer((set) => ({
|
||||||
actions: {
|
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: () => {
|
reset: () => {
|
||||||
localStorage.removeItem('store_settings');
|
localStorage.removeItem('store_settings');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -1748,6 +1780,17 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
!state.general.sidebarCollapseShared;
|
!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,
|
...initialState,
|
||||||
})),
|
})),
|
||||||
@@ -2135,6 +2178,15 @@ export const useSideQueueType = () =>
|
|||||||
export const useVolumeWheelStep = () =>
|
export const useVolumeWheelStep = () =>
|
||||||
useSettingsStore((state) => state.general.volumeWheelStep, shallow);
|
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 = () =>
|
export const useSidebarPlaylistList = () =>
|
||||||
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
||||||
|
|
||||||
|
|||||||
@@ -178,3 +178,33 @@ export const parseCustomFiltersParam = (
|
|||||||
return undefined;
|
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();
|
||||||
|
};
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
LuMoon,
|
LuMoon,
|
||||||
LuMusic,
|
LuMusic,
|
||||||
LuMusic2,
|
LuMusic2,
|
||||||
|
LuPackage2,
|
||||||
LuPanelRightClose,
|
LuPanelRightClose,
|
||||||
LuPanelRightOpen,
|
LuPanelRightOpen,
|
||||||
LuPause,
|
LuPause,
|
||||||
@@ -152,6 +153,7 @@ export const AppIcon = {
|
|||||||
cache: LuCloudDownload,
|
cache: LuCloudDownload,
|
||||||
check: LuCheck,
|
check: LuCheck,
|
||||||
clipboardCopy: LuClipboardCopy,
|
clipboardCopy: LuClipboardCopy,
|
||||||
|
collection: LuPackage2,
|
||||||
delete: LuDelete,
|
delete: LuDelete,
|
||||||
disc: LuDisc,
|
disc: LuDisc,
|
||||||
download: LuDownload,
|
download: LuDownload,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: var(--theme-spacing-md);
|
margin-bottom: var(--theme-spacing-md);
|
||||||
background: var(--theme-colors-background);
|
background: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,14 +14,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: var(--theme-spacing-md);
|
padding: var(--theme-spacing-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--theme-colors-background);
|
background: var(--theme-colors-background);
|
||||||
border: 2px solid var(--theme-colors-border);
|
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 {
|
.close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--theme-spacing-md);
|
|
||||||
right: var(--theme-spacing-md);
|
right: var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const Modal = ({ children, classNames, handlers, ...rest }: ModalProps) =
|
|||||||
backgroundOpacity: 0.5,
|
backgroundOpacity: 0.5,
|
||||||
blur: 1,
|
blur: 1,
|
||||||
}}
|
}}
|
||||||
radius="xl"
|
radius="md"
|
||||||
scrollAreaComponent={ScrollArea}
|
scrollAreaComponent={ScrollArea}
|
||||||
transitionProps={{
|
transitionProps={{
|
||||||
duration: 300,
|
duration: 300,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
.dropdown {
|
.dropdown {
|
||||||
padding: var(--theme-spacing-lg);
|
padding: var(--theme-spacing-sm);
|
||||||
color: var(--theme-colors-foreground);
|
color: var(--theme-colors-foreground);
|
||||||
background: var(--theme-colors-background);
|
background: var(--theme-colors-background);
|
||||||
border: 1px solid var(--theme-colors-border);
|
border: 2px solid var(--theme-colors-border);
|
||||||
border-radius: var(--theme-radius-xl);
|
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%));
|
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
|
|||||||
classNames={{
|
classNames={{
|
||||||
dropdown: styles.dropdown,
|
dropdown: styles.dropdown,
|
||||||
}}
|
}}
|
||||||
|
closeOnClickOutside={true}
|
||||||
|
closeOnEscape={true}
|
||||||
offset={10}
|
offset={10}
|
||||||
transitionProps={{ transition: getTransition(props.position) }}
|
transitionProps={{ transition: getTransition(props.position) }}
|
||||||
withArrow={false}
|
withArrow={false}
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export type QueueSong = Song & {
|
|||||||
_uniqueId: string;
|
_uniqueId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface SavedCollection {
|
||||||
|
filterQueryString: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: LibraryItem.ALBUM | LibraryItem.SONG;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerListItem = {
|
export type ServerListItem = {
|
||||||
features?: ServerFeatures;
|
features?: ServerFeatures;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user