mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
fix album list filters
This commit is contained in:
@@ -299,6 +299,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
genre_id: genres,
|
genre_id: genres,
|
||||||
library_id: getLibraryId(query.musicFolderId),
|
library_id: getLibraryId(query.musicFolderId),
|
||||||
name: query.searchTerm,
|
name: query.searchTerm,
|
||||||
|
year: query.maxYear || query.minYear,
|
||||||
...query._custom,
|
...query._custom,
|
||||||
starred: query.favorite,
|
starred: query.favorite,
|
||||||
...excludeMissing(apiClientProps.server),
|
...excludeMissing(apiClientProps.server),
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { lazy, Suspense, useMemo } from 'react';
|
|||||||
|
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
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 } from '/@/renderer/features/shared/components/list-filters';
|
||||||
|
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||||
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||||
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
import { AlbumListQuery } from '/@/shared/types/domain-types';
|
import { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';
|
||||||
|
|
||||||
const AlbumListInfiniteGrid = lazy(() =>
|
const AlbumListInfiniteGrid = lazy(() =>
|
||||||
@@ -37,6 +40,12 @@ export const AlbumListContent = () => {
|
|||||||
const { customFilters } = useListContext();
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
|
<ScrollArea>
|
||||||
|
<ListFilters itemType={LibraryItem.ALBUM} />
|
||||||
|
</ScrollArea>
|
||||||
|
</ListWithSidebarContainer.SidebarPortal>
|
||||||
<Suspense fallback={<Spinner container />}>
|
<Suspense fallback={<Spinner container />}>
|
||||||
<AlbumListView
|
<AlbumListView
|
||||||
display={display}
|
display={display}
|
||||||
@@ -47,6 +56,7 @@ export const AlbumListContent = () => {
|
|||||||
table={table}
|
table={table}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +87,10 @@ export const AlbumListView = ({
|
|||||||
};
|
};
|
||||||
}, [query, overrideQuery]);
|
}, [query, overrideQuery]);
|
||||||
|
|
||||||
|
console.log('query', query);
|
||||||
|
console.log('overrideQuery', overrideQuery);
|
||||||
|
console.log('mergedQuery', mergedQuery);
|
||||||
|
|
||||||
switch (display) {
|
switch (display) {
|
||||||
case ListDisplayType.GRID: {
|
case ListDisplayType.GRID: {
|
||||||
switch (pagination) {
|
switch (pagination) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
|
import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
|
||||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
@@ -65,7 +65,7 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
|
|||||||
defaultSortOrder={SortOrder.ASC}
|
defaultSortOrder={SortOrder.ASC}
|
||||||
listKey={ItemListKey.ALBUM}
|
listKey={ItemListKey.ALBUM}
|
||||||
/>
|
/>
|
||||||
<ListFilters itemType={LibraryItem.ALBUM} />
|
<ListFiltersModal itemType={LibraryItem.ALBUM} />
|
||||||
<ListRefreshButton listKey={ItemListKey.ALBUM} />
|
<ListRefreshButton listKey={ItemListKey.ALBUM} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import debounce from 'lodash/debounce';
|
import { ChangeEvent, memo, useMemo } from 'react';
|
||||||
import { ChangeEvent, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,19 +10,17 @@ import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album
|
|||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store';
|
||||||
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
|
import { titleCase } from '/@/renderer/utils';
|
||||||
import { hasFeature } from '/@/shared/api/utils';
|
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
import { Spinner, SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
||||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
|
||||||
|
|
||||||
interface NavidromeAlbumFiltersProps {
|
interface NavidromeAlbumFiltersProps {
|
||||||
disableArtistFilter?: boolean;
|
disableArtistFilter?: boolean;
|
||||||
@@ -38,7 +35,6 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
query,
|
query,
|
||||||
setAlbumArtist,
|
setAlbumArtist,
|
||||||
setCompilation,
|
setCompilation,
|
||||||
setCustom,
|
|
||||||
setFavorite,
|
setFavorite,
|
||||||
setGenreId,
|
setGenreId,
|
||||||
setHasRating,
|
setHasRating,
|
||||||
@@ -57,20 +53,8 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [genreListQuery.data]);
|
||||||
|
|
||||||
const tagsQuery = useQuery(
|
const yesNoUndefinedFilters = useMemo(
|
||||||
sharedQueries.tags({
|
() => [
|
||||||
options: {
|
|
||||||
gcTime: 1000 * 60 * 2,
|
|
||||||
staleTime: 1000 * 60 * 1,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: LibraryItem.ALBUM,
|
|
||||||
},
|
|
||||||
serverId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const yesNoUndefinedFilters = [
|
|
||||||
{
|
{
|
||||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||||
onChange: (favorite?: boolean) => {
|
onChange: (favorite?: boolean) => {
|
||||||
@@ -85,9 +69,12 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
},
|
},
|
||||||
value: query.compilation,
|
value: query.compilation,
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[t, query.favorite, query.compilation, setFavorite, setCompilation],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleFilters = [
|
const toggleFilters = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
|
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -104,13 +91,35 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
},
|
},
|
||||||
value: query.recentlyPlayed,
|
value: query.recentlyPlayed,
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[t, query.hasRating, query.recentlyPlayed, setHasRating, setRecentlyPlayed],
|
||||||
|
);
|
||||||
|
|
||||||
const handleYearFilter = debounce((e: number | string) => {
|
const handleYearFilter = useMemo(
|
||||||
const year = e === '' ? undefined : (e as number);
|
() => (e: number | string) => {
|
||||||
setMinYear(year ?? null);
|
// Handle empty string, null, undefined, or invalid numbers as clearing
|
||||||
setMaxYear(year ?? null);
|
|
||||||
}, 500);
|
if (e === '' || e === null || e === undefined) {
|
||||||
|
console.log('clearing year filters');
|
||||||
|
setMinYear(null);
|
||||||
|
setMaxYear(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = typeof e === 'number' ? e : Number(e);
|
||||||
|
// If it's a valid number, set it; otherwise clear
|
||||||
|
if (!isNaN(year) && isFinite(year) && year > 0) {
|
||||||
|
console.log('setting year filters', year);
|
||||||
|
setMinYear(year);
|
||||||
|
setMaxYear(year);
|
||||||
|
} else {
|
||||||
|
console.log('clearing year filters', year);
|
||||||
|
setMinYear(null);
|
||||||
|
setMaxYear(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setMinYear, setMaxYear],
|
||||||
|
);
|
||||||
|
|
||||||
const albumArtistListQuery = useQuery(
|
const albumArtistListQuery = useQuery(
|
||||||
artistsQueries.albumArtistList({
|
artistsQueries.albumArtistList({
|
||||||
@@ -136,26 +145,15 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
}));
|
}));
|
||||||
}, [albumArtistListQuery.data?.items]);
|
}, [albumArtistListQuery.data?.items]);
|
||||||
|
|
||||||
const handleTagFilter = debounce((tag: string, e: null | string) => {
|
|
||||||
setCustom((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[tag]: e || undefined,
|
|
||||||
}));
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
const hasBFR = hasFeature(server, ServerFeature.BFR);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="0.8rem">
|
||||||
{yesNoUndefinedFilters.map((filter) => (
|
{yesNoUndefinedFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
|
||||||
<Text>{filter.label}</Text>
|
|
||||||
<YesNoSelect
|
<YesNoSelect
|
||||||
|
key={`nd-filter-${filter.label}`}
|
||||||
|
label={filter.label}
|
||||||
onChange={filter.onChange}
|
onChange={filter.onChange}
|
||||||
size="xs"
|
|
||||||
value={filter.value ?? undefined}
|
value={filter.value ?? undefined}
|
||||||
/>
|
/>
|
||||||
</Group>
|
|
||||||
))}
|
))}
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
||||||
@@ -164,37 +162,22 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
<Divider my="0.5rem" />
|
||||||
<Group grow>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.minYear ?? undefined}
|
defaultValue={query.minYear ?? undefined}
|
||||||
hideControls={false}
|
hideControls={false}
|
||||||
label={t('common.year', { postProcess: 'titleCase' })}
|
label={t('common.year', { postProcess: 'titleCase' })}
|
||||||
max={5000}
|
max={5000}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => handleYearFilter(e)}
|
onBlur={(e) => handleYearFilter(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<SelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={genreList}
|
|
||||||
defaultValue={query.genreId ? query.genreId[0] : undefined}
|
|
||||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
|
||||||
onChange={(e) => (e ? setGenreId([e]) : undefined)}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
{hasBFR && (
|
|
||||||
<Group grow>
|
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={genreList}
|
data={genreList}
|
||||||
defaultValue={query.genreId}
|
defaultValue={query.genreIds}
|
||||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => (e ? setGenreId(e) : undefined)}
|
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
<Group grow>
|
|
||||||
<SelectWithInvalidData
|
<SelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={selectableAlbumArtists}
|
data={selectableAlbumArtists}
|
||||||
@@ -206,25 +189,126 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
</Group>
|
<TagFilters />
|
||||||
{tagsQuery.data?.enumTags?.length &&
|
|
||||||
tagsQuery.data.enumTags.length > 0 &&
|
|
||||||
tagsQuery.data.enumTags.map((tag) => (
|
|
||||||
<Group grow key={tag.name}>
|
|
||||||
<SelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={tag.options}
|
|
||||||
defaultValue={query._custom?.[tag.name] as string | undefined}
|
|
||||||
label={
|
|
||||||
NDSongQueryFields.find((i) => i.value === tag.name)?.label ||
|
|
||||||
tag.name
|
|
||||||
}
|
|
||||||
onChange={(value) => handleTagFilter(tag.name, value)}
|
|
||||||
searchable
|
|
||||||
width={150}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface TagFilterItemProps {
|
||||||
|
label: string;
|
||||||
|
onChange: (value: null | string) => void;
|
||||||
|
options: string[];
|
||||||
|
tagValue: string;
|
||||||
|
value: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagFilterItem = memo(
|
||||||
|
({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
|
||||||
|
return (
|
||||||
|
<SelectWithInvalidData
|
||||||
|
clearable
|
||||||
|
data={options}
|
||||||
|
defaultValue={value}
|
||||||
|
key={tagValue}
|
||||||
|
label={label}
|
||||||
|
limit={100}
|
||||||
|
onChange={onChange}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prevProps, nextProps) => {
|
||||||
|
// Only re-render if the specific tag's value or options change
|
||||||
|
// We don't compare onChange since it's a stable wrapper around handleTagFilter
|
||||||
|
// and handleTagFilter itself is memoized and stable
|
||||||
|
return (
|
||||||
|
prevProps.tagValue === nextProps.tagValue &&
|
||||||
|
prevProps.label === nextProps.label &&
|
||||||
|
prevProps.value === nextProps.value &&
|
||||||
|
prevProps.options === nextProps.options
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
TagFilterItem.displayName = 'TagFilterItem';
|
||||||
|
|
||||||
|
const TagFilters = () => {
|
||||||
|
const { query, setCustom } = useAlbumListFilters();
|
||||||
|
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
const tagsQuery = useQuery(
|
||||||
|
sharedQueries.tags({
|
||||||
|
options: {
|
||||||
|
gcTime: 1000 * 60 * 60,
|
||||||
|
staleTime: 1000 * 60 * 60,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: LibraryItem.ALBUM,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagFilter = useMemo(
|
||||||
|
() => (tag: string, e: null | string) => {
|
||||||
|
setCustom((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return e ? { [tag]: e } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e === null) {
|
||||||
|
const rest = Object.fromEntries(
|
||||||
|
Object.entries(prev).filter(([key]) => key !== tag),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(rest).length === 0 ? null : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[tag]: e,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setCustom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tags = useMemo(() => {
|
||||||
|
return (
|
||||||
|
tagsQuery.data?.enumTags?.map((tag) => ({
|
||||||
|
label: titleCase(tag.name),
|
||||||
|
options: tag.options,
|
||||||
|
value: tag.name,
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}, [tagsQuery.data?.enumTags]);
|
||||||
|
|
||||||
|
// Create stable onChange handlers for each tag using useMemo
|
||||||
|
const tagHandlers = useMemo(() => {
|
||||||
|
const handlers = new Map<string, (value: null | string) => void>();
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
handlers.set(tag.value, (value: null | string) => handleTagFilter(tag.value, value));
|
||||||
|
});
|
||||||
|
return handlers;
|
||||||
|
}, [tags, handleTagFilter]);
|
||||||
|
|
||||||
|
if (tagsQuery.isLoading) {
|
||||||
|
return <Spinner container />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TagFilterItem
|
||||||
|
key={tag.value}
|
||||||
|
label={tag.label}
|
||||||
|
onChange={tagHandlers.get(tag.value)!}
|
||||||
|
options={tag.options}
|
||||||
|
tagValue={tag.value}
|
||||||
|
value={query._custom?.[tag.value] as string | undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
parseAsString,
|
parseAsString,
|
||||||
useQueryState,
|
useQueryState,
|
||||||
} from 'nuqs';
|
} from 'nuqs';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||||
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
||||||
@@ -86,7 +86,8 @@ export const useAlbumListFilters = () => {
|
|||||||
setSortOrder,
|
setSortOrder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const query = {
|
const query = useMemo(
|
||||||
|
() => ({
|
||||||
[FILTER_KEYS.ALBUM._CUSTOM]: custom ?? undefined,
|
[FILTER_KEYS.ALBUM._CUSTOM]: custom ?? undefined,
|
||||||
[FILTER_KEYS.ALBUM.ARTIST_IDS]: albumArtist ?? undefined,
|
[FILTER_KEYS.ALBUM.ARTIST_IDS]: albumArtist ?? undefined,
|
||||||
[FILTER_KEYS.ALBUM.COMPILATION]: compilation ?? undefined,
|
[FILTER_KEYS.ALBUM.COMPILATION]: compilation ?? undefined,
|
||||||
@@ -99,7 +100,22 @@ export const useAlbumListFilters = () => {
|
|||||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
||||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
||||||
};
|
}),
|
||||||
|
[
|
||||||
|
custom,
|
||||||
|
albumArtist,
|
||||||
|
compilation,
|
||||||
|
favorite,
|
||||||
|
genreId,
|
||||||
|
hasRating,
|
||||||
|
maxYear,
|
||||||
|
minYear,
|
||||||
|
recentlyPlayed,
|
||||||
|
searchTerm,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clear,
|
clear,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ListContext } from '/@/renderer/context/list-context';
|
|||||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||||
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
||||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||||
|
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||||
import { AlbumListQuery } from '/@/shared/types/domain-types';
|
import { AlbumListQuery } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
@@ -28,10 +29,19 @@ const AlbumListRoute = () => {
|
|||||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const customFilters: Partial<AlbumListQuery> = useMemo(() => {
|
const customFilters: Partial<AlbumListQuery> = useMemo(() => {
|
||||||
|
if (albumArtistId) {
|
||||||
return {
|
return {
|
||||||
artistIds: albumArtistId ? [albumArtistId] : undefined,
|
artistIds: [albumArtistId],
|
||||||
genreIds: genreId ? [genreId] : undefined,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genreId) {
|
||||||
|
return {
|
||||||
|
genreIds: [genreId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
}, [albumArtistId, genreId]);
|
}, [albumArtistId, genreId]);
|
||||||
|
|
||||||
const providerValue = useMemo(() => {
|
const providerValue = useMemo(() => {
|
||||||
@@ -48,7 +58,9 @@ const AlbumListRoute = () => {
|
|||||||
<AnimatedPage>
|
<AnimatedPage>
|
||||||
<ListContext.Provider value={providerValue}>
|
<ListContext.Provider value={providerValue}>
|
||||||
<AlbumListHeader />
|
<AlbumListHeader />
|
||||||
|
<ListWithSidebarContainer>
|
||||||
<AlbumListContent />
|
<AlbumListContent />
|
||||||
|
</ListWithSidebarContainer>
|
||||||
</ListContext.Provider>
|
</ListContext.Provider>
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
|
import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
|
||||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
@@ -24,7 +24,7 @@ export const GenreListHeaderFilters = () => {
|
|||||||
defaultSortOrder={SortOrder.ASC}
|
defaultSortOrder={SortOrder.ASC}
|
||||||
listKey={ItemListKey.GENRE}
|
listKey={ItemListKey.GENRE}
|
||||||
/>
|
/>
|
||||||
<ListFilters itemType={LibraryItem.GENRE} />
|
<ListFiltersModal itemType={LibraryItem.GENRE} />
|
||||||
<ListRefreshButton listKey={ItemListKey.GENRE} />
|
<ListRefreshButton listKey={ItemListKey.GENRE} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
|
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
|
import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
|
||||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
@@ -39,7 +39,7 @@ export const PlaylistListHeaderFilters = () => {
|
|||||||
defaultSortOrder={SortOrder.ASC}
|
defaultSortOrder={SortOrder.ASC}
|
||||||
listKey={ItemListKey.PLAYLIST}
|
listKey={ItemListKey.PLAYLIST}
|
||||||
/>
|
/>
|
||||||
<ListFilters itemType={LibraryItem.PLAYLIST} />
|
<ListFiltersModal itemType={LibraryItem.PLAYLIST} />
|
||||||
<ListRefreshButton listKey={ItemListKey.PLAYLIST} />
|
<ListRefreshButton listKey={ItemListKey.PLAYLIST} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface ListFiltersProps {
|
|||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListFilters = ({ isActive, itemType }: ListFiltersProps) => {
|
export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
|
||||||
@@ -41,6 +41,14 @@ export const ListFilters = ({ isActive, itemType }: ListFiltersProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ListFilters = ({ itemType }: ListFiltersProps) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const serverType = server.type;
|
||||||
|
const FilterComponent = FILTERS[serverType][itemType];
|
||||||
|
|
||||||
|
return <FilterComponent />;
|
||||||
|
};
|
||||||
|
|
||||||
const FILTERS = {
|
const FILTERS = {
|
||||||
[ServerType.JELLYFIN]: {
|
[ServerType.JELLYFIN]: {
|
||||||
[LibraryItem.ALBUM]: JellyfinAlbumFilters,
|
[LibraryItem.ALBUM]: JellyfinAlbumFilters,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
container-type: inline-size;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 300px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 300px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { createContext, ReactNode, useContext, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import styles from './list-with-sidebar-container.module.css';
|
||||||
|
|
||||||
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
|
import { animationProps } from '/@/shared/components/animations/animation-props';
|
||||||
|
import { Portal } from '/@/shared/components/portal/portal';
|
||||||
|
|
||||||
|
interface ListWithSidebarContainerContextValue {
|
||||||
|
showSidebar: boolean;
|
||||||
|
sidebarRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListWithSidebarContainerContext = createContext<ListWithSidebarContainerContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ListWithSidebarContainerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
sidebarBreakpoint?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarPortalProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ children }: SidebarProps) {
|
||||||
|
const context = useContext(ListWithSidebarContainerContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Sidebar must be used within ResponsiveAnimatedPage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.showSidebar || !context.sidebarRef?.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal target={context.sidebarRef.current}>
|
||||||
|
<motion.div {...animationProps.slideInLeft} style={{ height: '100%', width: '100%' }}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarPortal({ children }: SidebarPortalProps) {
|
||||||
|
const context = useContext(ListWithSidebarContainerContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('SidebarPortal must be used within ResponsiveAnimatedPage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.showSidebar || !context.sidebarRef?.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Portal target={context.sidebarRef.current}>{children}</Portal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListWithSidebarContainer = ({
|
||||||
|
children,
|
||||||
|
sidebarBreakpoint,
|
||||||
|
}: ListWithSidebarContainerProps) => {
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { isLg, ref: containerQueryRef } = useContainerQuery({
|
||||||
|
lg: sidebarBreakpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSidebar = isLg;
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
showSidebar,
|
||||||
|
sidebarRef,
|
||||||
|
}),
|
||||||
|
[showSidebar],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListWithSidebarContainerContext.Provider value={contextValue}>
|
||||||
|
<div className={styles.container} ref={containerQueryRef}>
|
||||||
|
<div
|
||||||
|
className={styles.sidebarContainer}
|
||||||
|
ref={sidebarRef}
|
||||||
|
style={{ display: showSidebar ? 'block' : 'none' }}
|
||||||
|
/>
|
||||||
|
<div className={styles.contentContainer}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</ListWithSidebarContainerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ListWithSidebarContainer.Sidebar = Sidebar;
|
||||||
|
ListWithSidebarContainer.SidebarPortal = SidebarPortal;
|
||||||
@@ -40,7 +40,7 @@ enum AlbumFilterKeys {
|
|||||||
ARTIST_IDS = 'artistIds',
|
ARTIST_IDS = 'artistIds',
|
||||||
COMPILATION = 'compilation',
|
COMPILATION = 'compilation',
|
||||||
FAVORITE = 'favorite',
|
FAVORITE = 'favorite',
|
||||||
GENRE_ID = 'genreId',
|
GENRE_ID = 'genreIds',
|
||||||
HAS_RATING = 'hasRating',
|
HAS_RATING = 'hasRating',
|
||||||
MAX_YEAR = 'maxYear',
|
MAX_YEAR = 'maxYear',
|
||||||
MIN_YEAR = 'minYear',
|
MIN_YEAR = 'minYear',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
|
import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
|
||||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
@@ -65,7 +65,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
|
|||||||
defaultSortOrder={SortOrder.ASC}
|
defaultSortOrder={SortOrder.ASC}
|
||||||
listKey={ItemListKey.SONG}
|
listKey={ItemListKey.SONG}
|
||||||
/>
|
/>
|
||||||
<ListFilters itemType={LibraryItem.SONG} />
|
<ListFiltersModal itemType={LibraryItem.SONG} />
|
||||||
<ListRefreshButton listKey={ItemListKey.SONG} />
|
<ListRefreshButton listKey={ItemListKey.SONG} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export const Select = ({
|
|||||||
section: styles.section,
|
section: styles.section,
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
clearable={false}
|
clearable={clearable}
|
||||||
|
spellCheck={false}
|
||||||
style={{ maxWidth, width }}
|
style={{ maxWidth, width }}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
withCheckIcon={false}
|
withCheckIcon={false}
|
||||||
|
|||||||
Reference in New Issue
Block a user