mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
conditionally disable Subsonic list filters based on availability (#1567)
This commit is contained in:
@@ -1599,6 +1599,76 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
return (res.body.starred?.song || []).length || 0;
|
return (res.body.starred?.song || []).length || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const artistIds = query.albumArtistIds || query.artistIds;
|
||||||
|
|
||||||
|
if (query.albumIds || artistIds) {
|
||||||
|
const fromAlbumPromises: Promise<ServerInferResponses<typeof contract.getAlbum>>[] = [];
|
||||||
|
const artistDetailPromises: Promise<ServerInferResponses<typeof contract.getArtist>>[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (query.albumIds) {
|
||||||
|
for (const albumId of query.albumIds) {
|
||||||
|
fromAlbumPromises.push(
|
||||||
|
ssApiClient(apiClientProps).getAlbum({
|
||||||
|
query: {
|
||||||
|
id: albumId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistIds) {
|
||||||
|
for (const artistId of artistIds) {
|
||||||
|
artistDetailPromises.push(
|
||||||
|
ssApiClient(apiClientProps).getArtist({
|
||||||
|
query: {
|
||||||
|
id: artistId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistResult = await Promise.all(artistDetailPromises);
|
||||||
|
|
||||||
|
const albums = artistResult.flatMap((artist) => {
|
||||||
|
if (artist.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return artist.body.artist.album ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumIds = albums.map((album) => album.id);
|
||||||
|
|
||||||
|
for (const albumId of albumIds) {
|
||||||
|
fromAlbumPromises.push(
|
||||||
|
ssApiClient(apiClientProps).getAlbum({
|
||||||
|
query: {
|
||||||
|
id: albumId.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: z.infer<typeof ssType._response.song>[] = [];
|
||||||
|
|
||||||
|
if (fromAlbumPromises.length > 0) {
|
||||||
|
const albumsResult = await Promise.all(fromAlbumPromises);
|
||||||
|
|
||||||
|
results = albumsResult.flatMap((album) => {
|
||||||
|
if (album.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return album.body.album.song;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.length;
|
||||||
|
}
|
||||||
|
|
||||||
let totalRecordCount = 0;
|
let totalRecordCount = 0;
|
||||||
|
|
||||||
// Rather than just do `search3` by groups of 500, instead
|
// Rather than just do `search3` by groups of 500, instead
|
||||||
|
|||||||
@@ -74,11 +74,22 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
}));
|
}));
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
const hasFavorite = query.favorite === true;
|
||||||
|
const hasArtist = query.artistIds && query.artistIds.length > 0;
|
||||||
|
const hasGenre = query.genreIds && query.genreIds.length > 0;
|
||||||
|
const hasYear = query.minYear !== undefined || query.maxYear !== undefined;
|
||||||
|
|
||||||
|
const isFavoriteDisabled = hasArtist || hasGenre || hasYear;
|
||||||
|
const isArtistDisabled = hasFavorite || hasGenre || hasYear;
|
||||||
|
const isGenreDisabled = hasFavorite || hasArtist || hasYear;
|
||||||
|
const isYearDisabled = hasFavorite || hasArtist || hasGenre;
|
||||||
|
|
||||||
const handleAlbumArtistFilter = useCallback(
|
const handleAlbumArtistFilter = useCallback(
|
||||||
(e: null | string[]) => {
|
(e: null | string[]) => {
|
||||||
|
if (isArtistDisabled && e !== null) return;
|
||||||
setAlbumArtist(e ?? null);
|
setAlbumArtist(e ?? null);
|
||||||
},
|
},
|
||||||
[setAlbumArtist],
|
[isArtistDisabled, setAlbumArtist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const genreListQuery = useGenreList();
|
const genreListQuery = useGenreList();
|
||||||
@@ -97,13 +108,14 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
|
|
||||||
const handleGenresFilter = useCallback(
|
const handleGenresFilter = useCallback(
|
||||||
(e: null | string[]) => {
|
(e: null | string[]) => {
|
||||||
|
if (isGenreDisabled && e !== null && e.length > 0) return; // Prevent setting if disabled
|
||||||
if (e && e.length > 0) {
|
if (e && e.length > 0) {
|
||||||
setGenreId([e[0]]);
|
setGenreId([e[0]]);
|
||||||
} else {
|
} else {
|
||||||
setGenreId(null);
|
setGenreId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setGenreId],
|
[isGenreDisabled, setGenreId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const genreFilterLabel = useMemo(() => {
|
const genreFilterLabel = useMemo(() => {
|
||||||
@@ -119,17 +131,23 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
{
|
{
|
||||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (isFavoriteDisabled && e.target.checked) return; // Prevent setting if disabled
|
||||||
const favoriteValue = e.target.checked ? true : undefined;
|
const favoriteValue = e.target.checked ? true : undefined;
|
||||||
setFavorite(favoriteValue ?? null);
|
setFavorite(favoriteValue ?? null);
|
||||||
},
|
},
|
||||||
value: query.favorite,
|
value: query.favorite,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, query.favorite, setFavorite],
|
[isFavoriteDisabled, query.favorite, setFavorite, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMinYearFilter = useMemo(
|
const handleMinYearFilter = useMemo(
|
||||||
() => (e: number | string) => {
|
() => (e: number | string) => {
|
||||||
|
if (isYearDisabled) {
|
||||||
|
const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e));
|
||||||
|
if (!isEmpty) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle empty string, null, undefined, or invalid numbers as clearing
|
// Handle empty string, null, undefined, or invalid numbers as clearing
|
||||||
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
|
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
|
||||||
setMinYear(null);
|
setMinYear(null);
|
||||||
@@ -144,11 +162,16 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
setMinYear(null);
|
setMinYear(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setMinYear],
|
[isYearDisabled, setMinYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMaxYearFilter = useMemo(
|
const handleMaxYearFilter = useMemo(
|
||||||
() => (e: number | string) => {
|
() => (e: number | string) => {
|
||||||
|
if (isYearDisabled) {
|
||||||
|
const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e));
|
||||||
|
if (!isEmpty) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle empty string, null, undefined, or invalid numbers as clearing
|
// Handle empty string, null, undefined, or invalid numbers as clearing
|
||||||
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
|
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
|
||||||
setMaxYear(null);
|
setMaxYear(null);
|
||||||
@@ -163,7 +186,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
setMaxYear(null);
|
setMaxYear(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setMaxYear],
|
[isYearDisabled, setMaxYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
||||||
@@ -203,26 +226,32 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
value: 'multi',
|
value: 'multi',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
disabled={isArtistDisabled}
|
||||||
onChange={handleArtistSelectModeChange}
|
onChange={handleArtistSelectModeChange}
|
||||||
size="xs"
|
size="xs"
|
||||||
value={artistSelectMode}
|
value={artistSelectMode}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}, [artistSelectMode, handleArtistSelectModeChange, t]);
|
}, [artistSelectMode, handleArtistSelectModeChange, isArtistDisabled, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="md" py="md">
|
<Stack px="md" py="md">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
||||||
<Text>{filter.label}</Text>
|
<Text>{filter.label}</Text>
|
||||||
<Switch checked={filter.value ?? false} onChange={filter.onChange} />
|
<Switch
|
||||||
|
checked={filter.value ?? false}
|
||||||
|
disabled={isFavoriteDisabled}
|
||||||
|
onChange={filter.onChange}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
{!disableArtistFilter && (
|
{!disableArtistFilter && (
|
||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<VirtualMultiSelect
|
<VirtualMultiSelect
|
||||||
|
disabled={isArtistDisabled}
|
||||||
displayCountType="album"
|
displayCountType="album"
|
||||||
height={300}
|
height={300}
|
||||||
label={artistFilterLabel}
|
label={artistFilterLabel}
|
||||||
@@ -238,6 +267,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<VirtualMultiSelect
|
<VirtualMultiSelect
|
||||||
|
disabled={isGenreDisabled}
|
||||||
displayCountType="album"
|
displayCountType="album"
|
||||||
height={220}
|
height={220}
|
||||||
label={genreFilterLabel}
|
label={genreFilterLabel}
|
||||||
@@ -252,7 +282,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={Boolean(query.genreIds && query.genreIds.length > 0)}
|
disabled={isYearDisabled}
|
||||||
hideControls={false}
|
hideControls={false}
|
||||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||||
max={5000}
|
max={5000}
|
||||||
@@ -261,7 +291,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
value={query.minYear ?? undefined}
|
value={query.minYear ?? undefined}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={Boolean(query.genreIds && query.genreIds.length > 0)}
|
disabled={isYearDisabled}
|
||||||
hideControls={false}
|
hideControls={false}
|
||||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||||
max={5000}
|
max={5000}
|
||||||
|
|||||||
@@ -27,3 +27,13 @@
|
|||||||
.row[data-focused='true'] {
|
.row[data-focused='true'] {
|
||||||
border: 1px solid var(--theme-colors-primary);
|
border: 1px solid var(--theme-colors-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.disabled:hover {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export function ArtistMultiSelectRow({
|
export function ArtistMultiSelectRow({
|
||||||
|
disabled = false,
|
||||||
displayCountType = 'album',
|
displayCountType = 'album',
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
index,
|
index,
|
||||||
@@ -18,6 +19,7 @@ export function ArtistMultiSelectRow({
|
|||||||
options,
|
options,
|
||||||
style,
|
style,
|
||||||
}: RowComponentProps<{
|
}: RowComponentProps<{
|
||||||
|
disabled?: boolean;
|
||||||
displayCountType?: 'album' | 'song';
|
displayCountType?: 'album' | 'song';
|
||||||
focusedIndex: null | number;
|
focusedIndex: null | number;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
@@ -41,11 +43,11 @@ export function ArtistMultiSelectRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
className={styles.row}
|
className={`${styles.row} ${disabled ? styles.disabled : ''}`}
|
||||||
gap="sm"
|
gap="sm"
|
||||||
onClick={handleClick}
|
onClick={disabled ? undefined : handleClick}
|
||||||
style={{ ...style }}
|
style={{ ...style }}
|
||||||
{...(isFocused && { 'data-focused': true })}
|
{...(isFocused && !disabled && { 'data-focused': true })}
|
||||||
>
|
>
|
||||||
<ItemImage
|
<ItemImage
|
||||||
containerClassName={styles.rowImage}
|
containerClassName={styles.rowImage}
|
||||||
@@ -70,6 +72,7 @@ export function ArtistMultiSelectRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GenreMultiSelectRow({
|
export function GenreMultiSelectRow({
|
||||||
|
disabled = false,
|
||||||
displayCountType = 'album',
|
displayCountType = 'album',
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
index,
|
index,
|
||||||
@@ -77,6 +80,7 @@ export function GenreMultiSelectRow({
|
|||||||
options,
|
options,
|
||||||
style,
|
style,
|
||||||
}: RowComponentProps<{
|
}: RowComponentProps<{
|
||||||
|
disabled?: boolean;
|
||||||
displayCountType?: 'album' | 'song';
|
displayCountType?: 'album' | 'song';
|
||||||
focusedIndex: null | number;
|
focusedIndex: null | number;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
@@ -99,11 +103,11 @@ export function GenreMultiSelectRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
className={styles.row}
|
className={`${styles.row} ${disabled ? styles.disabled : ''}`}
|
||||||
gap="sm"
|
gap="sm"
|
||||||
onClick={handleClick}
|
onClick={disabled ? undefined : handleClick}
|
||||||
style={{ ...style }}
|
style={{ ...style }}
|
||||||
{...(isFocused && { 'data-focused': true })}
|
{...(isFocused && !disabled && { 'data-focused': true })}
|
||||||
>
|
>
|
||||||
<div className={styles.rowContent}>
|
<div className={styles.rowContent}>
|
||||||
<Text isNoSelect overflow="hidden" size="sm">
|
<Text isNoSelect overflow="hidden" size="sm">
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { ChangeEvent, useCallback, useMemo } from 'react';
|
import { ChangeEvent, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
|
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 { GenreMultiSelectRow } from '/@/renderer/features/shared/components/multi-select-rows';
|
import {
|
||||||
|
ArtistMultiSelectRow,
|
||||||
|
GenreMultiSelectRow,
|
||||||
|
} from '/@/renderer/features/shared/components/multi-select-rows';
|
||||||
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||||
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
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';
|
||||||
@@ -12,10 +19,12 @@ import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-mu
|
|||||||
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 { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const SubsonicSongFilters = () => {
|
export const SubsonicSongFilters = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { clear, query, setFavorite, setGenreId } = useSongListFilters();
|
const serverId = useCurrentServerId();
|
||||||
|
const { clear, query, setArtistIds, setFavorite, setGenreId } = useSongListFilters();
|
||||||
|
|
||||||
const { customFilters } = useListContext();
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
@@ -35,15 +44,75 @@ export const SubsonicSongFilters = () => {
|
|||||||
|
|
||||||
const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
|
const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
|
||||||
|
|
||||||
|
const albumArtistListQuery = useSuspenseQuery(
|
||||||
|
artistsQueries.albumArtistList({
|
||||||
|
options: {
|
||||||
|
gcTime: 1000 * 60 * 2,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
sortBy: AlbumArtistListSort.NAME,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
startIndex: 0,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = albumArtistListQuery?.data?.items;
|
||||||
|
|
||||||
|
const selectableAlbumArtists = useMemo(() => {
|
||||||
|
if (!items) return [];
|
||||||
|
|
||||||
|
return items.map((artist) => ({
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
imageUrl: getItemImageUrl({
|
||||||
|
id: artist.id,
|
||||||
|
itemType: LibraryItem.ARTIST,
|
||||||
|
type: 'table',
|
||||||
|
}),
|
||||||
|
label: artist.name,
|
||||||
|
songCount: artist.songCount,
|
||||||
|
value: artist.id,
|
||||||
|
}));
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);
|
||||||
|
|
||||||
|
const hasFavorite = query.favorite === true;
|
||||||
|
const hasArtist = query.artistIds && query.artistIds.length > 0;
|
||||||
|
const hasGenre = query.genreIds && query.genreIds.length > 0;
|
||||||
|
|
||||||
|
const isFavoriteDisabled = hasArtist || hasGenre;
|
||||||
|
const isArtistDisabled = hasFavorite || hasGenre;
|
||||||
|
const isGenreDisabled = hasFavorite || hasArtist;
|
||||||
|
|
||||||
|
const handleArtistFilter = useCallback(
|
||||||
|
(e: null | string[]) => {
|
||||||
|
if (isArtistDisabled && e !== null) return;
|
||||||
|
setArtistIds(e ?? null);
|
||||||
|
},
|
||||||
|
[isArtistDisabled, setArtistIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const artistFilterLabel = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const handleGenresFilter = useCallback(
|
const handleGenresFilter = useCallback(
|
||||||
(e: null | string[]) => {
|
(e: null | string[]) => {
|
||||||
|
if (isGenreDisabled && e !== null && e.length > 0) return;
|
||||||
if (e && e.length > 0) {
|
if (e && e.length > 0) {
|
||||||
setGenreId([e[0]]);
|
setGenreId([e[0]]);
|
||||||
} else {
|
} else {
|
||||||
setGenreId(null);
|
setGenreId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setGenreId],
|
[isGenreDisabled, setGenreId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const genreFilterLabel = useMemo(() => {
|
const genreFilterLabel = useMemo(() => {
|
||||||
@@ -59,13 +128,14 @@ export const SubsonicSongFilters = () => {
|
|||||||
{
|
{
|
||||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (isFavoriteDisabled && e.target.checked) return;
|
||||||
const favoriteValue = e.target.checked ? true : undefined;
|
const favoriteValue = e.target.checked ? true : undefined;
|
||||||
setFavorite(favoriteValue ?? null);
|
setFavorite(favoriteValue ?? null);
|
||||||
},
|
},
|
||||||
value: query.favorite,
|
value: query.favorite,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, query.favorite, setFavorite],
|
[isFavoriteDisabled, query.favorite, setFavorite, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,13 +143,30 @@ export const SubsonicSongFilters = () => {
|
|||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
||||||
<Text>{filter.label}</Text>
|
<Text>{filter.label}</Text>
|
||||||
<Switch checked={filter.value ?? false} onChange={filter.onChange} />
|
<Switch
|
||||||
|
checked={filter.value ?? false}
|
||||||
|
disabled={isFavoriteDisabled}
|
||||||
|
onChange={filter.onChange}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
disabled={isArtistDisabled}
|
||||||
|
displayCountType="song"
|
||||||
|
height={300}
|
||||||
|
label={artistFilterLabel}
|
||||||
|
onChange={handleArtistFilter}
|
||||||
|
options={selectableAlbumArtists}
|
||||||
|
RowComponent={ArtistMultiSelectRow}
|
||||||
|
singleSelect={true}
|
||||||
|
value={selectedArtistIds}
|
||||||
|
/>
|
||||||
{!isGenrePage && (
|
{!isGenrePage && (
|
||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<VirtualMultiSelect
|
<VirtualMultiSelect
|
||||||
|
disabled={isGenreDisabled}
|
||||||
displayCountType="song"
|
displayCountType="song"
|
||||||
height={220}
|
height={220}
|
||||||
label={genreFilterLabel}
|
label={genreFilterLabel}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.list-container {
|
.list-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -11,6 +16,10 @@
|
|||||||
background-color: var(--theme-colors-surface);
|
background-color: var(--theme-colors-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container.disabled .list-container {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
border-bottom: 1px solid var(--theme-colors-border);
|
border-bottom: 1px solid var(--theme-colors-border);
|
||||||
}
|
}
|
||||||
@@ -23,3 +32,11 @@
|
|||||||
.selected-option:hover {
|
.selected-option:hover {
|
||||||
background-color: alpha(var(--theme-colors-surface), 0.6);
|
background-color: alpha(var(--theme-colors-surface), 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-option.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-option.disabled:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
export type VirtualMultiSelectOption<T> = T & { label: string; value: string };
|
export type VirtualMultiSelectOption<T> = T & { label: string; value: string };
|
||||||
|
|
||||||
interface VirtualMultiSelectProps<T> {
|
interface VirtualMultiSelectProps<T> {
|
||||||
|
disabled?: boolean;
|
||||||
displayCountType?: 'album' | 'song';
|
displayCountType?: 'album' | 'song';
|
||||||
height: number;
|
height: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -25,6 +26,7 @@ interface VirtualMultiSelectProps<T> {
|
|||||||
options: VirtualMultiSelectOption<T>[];
|
options: VirtualMultiSelectOption<T>[];
|
||||||
RowComponent: (
|
RowComponent: (
|
||||||
props: RowComponentProps<{
|
props: RowComponentProps<{
|
||||||
|
disabled?: boolean;
|
||||||
displayCountType?: 'album' | 'song';
|
displayCountType?: 'album' | 'song';
|
||||||
focusedIndex: null | number;
|
focusedIndex: null | number;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
@@ -37,6 +39,7 @@ interface VirtualMultiSelectProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VirtualMultiSelect<T>({
|
export function VirtualMultiSelect<T>({
|
||||||
|
disabled = false,
|
||||||
displayCountType = 'album',
|
displayCountType = 'album',
|
||||||
height,
|
height,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -105,6 +108,7 @@ export function VirtualMultiSelect<T>({
|
|||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
(optionValue: string) => {
|
(optionValue: string) => {
|
||||||
|
if (disabled) return;
|
||||||
if (value.includes(optionValue)) {
|
if (value.includes(optionValue)) {
|
||||||
const newValue = value.filter((v) => v !== optionValue);
|
const newValue = value.filter((v) => v !== optionValue);
|
||||||
onChange(newValue.length > 0 ? newValue : null);
|
onChange(newValue.length > 0 ? newValue : null);
|
||||||
@@ -112,15 +116,16 @@ export function VirtualMultiSelect<T>({
|
|||||||
onChange(singleSelect ? [optionValue] : [...value, optionValue]);
|
onChange(singleSelect ? [optionValue] : [...value, optionValue]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange, singleSelect, value],
|
[disabled, onChange, singleSelect, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeselect = useCallback(
|
const handleDeselect = useCallback(
|
||||||
(optionValue: string) => {
|
(optionValue: string) => {
|
||||||
|
if (disabled) return;
|
||||||
const newValue = value.filter((v) => v !== optionValue);
|
const newValue = value.filter((v) => v !== optionValue);
|
||||||
onChange(newValue.length > 0 ? newValue : null);
|
onChange(newValue.length > 0 ? newValue : null);
|
||||||
},
|
},
|
||||||
[onChange, value],
|
[disabled, onChange, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeholder = useMemo(
|
const placeholder = useMemo(
|
||||||
@@ -147,7 +152,7 @@ export function VirtualMultiSelect<T>({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (stableOptions.length === 0) return;
|
if (disabled || stableOptions.length === 0) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
@@ -186,16 +191,17 @@ export function VirtualMultiSelect<T>({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusedIndex, handleToggle, scrollToIndex, stableOptions],
|
[disabled, focusedIndex, handleToggle, scrollToIndex, stableOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={`${styles.container} ${disabled ? styles.disabled : ''}`}>
|
||||||
<TextInput
|
<TextInput
|
||||||
className={styles['search-input']}
|
className={styles['search-input']}
|
||||||
|
disabled={disabled}
|
||||||
label={labelWithClear}
|
label={labelWithClear}
|
||||||
leftSection={
|
leftSection={
|
||||||
value.length > 0 ? (
|
value.length > 0 && !disabled ? (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="x"
|
icon="x"
|
||||||
iconProps={{ size: 'md' }}
|
iconProps={{ size: 'md' }}
|
||||||
@@ -208,11 +214,15 @@ export function VirtualMultiSelect<T>({
|
|||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
if (!disabled) {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rightSection={
|
rightSection={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
{search ? (
|
{search && !disabled ? (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="x"
|
icon="x"
|
||||||
iconProps={{ size: 'md' }}
|
iconProps={{ size: 'md' }}
|
||||||
@@ -225,13 +235,19 @@ export function VirtualMultiSelect<T>({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
styles={{ label: { width: '100%' } }}
|
styles={{
|
||||||
|
input: disabled ? { opacity: 0.6 } : undefined,
|
||||||
|
label: { width: '100%' },
|
||||||
|
section: disabled ? { opacity: 0.6 } : undefined,
|
||||||
|
wrapper: disabled ? { opacity: 0.6 } : undefined,
|
||||||
|
}}
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={styles['list-container']}
|
className={styles['list-container']}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
const element = e.currentTarget as HTMLDivElement;
|
const element = e.currentTarget as HTMLDivElement;
|
||||||
if (element.focus) {
|
if (element.focus) {
|
||||||
element.focus({ preventScroll: true });
|
element.focus({ preventScroll: true });
|
||||||
@@ -239,7 +255,7 @@ export function VirtualMultiSelect<T>({
|
|||||||
}}
|
}}
|
||||||
ref={listContainerRef}
|
ref={listContainerRef}
|
||||||
style={{ height: `${height}px` }}
|
style={{ height: `${height}px` }}
|
||||||
tabIndex={0}
|
tabIndex={disabled ? -1 : 0}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -258,6 +274,7 @@ export function VirtualMultiSelect<T>({
|
|||||||
rowCount={stableOptions.length}
|
rowCount={stableOptions.length}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
rowProps={{
|
rowProps={{
|
||||||
|
disabled,
|
||||||
displayCountType,
|
displayCountType,
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
onToggle: handleToggle,
|
onToggle: handleToggle,
|
||||||
@@ -271,19 +288,21 @@ export function VirtualMultiSelect<T>({
|
|||||||
<Stack gap="xs" mt="sm">
|
<Stack gap="xs" mt="sm">
|
||||||
{selectedOptions.map((option) => (
|
{selectedOptions.map((option) => (
|
||||||
<Group
|
<Group
|
||||||
className={styles['selected-option']}
|
className={`${styles['selected-option']} ${disabled ? styles.disabled : ''}`}
|
||||||
gap="sm"
|
gap="sm"
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => handleDeselect(option.value)}
|
onClick={() => handleDeselect(option.value)}
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
>
|
>
|
||||||
<ActionIcon
|
{!disabled && (
|
||||||
icon="minus"
|
<ActionIcon
|
||||||
iconProps={{ size: 'sm' }}
|
icon="minus"
|
||||||
size="xs"
|
iconProps={{ size: 'sm' }}
|
||||||
stopsPropagation
|
size="xs"
|
||||||
variant="transparent"
|
stopsPropagation
|
||||||
/>
|
variant="transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text isNoSelect overflow="hidden" size="sm">
|
<Text isNoSelect overflow="hidden" size="sm">
|
||||||
{option.label}
|
{option.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user