mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 13:00:13 +02:00
add favorites list
This commit is contained in:
@@ -1,17 +1,28 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { AlbumArtistListSort } from '/@/shared/types/domain-types';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export const useAlbumArtistListFilters = () => {
|
||||
const { sortBy } = useSortByFilter<AlbumArtistListSort>(null, ItemListKey.ALBUM_ARTIST);
|
||||
const { setSortBy, sortBy } = useSortByFilter<AlbumArtistListSort>(
|
||||
null,
|
||||
ItemListKey.ALBUM_ARTIST,
|
||||
);
|
||||
|
||||
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
|
||||
const { setSortOrder, sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
|
||||
|
||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSearchTerm(null);
|
||||
setSortBy(AlbumArtistListSort.NAME);
|
||||
setSortOrder(SortOrder.ASC);
|
||||
}, [setSearchTerm, setSortBy, setSortOrder]);
|
||||
|
||||
const query = {
|
||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||
@@ -19,6 +30,7 @@ export const useAlbumArtistListFilters = () => {
|
||||
};
|
||||
|
||||
return {
|
||||
clear,
|
||||
query,
|
||||
setSearchTerm,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import {
|
||||
AlbumListView,
|
||||
OverrideAlbumListQuery,
|
||||
} from '/@/renderer/features/albums/components/album-list-content';
|
||||
import {
|
||||
AlbumArtistListView,
|
||||
OverrideAlbumArtistListQuery,
|
||||
} from '/@/renderer/features/artists/components/album-artist-list-content';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import {
|
||||
OverrideSongListQuery,
|
||||
SongListView,
|
||||
} from '/@/renderer/features/songs/components/song-list-content';
|
||||
import { useListSettings } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface FavoritesContentProps {
|
||||
itemType: LibraryItem;
|
||||
}
|
||||
|
||||
export const FavoritesContent = ({ itemType }: FavoritesContentProps) => {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{itemType === LibraryItem.ALBUM && <AlbumFavorites />}
|
||||
{itemType === LibraryItem.SONG && <SongFavorites />}
|
||||
{itemType === LibraryItem.ALBUM_ARTIST && <ArtistFavorites />}
|
||||
</Suspense>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
const AlbumFavorites = () => {
|
||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);
|
||||
const { customFilters } = useListContext();
|
||||
|
||||
const albumQuery: OverrideAlbumListQuery = {
|
||||
...(customFilters as OverrideAlbumListQuery),
|
||||
};
|
||||
|
||||
return (
|
||||
<AlbumListView
|
||||
display={display}
|
||||
grid={grid}
|
||||
itemsPerPage={itemsPerPage}
|
||||
overrideQuery={albumQuery}
|
||||
pagination={pagination}
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SongFavorites = () => {
|
||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);
|
||||
const { customFilters } = useListContext();
|
||||
|
||||
const songQuery: OverrideSongListQuery = {
|
||||
...(customFilters as OverrideSongListQuery),
|
||||
};
|
||||
|
||||
return (
|
||||
<SongListView
|
||||
display={display}
|
||||
grid={grid}
|
||||
itemsPerPage={itemsPerPage}
|
||||
overrideQuery={songQuery}
|
||||
pagination={pagination}
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistFavorites = () => {
|
||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ARTIST);
|
||||
const { customFilters } = useListContext();
|
||||
|
||||
const albumArtistQuery: OverrideAlbumArtistListQuery = {
|
||||
...(customFilters as OverrideAlbumArtistListQuery),
|
||||
};
|
||||
|
||||
return (
|
||||
<AlbumArtistListView
|
||||
display={display}
|
||||
grid={grid}
|
||||
itemsPerPage={itemsPerPage}
|
||||
overrideQuery={albumArtistQuery}
|
||||
pagination={pagination}
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
import { useAlbumArtistListFilters } from '/@/renderer/features/artists/hooks/use-album-artist-list-filters';
|
||||
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
||||
import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';
|
||||
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface FavoritesHeaderProps {
|
||||
itemType: LibraryItem;
|
||||
}
|
||||
|
||||
export const FavoritesHeader = ({ itemType }: FavoritesHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { customFilters, itemCount } = useListContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const albumFilters = useAlbumListFilters();
|
||||
const albumArtistFilters = useAlbumArtistListFilters();
|
||||
const songFilters = useSongListFilters();
|
||||
|
||||
const playQuery = useMemo(() => {
|
||||
let query = {};
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
query = albumFilters.query;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
query = albumArtistFilters.query;
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
query = songFilters.query;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
...(customFilters ?? {}),
|
||||
};
|
||||
}, [albumFilters.query, albumArtistFilters.query, songFilters.query, customFilters, itemType]);
|
||||
|
||||
const handleItemTypeChange = useCallback(
|
||||
(type: LibraryItem) => {
|
||||
albumFilters.clear();
|
||||
songFilters.clear();
|
||||
albumArtistFilters.clear();
|
||||
|
||||
// Clear all URL search params except 'type'
|
||||
navigate(`?type=${type}`, { replace: true });
|
||||
},
|
||||
[albumFilters, albumArtistFilters, songFilters, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<PageHeader>
|
||||
<Flex justify="space-between" w="100%">
|
||||
<LibraryHeaderBar ignoreMaxWidth>
|
||||
<PlayButton itemType={itemType} query={playQuery} />
|
||||
<LibraryHeaderBar.Title>
|
||||
<DropdownMenu position="right">
|
||||
<DropdownMenu.Target>
|
||||
<Stack gap={0} style={{ cursor: 'pointer' }}>
|
||||
<Group>
|
||||
<TextTitle isNoSelect order={3}>
|
||||
{t('page.favorites.title', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<Icon icon="dropdown" size="xl" />
|
||||
</Group>
|
||||
<Text isMuted size="sm">
|
||||
{itemType === LibraryItem.ALBUM &&
|
||||
t('entity.album_other', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
{itemType === LibraryItem.ALBUM_ARTIST &&
|
||||
t('entity.artist_other', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
{itemType === LibraryItem.SONG &&
|
||||
t('entity.track_other', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
isSelected={itemType === LibraryItem.SONG}
|
||||
leftSection={<Icon icon="track" size="xl" />}
|
||||
onClick={() => handleItemTypeChange(LibraryItem.SONG)}
|
||||
>
|
||||
{t('entity.track_other', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
isSelected={itemType === LibraryItem.ALBUM}
|
||||
leftSection={<Icon icon="album" size="xl" />}
|
||||
onClick={() => handleItemTypeChange(LibraryItem.ALBUM)}
|
||||
>
|
||||
{t('entity.album_other', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
isSelected={itemType === LibraryItem.ALBUM_ARTIST}
|
||||
leftSection={<Icon icon="artist" size="xl" />}
|
||||
onClick={() =>
|
||||
handleItemTypeChange(LibraryItem.ALBUM_ARTIST)
|
||||
}
|
||||
>
|
||||
{t('entity.artist_other', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge isLoading={!itemCount}>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<ListSearchInput />
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
{itemType === LibraryItem.ALBUM && <AlbumListHeaderFilters />}
|
||||
{itemType === LibraryItem.ALBUM_ARTIST && <AlbumArtistListHeaderFilters />}
|
||||
{itemType === LibraryItem.SONG && <SongListHeaderFilters />}
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const PlayButton = ({ itemType, query }: { itemType: LibraryItem; query: Record<string, any> }) => {
|
||||
return <LibraryHeaderBar.PlayButton itemType={itemType} listQuery={query} variant="filled" />;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { FavoritesContent } from '/@/renderer/features/favorites/components/favorites-content';
|
||||
import { FavoritesHeader } from '/@/renderer/features/favorites/components/favorites-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const FavoritesRoute = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const itemType = (searchParams.get('type') as LibraryItem) || LibraryItem.SONG;
|
||||
|
||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||
|
||||
const getPageKey = (type: LibraryItem): ItemListKey => {
|
||||
switch (type) {
|
||||
case LibraryItem.ALBUM:
|
||||
return ItemListKey.ALBUM;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
return ItemListKey.ALBUM_ARTIST;
|
||||
case LibraryItem.SONG:
|
||||
return ItemListKey.SONG;
|
||||
default:
|
||||
return ItemListKey.SONG;
|
||||
}
|
||||
};
|
||||
|
||||
const pageKey = useMemo(() => getPageKey(itemType), [itemType]);
|
||||
|
||||
const customFilters = useMemo(() => {
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return { favorite: true };
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
return { favorite: true };
|
||||
case LibraryItem.SONG:
|
||||
return { favorite: true };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [itemType]);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
customFilters,
|
||||
itemCount,
|
||||
pageKey,
|
||||
setItemCount,
|
||||
};
|
||||
}, [customFilters, itemCount, pageKey]);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<FavoritesHeader itemType={itemType} />
|
||||
<FavoritesContent itemType={itemType} />
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesRouteWithBoundary = () => {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<FavoritesRoute />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesRouteWithBoundary;
|
||||
@@ -10,6 +10,7 @@ const SIDEBAR_ITEMS: Array<[string, string]> = [
|
||||
['Now Playing', 'page.sidebar.nowPlaying'],
|
||||
['Playlists', 'page.sidebar.playlists'],
|
||||
['Search', 'page.sidebar.search'],
|
||||
['Favorites', 'page.sidebar.favorites'],
|
||||
['Settings', 'page.sidebar.settings'],
|
||||
['Tracks', 'page.sidebar.tracks'],
|
||||
];
|
||||
|
||||
@@ -32,6 +32,7 @@ export const CollapsedSidebar = () => {
|
||||
'\n',
|
||||
),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||
|
||||
@@ -28,6 +28,7 @@ export const MobileSidebar = () => {
|
||||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||
@@ -105,7 +106,7 @@ export const MobileSidebar = () => {
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
<div className={styles.serverSelectorWrapper}>
|
||||
<ServerSelector showImage={false} />
|
||||
<ServerSelector />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
RiFlag2Line,
|
||||
RiFolder3Fill,
|
||||
RiFolder3Line,
|
||||
RiHeartFill,
|
||||
RiHeartLine,
|
||||
RiHome6Fill,
|
||||
RiHome6Line,
|
||||
RiMusic2Fill,
|
||||
@@ -67,6 +69,10 @@ export const SidebarIcon = ({ active, route, size }: SidebarIconProps) => {
|
||||
if (active) return <RiSearchFill size={size} />;
|
||||
return <RiSearchLine size={size} />;
|
||||
default:
|
||||
if (route.startsWith(AppRoute.FAVORITES)) {
|
||||
if (active) return <RiHeartFill size={size} />;
|
||||
return <RiHeartLine size={size} />;
|
||||
}
|
||||
return <RiHome6Line size={size} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,6 +46,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' }),
|
||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||
|
||||
Reference in New Issue
Block a user