mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add list filter collections
This commit is contained in:
@@ -128,6 +128,7 @@
|
||||
"releaseType": "release type",
|
||||
"refresh": "refresh",
|
||||
"reload": "reload",
|
||||
"rename": "rename",
|
||||
"reset": "reset",
|
||||
"resetToDefault": "reset to default",
|
||||
"restartRequired": "restart required",
|
||||
@@ -560,6 +561,10 @@
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||
},
|
||||
"collections": {
|
||||
"overrideExisting": "override existing",
|
||||
"saveAsCollection": "save as collection"
|
||||
},
|
||||
"setting": {
|
||||
"advanced": "advanced",
|
||||
"analytics": "analytics",
|
||||
@@ -588,6 +593,7 @@
|
||||
"sidebar": {
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
"albums": "$t(entity.album, {\"count\": 2})",
|
||||
"collections": "collections",
|
||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"folders": "$t(entity.folder, {\"count\": 2})",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||
import { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';
|
||||
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
@@ -38,11 +39,14 @@ const AlbumListPaginatedTable = lazy(() =>
|
||||
const AlbumListFilters = () => {
|
||||
return (
|
||||
<ListWithSidebarContainer.SidebarPortal>
|
||||
<Stack h="100%">
|
||||
<Stack h="100%" style={{ minHeight: 0 }}>
|
||||
<ListFiltersTitle itemType={LibraryItem.ALBUM} />
|
||||
<ScrollArea>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<ListFilters itemType={LibraryItem.ALBUM} />
|
||||
</ScrollArea>
|
||||
<Stack p="sm">
|
||||
<SaveAsCollectionButton fullWidth itemType={LibraryItem.ALBUM} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ListWithSidebarContainer.SidebarPortal>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ const SIDEBAR_ITEMS: Array<[string, string]> = [
|
||||
[SidebarItem.HOME, 'page.sidebar.home'],
|
||||
[SidebarItem.NOW_PLAYING, 'page.sidebar.nowPlaying'],
|
||||
[SidebarItem.PLAYLISTS, 'page.sidebar.playlists'],
|
||||
[SidebarItem.COLLECTIONS, 'page.sidebar.collections'],
|
||||
[SidebarItem.RADIO, 'page.sidebar.radio'],
|
||||
[SidebarItem.SEARCH, 'page.sidebar.search'],
|
||||
[SidebarItem.SETTINGS, 'page.sidebar.settings'],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
@@ -40,29 +39,25 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar>
|
||||
<Flex align="center" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<Icon icon="settings" size="5xl" />
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
</Group>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={search}
|
||||
onChange={(event) =>
|
||||
setSearch(event.target.value.toLocaleLowerCase())
|
||||
}
|
||||
/>
|
||||
<Button onClick={openResetConfirmModal} variant="default">
|
||||
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
<LibraryHeaderBar>
|
||||
<Flex align="center" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<Icon icon="settings" size="5xl" />
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
</Group>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={search}
|
||||
onChange={(event) => setSearch(event.target.value.toLocaleLowerCase())}
|
||||
/>
|
||||
<Button onClick={openResetConfirmModal} variant="default">
|
||||
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</LibraryHeaderBar>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,10 +47,18 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
|
||||
|
||||
const [isOpen, handlers] = useDisclosure(false);
|
||||
|
||||
const albumListFilters = useAlbumListFilters(pageKey as ItemListKey);
|
||||
const songListFilters = useSongListFilters(pageKey as ItemListKey);
|
||||
const clear = itemType === LibraryItem.ALBUM ? albumListFilters.clear : songListFilters.clear;
|
||||
|
||||
const handlePin = () => {
|
||||
setIsSidebarOpen?.(!isSidebarOpen);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
clear();
|
||||
};
|
||||
|
||||
const canPin = Boolean(setIsSidebarOpen);
|
||||
|
||||
const disableArtistFilter = pageKey === ItemListKey.ALBUM_ARTIST_ALBUM;
|
||||
@@ -72,15 +80,21 @@ export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<Group>
|
||||
{canPin && (
|
||||
<ActionIcon
|
||||
icon={isSidebarOpen ? 'unpin' : 'pin'}
|
||||
onClick={handlePin}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
{t('common.filters', { postProcess: 'sentenceCase' })}
|
||||
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
|
||||
<Group>
|
||||
{canPin && (
|
||||
<ActionIcon
|
||||
icon={isSidebarOpen ? 'unpin' : 'pin'}
|
||||
onClick={handlePin}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('common.filters', { postProcess: 'sentenceCase' })}
|
||||
</Group>
|
||||
<Button onClick={handleReset} size="compact-sm" variant="subtle">
|
||||
{t('common.reset', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.list {
|
||||
flex: 0 0 200px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import styles from './save-as-collection-button.module.css';
|
||||
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useCollections, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { getFilterQueryStringFromSearchParams } from '/@/renderer/utils/query-params';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import { LibraryItem, SavedCollection } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SaveAsCollectionButtonProps {
|
||||
fullWidth?: boolean;
|
||||
itemType: LibraryItem.ALBUM | LibraryItem.SONG;
|
||||
}
|
||||
|
||||
export const SaveAsCollectionButton = ({ fullWidth, itemType }: SaveAsCollectionButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { customFilters } = useListContext();
|
||||
const collections = useCollections();
|
||||
const { addCollection, updateCollection } = useSettingsStoreActions();
|
||||
const [isOpen, handlers] = useDisclosure(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const sameTypeCollections = useMemo(
|
||||
() => collections?.filter((c): c is SavedCollection => c.type === itemType) ?? [],
|
||||
[collections, itemType],
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
form.setValues({ name: '' });
|
||||
handlers.open();
|
||||
}, [form, handlers]);
|
||||
|
||||
const handleOverrideExisting = useCallback(
|
||||
(collection: SavedCollection) => {
|
||||
const filterQueryString = getFilterQueryStringFromSearchParams(
|
||||
searchParams,
|
||||
customFilters as Record<
|
||||
string,
|
||||
boolean | number | Record<string, unknown> | string | string[]
|
||||
>,
|
||||
);
|
||||
updateCollection(collection.id, { filterQueryString });
|
||||
handlers.close();
|
||||
},
|
||||
[customFilters, handlers, searchParams, updateCollection],
|
||||
);
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
const trimmed = values.name.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const filterQueryString = getFilterQueryStringFromSearchParams(
|
||||
searchParams,
|
||||
customFilters as Record<
|
||||
string,
|
||||
boolean | number | Record<string, unknown> | string | string[]
|
||||
>,
|
||||
);
|
||||
|
||||
addCollection({
|
||||
filterQueryString,
|
||||
id: nanoid(),
|
||||
name: trimmed,
|
||||
type: itemType,
|
||||
});
|
||||
handlers.close();
|
||||
});
|
||||
|
||||
const handleFormKeyDown = useCallback((e: React.KeyboardEvent<HTMLFormElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover onClose={handlers.close} opened={isOpen} position="bottom-start" width={320}>
|
||||
<Popover.Target>
|
||||
{fullWidth ? (
|
||||
<Button fullWidth onClick={handleOpen} variant="default">
|
||||
{t('page.collections.saveAsCollection', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
) : (
|
||||
<ActionIcon
|
||||
icon="folder"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleOpen}
|
||||
tooltip={{
|
||||
label: t('page.collections.saveAsCollection', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<form onKeyDown={handleFormKeyDown} onSubmit={handleSubmit} ref={formRef}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500} size="sm" ta="center">
|
||||
{t('page.collections.overrideExisting', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
<div className={styles.list}>
|
||||
<ScrollArea>
|
||||
<Stack gap={0}>
|
||||
{sameTypeCollections.map((collection) => (
|
||||
<Button
|
||||
className={styles.row}
|
||||
key={collection.id}
|
||||
onClick={() => handleOverrideExisting(collection)}
|
||||
type="button"
|
||||
variant="subtle"
|
||||
>
|
||||
<Text className={styles['row-name']} size="sm">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<TextInput autoFocus maxLength={128} {...form.getInputProps('name')} />
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button onClick={handlers.close} type="button" variant="subtle">
|
||||
{t('common.cancel', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="filled">
|
||||
{t('common.save', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate } from 'react-router';
|
||||
import { Link, NavLink, useNavigate } from 'react-router';
|
||||
|
||||
import styles from './collapsed-sidebar.module.css';
|
||||
|
||||
@@ -12,10 +12,12 @@ import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.p
|
||||
import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
|
||||
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
||||
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
|
||||
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import {
|
||||
SidebarItemType,
|
||||
useCollections,
|
||||
useCurrentServer,
|
||||
useSidebarCollapsedNavigation,
|
||||
useSidebarItems,
|
||||
@@ -26,12 +28,14 @@ import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { ServerType } from '/@/shared/types/domain-types';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
export const CollapsedSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const collections = useCollections();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
||||
const sidebarItems = useSidebarItems();
|
||||
@@ -45,6 +49,7 @@ export const CollapsedSidebar = () => {
|
||||
'\n',
|
||||
),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
@@ -110,17 +115,66 @@ export const CollapsedSidebar = () => {
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
{sidebarItemsWithRoute.map((item) => (
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={<SidebarIcon active route={item.route} size="25" />}
|
||||
component={NavLink}
|
||||
icon={<SidebarIcon route={item.route} size="25" />}
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
route={item.route}
|
||||
to={item.route}
|
||||
/>
|
||||
))}
|
||||
{sidebarItemsWithRoute.map((item) =>
|
||||
item.id === 'Collections' ? (
|
||||
collections && collections.length > 0 ? (
|
||||
<DropdownMenu key={item.id} offset={0} position="right-end">
|
||||
<DropdownMenu.Target>
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={null}
|
||||
component={Flex}
|
||||
icon={<Icon color="muted" icon="collection" size="3xl" />}
|
||||
label={item.label}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: 'var(--theme-spacing-md) 0',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<ScrollArea style={{ maxHeight: '50vh' }}>
|
||||
<Stack gap={0} p="xs">
|
||||
{collections.map((collection) => {
|
||||
const to = getCollectionTo(collection);
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
component={Link}
|
||||
key={collection.id}
|
||||
leftSection={
|
||||
<Icon
|
||||
color="muted"
|
||||
icon={
|
||||
collection.type ===
|
||||
LibraryItem.ALBUM
|
||||
? 'itemAlbum'
|
||||
: 'itemSong'
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
to={to}
|
||||
>
|
||||
{collection.name}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
) : null
|
||||
) : (
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={<SidebarIcon active route={item.route} size="25" />}
|
||||
component={NavLink}
|
||||
icon={<SidebarIcon route={item.route} size="25" />}
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
route={item.route}
|
||||
to={item.route}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{currentServer && (
|
||||
<DropdownMenu offset={0} position="right-end" width={240}>
|
||||
<DropdownMenu.Target>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.row:hover .more-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-active {
|
||||
background-color: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
.row-link {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||
color: var(--theme-colors-foreground);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 1px transparent solid;
|
||||
border-radius: var(--theme-radius-md);
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus-visible {
|
||||
@mixin dark {
|
||||
background-color: lighten(var(--theme-colors-background), 10%);
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: darken(var(--theme-colors-background), 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
}
|
||||
|
||||
.row-active .row-link {
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.row-active .row-link .name {
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.row-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import clsx from 'clsx';
|
||||
import { MouseEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import styles from './sidebar-collection-list.module.css';
|
||||
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCollections, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { getFilterQueryStringFromSearchParams } from '/@/renderer/utils/query-params';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import { LibraryItem, SavedCollection } from '/@/shared/types/domain-types';
|
||||
|
||||
export const getCollectionTo = (collection: SavedCollection) => {
|
||||
const pathname =
|
||||
collection.type === LibraryItem.ALBUM ? AppRoute.LIBRARY_ALBUMS : AppRoute.LIBRARY_SONGS;
|
||||
const search = collection.filterQueryString ? `?${collection.filterQueryString}` : '';
|
||||
return { pathname, search };
|
||||
};
|
||||
|
||||
const CollectionRow = ({
|
||||
collection,
|
||||
onRename,
|
||||
}: {
|
||||
collection: SavedCollection;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { removeCollection } = useSettingsStoreActions();
|
||||
const [isRenameOpen, renameHandlers] = useDisclosure(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: collection.name,
|
||||
},
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const to = getCollectionTo(collection);
|
||||
|
||||
const currentFilterQuery = getFilterQueryStringFromSearchParams(
|
||||
new URLSearchParams(location.search),
|
||||
);
|
||||
const collectionFilterQuery = collection.filterQueryString ?? '';
|
||||
const isActive =
|
||||
location.pathname === to.pathname && currentFilterQuery === collectionFilterQuery;
|
||||
|
||||
const handleRenameOpen = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.setValues({ name: collection.name });
|
||||
renameHandlers.open();
|
||||
},
|
||||
[collection.name, form, renameHandlers],
|
||||
);
|
||||
|
||||
const handleRenameSubmit = form.onSubmit((values) => {
|
||||
const trimmed = values.name.trim();
|
||||
if (trimmed) {
|
||||
onRename(collection.id, trimmed);
|
||||
renameHandlers.close();
|
||||
}
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeCollection(collection.id);
|
||||
},
|
||||
[collection.id, removeCollection],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onClose={renameHandlers.close}
|
||||
opened={isRenameOpen}
|
||||
position="right-start"
|
||||
width={280}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div className={clsx(styles.row, { [styles.rowActive]: isActive })}>
|
||||
<Link className={styles.rowLink} to={to}>
|
||||
<Group className={styles.rowContent}>
|
||||
<Icon
|
||||
color={isActive ? 'primary' : 'muted'}
|
||||
icon={
|
||||
collection.type === LibraryItem.ALBUM ? 'itemAlbum' : 'itemSong'
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
<Text className={styles.name} fw={500} size="md">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<DropdownMenu position="right-start" trigger="click">
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
className={styles.moreButton}
|
||||
icon="ellipsisVertical"
|
||||
iconProps={{ size: 'xs' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
size="compact-sm"
|
||||
variant="transparent"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item onClick={handleRenameOpen}>
|
||||
{t('common.rename', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item color="red" onClick={handleDelete}>
|
||||
{t('common.delete', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Link>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<form onSubmit={handleRenameSubmit}>
|
||||
<Stack gap="md" p="xs">
|
||||
<TextInput
|
||||
autoFocus
|
||||
maxLength={128}
|
||||
variant="filled"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button onClick={renameHandlers.close} type="button" variant="subtle">
|
||||
{t('common.cancel', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="filled">
|
||||
{t('common.save', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarCollectionList = () => {
|
||||
const { t } = useTranslation();
|
||||
const collections = useCollections();
|
||||
const { updateCollection } = useSettingsStoreActions();
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, name: string) => {
|
||||
updateCollection(id, { name });
|
||||
},
|
||||
[updateCollection],
|
||||
);
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item value="collections">
|
||||
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
||||
<Text fw={500}>{t('page.sidebar.collections', { postProcess: 'titleCase' })}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{collections.map((collection) => (
|
||||
<CollectionRow
|
||||
collection={collection}
|
||||
key={collection.id}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
|
||||
import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
|
||||
import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ export const Sidebar = () => {
|
||||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
@@ -84,6 +86,12 @@ export const Sidebar = () => {
|
||||
return items;
|
||||
}, [sidebarItems, translatedSidebarItemMap]);
|
||||
|
||||
/* Library accordion: only items with a route (exclude Collections section) */
|
||||
const libraryItemsWithRoute = useMemo(
|
||||
() => sidebarItemsWithRoute.filter((item) => item.id !== 'Collections' && item.route),
|
||||
[sidebarItemsWithRoute],
|
||||
);
|
||||
|
||||
const isCustomWindowBar =
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
|
||||
|
||||
@@ -105,7 +113,7 @@ export const Sidebar = () => {
|
||||
item: styles.accordionItem,
|
||||
root: styles.accordionRoot,
|
||||
}}
|
||||
defaultValue={['library', 'playlists']}
|
||||
defaultValue={['library', 'collections', 'playlists']}
|
||||
multiple
|
||||
>
|
||||
<Accordion.Item value="library">
|
||||
@@ -117,7 +125,7 @@ export const Sidebar = () => {
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{sidebarItemsWithRoute.map((item) => {
|
||||
{libraryItemsWithRoute.map((item) => {
|
||||
return (
|
||||
<SidebarItem key={`sidebar-${item.route}`} to={item.route}>
|
||||
<Group gap="md">
|
||||
@@ -129,6 +137,7 @@ export const Sidebar = () => {
|
||||
})}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<SidebarCollectionList />
|
||||
{sidebarPlaylistList && (
|
||||
<>
|
||||
<SidebarPlaylistList />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lazy, Suspense, useMemo } from 'react';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';
|
||||
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
@@ -44,11 +45,14 @@ export const SongListContent = () => {
|
||||
const SongListFilters = () => {
|
||||
return (
|
||||
<ListWithSidebarContainer.SidebarPortal>
|
||||
<Stack h="100%">
|
||||
<Stack h="100%" style={{ minHeight: 0 }}>
|
||||
<ListFiltersTitle itemType={LibraryItem.SONG} />
|
||||
<ScrollArea>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<ListFilters itemType={LibraryItem.SONG} />
|
||||
</ScrollArea>
|
||||
<Stack p="sm">
|
||||
<SaveAsCollectionButton fullWidth itemType={LibraryItem.SONG} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ListWithSidebarContainer.SidebarPortal>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import isElectron from 'is-electron';
|
||||
import mergeWith from 'lodash/mergeWith';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useMemo } from 'react';
|
||||
import { generatePath } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
@@ -26,7 +27,7 @@ import { FontValueSchema } from '/@/renderer/types/fonts';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||
import { AppTheme } from '/@/shared/themes/app-theme-types';
|
||||
import { LibraryItem, LyricSource } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, LyricSource, SavedCollection } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
FontType,
|
||||
ItemListKey,
|
||||
@@ -154,6 +155,13 @@ const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
|
||||
|
||||
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
||||
|
||||
const CollectionSchema = z.object({
|
||||
filterQueryString: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum([LibraryItem.ALBUM, LibraryItem.SONG]),
|
||||
});
|
||||
|
||||
const SidebarItemTypeSchema = z.object({
|
||||
disabled: z.boolean(),
|
||||
id: z.string(),
|
||||
@@ -408,6 +416,7 @@ export const GeneralSettingsSchema = z.object({
|
||||
artistRadioCount: z.number(),
|
||||
artistReleaseTypeItems: z.array(SortableItemSchema(ArtistReleaseTypeItemSchema)),
|
||||
buttonSize: z.number(),
|
||||
collections: z.array(CollectionSchema),
|
||||
combinedLyricsAndVisualizer: z.boolean(),
|
||||
disabledContextMenu: z.record(z.string(), z.boolean()),
|
||||
enableGridMultiSelect: z.boolean(),
|
||||
@@ -755,6 +764,7 @@ export enum SidebarItem {
|
||||
ALBUMS = 'Albums',
|
||||
ARTISTS = 'Artists',
|
||||
ARTISTS_ALL = 'Artists-all',
|
||||
COLLECTIONS = 'Collections',
|
||||
FAVORITES = 'Favorites',
|
||||
FOLDERS = 'Folders',
|
||||
GENRES = 'Genres',
|
||||
@@ -792,6 +802,8 @@ export type PlayerFilterOperator = z.infer<typeof PlayerFilterOperatorSchema>;
|
||||
|
||||
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
||||
actions: {
|
||||
addCollection: (collection: SavedCollection) => void;
|
||||
removeCollection: (id: string) => void;
|
||||
reset: () => void;
|
||||
resetSampleRate: () => void;
|
||||
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
|
||||
@@ -806,6 +818,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
||||
setTranscodingConfig: (config: TranscodingConfig) => void;
|
||||
toggleMediaSession: () => void;
|
||||
toggleSidebarCollapseShare: () => void;
|
||||
updateCollection: (id: string, updates: Partial<Omit<SavedCollection, 'id'>>) => void;
|
||||
};
|
||||
}
|
||||
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
||||
@@ -884,6 +897,12 @@ export const sidebarItems: SidebarItemType[] = [
|
||||
label: i18n.t('page.sidebar.playlists'),
|
||||
route: AppRoute.PLAYLISTS,
|
||||
},
|
||||
{
|
||||
disabled: false,
|
||||
id: 'Collections',
|
||||
label: i18n.t('page.sidebar.collections'),
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
disabled: false,
|
||||
id: 'Radio',
|
||||
@@ -967,6 +986,7 @@ const initialState: SettingsState = {
|
||||
artistRadioCount: 20,
|
||||
artistReleaseTypeItems,
|
||||
buttonSize: 15,
|
||||
collections: [],
|
||||
combinedLyricsAndVisualizer: false,
|
||||
disabledContextMenu: {},
|
||||
enableGridMultiSelect: false,
|
||||
@@ -1659,6 +1679,18 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
||||
subscribeWithSelector(
|
||||
immer((set) => ({
|
||||
actions: {
|
||||
addCollection: (collection: SavedCollection) => {
|
||||
set((state) => {
|
||||
state.general.collections.push(collection);
|
||||
});
|
||||
},
|
||||
removeCollection: (id: string) => {
|
||||
set((state) => {
|
||||
state.general.collections = state.general.collections.filter(
|
||||
(c) => c.id !== id,
|
||||
);
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
localStorage.removeItem('store_settings');
|
||||
window.location.reload();
|
||||
@@ -1748,6 +1780,17 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
||||
!state.general.sidebarCollapseShared;
|
||||
});
|
||||
},
|
||||
updateCollection: (
|
||||
id: string,
|
||||
updates: Partial<Omit<SavedCollection, 'id'>>,
|
||||
) => {
|
||||
set((state) => {
|
||||
const idx = state.general.collections.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
Object.assign(state.general.collections[idx], updates);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
...initialState,
|
||||
})),
|
||||
@@ -2135,6 +2178,15 @@ export const useSideQueueType = () =>
|
||||
export const useVolumeWheelStep = () =>
|
||||
useSettingsStore((state) => state.general.volumeWheelStep, shallow);
|
||||
|
||||
export const useCollections = () => {
|
||||
const collections = useSettingsStore((state) => state.general.collections, shallow);
|
||||
|
||||
return useMemo(
|
||||
() => [...(collections ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[collections],
|
||||
);
|
||||
};
|
||||
|
||||
export const useSidebarPlaylistList = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
||||
|
||||
|
||||
@@ -178,3 +178,33 @@ export const parseCustomFiltersParam = (
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const PAGINATION_KEYS = ['currentPage', 'scrollOffset'];
|
||||
|
||||
/**
|
||||
* Build filter query string from current search params (minus pagination/scroll).
|
||||
* Optionally merge customFilters (e.g. from ListContext) into the result.
|
||||
*/
|
||||
export const getFilterQueryStringFromSearchParams = (
|
||||
searchParams: URLSearchParams,
|
||||
customFilters?: Record<string, boolean | number | Record<string, unknown> | string | string[]>,
|
||||
): string => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
for (const key of PAGINATION_KEYS) {
|
||||
params.delete(key);
|
||||
}
|
||||
if (customFilters && Object.keys(customFilters).length > 0) {
|
||||
for (const [key, value] of Object.entries(customFilters)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
params.delete(key);
|
||||
value.forEach((v) => params.append(key, String(v)));
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
params.set(key, JSON.stringify(value));
|
||||
} else {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export const Modal = ({ children, classNames, handlers, ...rest }: ModalProps) =
|
||||
backgroundOpacity: 0.5,
|
||||
blur: 1,
|
||||
}}
|
||||
radius="xl"
|
||||
radius="md"
|
||||
scrollAreaComponent={ScrollArea}
|
||||
transitionProps={{
|
||||
duration: 300,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
.dropdown {
|
||||
padding: var(--theme-spacing-lg);
|
||||
padding: var(--theme-spacing-sm);
|
||||
color: var(--theme-colors-foreground);
|
||||
background: var(--theme-colors-background);
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
border-radius: var(--theme-radius-xl);
|
||||
border: 2px solid var(--theme-colors-border);
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
|
||||
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
|
||||
classNames={{
|
||||
dropdown: styles.dropdown,
|
||||
}}
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
offset={10}
|
||||
transitionProps={{ transition: getTransition(props.position) }}
|
||||
withArrow={false}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user