mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Lint all files
This commit is contained in:
parent
22af76b4d6
commit
30e52ebb54
334 changed files with 76519 additions and 75932 deletions
|
|
@ -12,211 +12,212 @@ import { queryClient } from '/@/renderer/lib/react-query';
|
|||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const AddToPlaylistContextModal = ({
|
||||
id,
|
||||
innerProps,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<{
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
songId?: string[];
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
songId?: string[];
|
||||
}>) => {
|
||||
const { albumId, artistId, songId } = innerProps;
|
||||
const server = useCurrentServer();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { albumId, artistId, songId } = innerProps;
|
||||
const server = useCurrentServer();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const addToPlaylistMutation = useAddToPlaylist({});
|
||||
const addToPlaylistMutation = useAddToPlaylist({});
|
||||
|
||||
const playlistList = usePlaylistList({
|
||||
query: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
smart: false,
|
||||
const playlistList = usePlaylistList({
|
||||
query: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
smart: false,
|
||||
},
|
||||
},
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
},
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const playlistSelect = useMemo(() => {
|
||||
return (
|
||||
playlistList.data?.items?.map((playlist) => ({
|
||||
label: playlist.name,
|
||||
value: playlist.id,
|
||||
})) || []
|
||||
);
|
||||
}, [playlistList.data]);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
playlistId: [],
|
||||
skipDuplicates: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getSongsByAlbum = async (albumId: string) => {
|
||||
const query: SongListQuery = {
|
||||
albumIds: [albumId],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
return songsRes;
|
||||
};
|
||||
const playlistSelect = useMemo(() => {
|
||||
return (
|
||||
playlistList.data?.items?.map((playlist) => ({
|
||||
label: playlist.name,
|
||||
value: playlist.id,
|
||||
})) || []
|
||||
);
|
||||
}, [playlistList.data]);
|
||||
|
||||
const getSongsByArtist = async (artistId: string) => {
|
||||
const query: SongListQuery = {
|
||||
artistIds: [artistId],
|
||||
sortBy: SongListSort.ARTIST,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
playlistId: [],
|
||||
skipDuplicates: true,
|
||||
},
|
||||
});
|
||||
|
||||
return songsRes;
|
||||
};
|
||||
|
||||
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsLoading(true);
|
||||
const allSongIds: string[] = [];
|
||||
const uniqueSongIds: string[] = [];
|
||||
|
||||
if (albumId && albumId.length > 0) {
|
||||
for (const id of albumId) {
|
||||
const songs = await getSongsByAlbum(id);
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
}
|
||||
|
||||
if (artistId && artistId.length > 0) {
|
||||
for (const id of artistId) {
|
||||
const songs = await getSongsByArtist(id);
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
}
|
||||
|
||||
if (songId && songId.length > 0) {
|
||||
allSongIds.push(...songId);
|
||||
}
|
||||
|
||||
for (const playlistId of values.playlistId) {
|
||||
if (values.skipDuplicates) {
|
||||
const query = {
|
||||
id: playlistId,
|
||||
startIndex: 0,
|
||||
const getSongsByAlbum = async (albumId: string) => {
|
||||
const query: SongListQuery = {
|
||||
albumIds: [albumId],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: { id: playlistId, startIndex: 0 },
|
||||
});
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||
});
|
||||
|
||||
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
|
||||
return songsRes;
|
||||
};
|
||||
|
||||
for (const songId of allSongIds) {
|
||||
if (!playlistSongIds?.includes(songId)) {
|
||||
uniqueSongIds.push(songId);
|
||||
}
|
||||
const getSongsByArtist = async (artistId: string) => {
|
||||
const query: SongListQuery = {
|
||||
artistIds: [artistId],
|
||||
sortBy: SongListSort.ARTIST,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||
});
|
||||
|
||||
return songsRes;
|
||||
};
|
||||
|
||||
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsLoading(true);
|
||||
const allSongIds: string[] = [];
|
||||
const uniqueSongIds: string[] = [];
|
||||
|
||||
if (albumId && albumId.length > 0) {
|
||||
for (const id of albumId) {
|
||||
const songs = await getSongsByAlbum(id);
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||
if (!server) return null;
|
||||
addToPlaylistMutation.mutate(
|
||||
{
|
||||
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||
query: { id: playlistId },
|
||||
serverId: server?.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: `[${
|
||||
playlistSelect.find((playlist) => playlist.value === playlistId)?.label
|
||||
}] ${err.message}`,
|
||||
title: 'Failed to add songs to playlist',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (artistId && artistId.length > 0) {
|
||||
for (const id of artistId) {
|
||||
const songs = await getSongsByArtist(id);
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
toast.success({
|
||||
message: `Added ${
|
||||
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
|
||||
} songs to ${values.playlistId.length} playlist(s)`,
|
||||
if (songId && songId.length > 0) {
|
||||
allSongIds.push(...songId);
|
||||
}
|
||||
|
||||
for (const playlistId of values.playlistId) {
|
||||
if (values.skipDuplicates) {
|
||||
const query = {
|
||||
id: playlistId,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||
|
||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: { id: playlistId, startIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
|
||||
|
||||
for (const songId of allSongIds) {
|
||||
if (!playlistSongIds?.includes(songId)) {
|
||||
uniqueSongIds.push(songId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||
if (!server) return null;
|
||||
addToPlaylistMutation.mutate(
|
||||
{
|
||||
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||
query: { id: playlistId },
|
||||
serverId: server?.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: `[${
|
||||
playlistSelect.find((playlist) => playlist.value === playlistId)
|
||||
?.label
|
||||
}] ${err.message}`,
|
||||
title: 'Failed to add songs to playlist',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
toast.success({
|
||||
message: `Added ${
|
||||
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
|
||||
} songs to ${values.playlistId.length} playlist(s)`,
|
||||
});
|
||||
closeModal(id);
|
||||
return null;
|
||||
});
|
||||
closeModal(id);
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box p="1rem">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={playlistSelect}
|
||||
disabled={playlistList.isLoading}
|
||||
label="Playlists"
|
||||
size="md"
|
||||
{...form.getInputProps('playlistId')}
|
||||
/>
|
||||
<Switch
|
||||
label="Skip duplicates"
|
||||
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Group>
|
||||
<Button
|
||||
disabled={addToPlaylistMutation.isLoading}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={() => closeModal(id)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
size="md"
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box p="1rem">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={playlistSelect}
|
||||
disabled={playlistList.isLoading}
|
||||
label="Playlists"
|
||||
size="md"
|
||||
{...form.getInputProps('playlistId')}
|
||||
/>
|
||||
<Switch
|
||||
label="Skip duplicates"
|
||||
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Group>
|
||||
<Button
|
||||
disabled={addToPlaylistMutation.isLoading}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={() => closeModal(id)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
size="md"
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,140 +4,142 @@ import { useRef, useState } from 'react';
|
|||
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/renderer/api/types';
|
||||
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
|
||||
import {
|
||||
PlaylistQueryBuilder,
|
||||
PlaylistQueryBuilderRef,
|
||||
PlaylistQueryBuilder,
|
||||
PlaylistQueryBuilderRef,
|
||||
} from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface CreatePlaylistFormProps {
|
||||
onCancel: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
||||
|
||||
const form = useForm<CreatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
},
|
||||
},
|
||||
comment: '',
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
if (isSmartPlaylist) {
|
||||
values._custom!.navidrome = {
|
||||
...values._custom?.navidrome,
|
||||
rules: queryBuilderRef.current?.getFilters(),
|
||||
};
|
||||
}
|
||||
|
||||
const smartPlaylist = queryBuilderRef.current?.getFilters();
|
||||
|
||||
if (!server) return;
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
body: {
|
||||
...values,
|
||||
_custom: {
|
||||
navidrome: {
|
||||
...values._custom?.navidrome,
|
||||
rules:
|
||||
isSmartPlaylist && smartPlaylist?.filters
|
||||
? {
|
||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
||||
...smartPlaylist.extraFilters,
|
||||
}
|
||||
: undefined,
|
||||
const form = useForm<CreatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
comment: '',
|
||||
name: '',
|
||||
},
|
||||
serverId: server.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: `Playlist has been created` });
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
});
|
||||
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
if (isSmartPlaylist) {
|
||||
values._custom!.navidrome = {
|
||||
...values._custom?.navidrome,
|
||||
rules: queryBuilderRef.current?.getFilters(),
|
||||
};
|
||||
}
|
||||
|
||||
const smartPlaylist = queryBuilderRef.current?.getFilters();
|
||||
|
||||
if (!server) return;
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
body: {
|
||||
...values,
|
||||
_custom: {
|
||||
navidrome: {
|
||||
...values._custom?.navidrome,
|
||||
rules:
|
||||
isSmartPlaylist && smartPlaylist?.filters
|
||||
? {
|
||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
||||
...smartPlaylist.extraFilters,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverId: server.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: `Playlist has been created` });
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
<Group>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is public?"
|
||||
{...form.getInputProps('_custom.navidrome.public', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{server?.type === ServerType.NAVIDROME && (
|
||||
<Switch
|
||||
label="Is smart playlist?"
|
||||
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
|
||||
<Stack pt="1rem">
|
||||
<Text>Query Editor</Text>
|
||||
<PlaylistQueryBuilder
|
||||
ref={queryBuilderRef}
|
||||
isSaving={false}
|
||||
limit={undefined}
|
||||
query={undefined}
|
||||
sortBy={SongListSort.ALBUM}
|
||||
sortOrder="asc"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
<Group>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is public?"
|
||||
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
{server?.type === ServerType.NAVIDROME && (
|
||||
<Switch
|
||||
label="Is smart playlist?"
|
||||
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
|
||||
<Stack pt="1rem">
|
||||
<Text>Query Editor</Text>
|
||||
<PlaylistQueryBuilder
|
||||
ref={queryBuilderRef}
|
||||
isSaving={false}
|
||||
limit={undefined}
|
||||
query={undefined}
|
||||
sortBy={SongListSort.ALBUM}
|
||||
sortOrder="asc"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ import styled from 'styled-components';
|
|||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
|
||||
import {
|
||||
getColumnDefs,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
getColumnDefs,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components/virtual-table';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
|
|
@ -31,226 +31,232 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
|||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ag-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const page = useSongListStore();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const navigate = useNavigate();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const page = useSongListStore();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
|
||||
options: {
|
||||
cacheTime: 0,
|
||||
keepPreviousData: false,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 50,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const handleLoadMore = () => {
|
||||
playlistSongsQueryInfinite.fetchNextPage();
|
||||
};
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (detailQuery?.data?.rules) {
|
||||
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}
|
||||
|
||||
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}, [detailQuery?.data?.rules]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
|
||||
playlistId,
|
||||
});
|
||||
|
||||
const playlistSongData = useMemo(
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
|
||||
[playlistSongsQueryInfinite.data?.pages],
|
||||
);
|
||||
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = () => {
|
||||
deletePlaylistMutation.mutate(
|
||||
{ query: { id: playlistId }, serverId: server?.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
});
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
|
||||
options: {
|
||||
cacheTime: 0,
|
||||
keepPreviousData: false,
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `Playlist has been deleted`,
|
||||
});
|
||||
closeAllModals();
|
||||
navigate(AppRoute.PLAYLISTS);
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 50,
|
||||
startIndex: 0,
|
||||
},
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const handleLoadMore = () => {
|
||||
playlistSongsQueryInfinite.fetchNextPage();
|
||||
};
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs(page.table.columns).filter(
|
||||
(c) => c.colId !== 'album' && c.colId !== 'artist',
|
||||
),
|
||||
[page.table.columns],
|
||||
);
|
||||
};
|
||||
|
||||
const openDeletePlaylist = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
loading={deletePlaylistMutation.isLoading}
|
||||
onConfirm={handleDeletePlaylist}
|
||||
>
|
||||
Are you sure you want to delete this playlist?
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist',
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (detailQuery?.data?.rules) {
|
||||
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}
|
||||
|
||||
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}, [detailQuery?.data?.rules]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
|
||||
playlistId,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
const playlistSongData = useMemo(
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
|
||||
[playlistSongsQueryInfinite.data?.pages],
|
||||
);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
|
||||
const handleDeletePlaylist = () => {
|
||||
deletePlaylistMutation.mutate(
|
||||
{ query: { id: playlistId }, serverId: server?.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `Playlist has been deleted`,
|
||||
});
|
||||
closeAllModals();
|
||||
navigate(AppRoute.PLAYLISTS);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Group
|
||||
ref={intersectRef}
|
||||
p="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={() => handlePlay(type.play)}
|
||||
const openDeletePlaylist = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
loading={deletePlaylistMutation.isLoading}
|
||||
onConfirm={handleDeletePlaylist}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
if (!detailQuery.data || !server) return;
|
||||
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
|
||||
}}
|
||||
>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={openDeletePlaylist}>Delete playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
|
||||
variant="subtle"
|
||||
>
|
||||
View full playlist
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box ref={tableContainerRef}>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => {
|
||||
// It's possible that there are duplicate song ids in a playlist
|
||||
return `${data.data.id}-${data.data.pageIndex}`;
|
||||
}}
|
||||
rowData={playlistSongData}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
<MotionGroup
|
||||
p="2rem"
|
||||
position="center"
|
||||
onViewportEnter={handleLoadMore}
|
||||
>
|
||||
<Button
|
||||
ref={loadMoreRef}
|
||||
compact
|
||||
disabled={!playlistSongsQueryInfinite.hasNextPage}
|
||||
loading={playlistSongsQueryInfinite.isFetchingNextPage}
|
||||
variant="subtle"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
|
||||
</Button>
|
||||
</MotionGroup>
|
||||
</ContentContainer>
|
||||
);
|
||||
Are you sure you want to delete this playlist?
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist',
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Group
|
||||
ref={intersectRef}
|
||||
p="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map(
|
||||
(type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={() => handlePlay(type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
),
|
||||
)}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
if (!detailQuery.data || !server) return;
|
||||
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
|
||||
}}
|
||||
>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={openDeletePlaylist}>
|
||||
Delete playlist
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
|
||||
variant="subtle"
|
||||
>
|
||||
View full playlist
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box ref={tableContainerRef}>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => {
|
||||
// It's possible that there are duplicate song ids in a playlist
|
||||
return `${data.data.id}-${data.data.pageIndex}`;
|
||||
}}
|
||||
rowData={playlistSongData}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
<MotionGroup
|
||||
p="2rem"
|
||||
position="center"
|
||||
onViewportEnter={handleLoadMore}
|
||||
>
|
||||
<Button
|
||||
ref={loadMoreRef}
|
||||
compact
|
||||
disabled={!playlistSongsQueryInfinite.hasNextPage}
|
||||
loading={playlistSongsQueryInfinite.isFetchingNextPage}
|
||||
variant="subtle"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
|
||||
</Button>
|
||||
</MotionGroup>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,69 +10,70 @@ import { LibraryItem } from '/@/renderer/api/types';
|
|||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
background: string;
|
||||
imagePlaceholderUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
background: string;
|
||||
imagePlaceholderUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export const PlaylistDetailHeader = forwardRef(
|
||||
(
|
||||
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
(
|
||||
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount || 0} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount || 0} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imagePlaceholderUrl={imagePlaceholderUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack>
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{isSmartPlaylist && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Badge
|
||||
radius="sm"
|
||||
size="md"
|
||||
>
|
||||
Smart Playlist
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<Stack>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imagePlaceholderUrl={imagePlaceholderUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack>
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{isSmartPlaylist && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Badge
|
||||
radius="sm"
|
||||
size="md"
|
||||
>
|
||||
Smart Playlist
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
usePlaylistDetailTablePagination,
|
||||
useSetPlaylistDetailTable,
|
||||
useSetPlaylistDetailTablePagination,
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
usePlaylistDetailTablePagination,
|
||||
useSetPlaylistDetailTable,
|
||||
useSetPlaylistDetailTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -21,16 +21,16 @@ import { AnimatePresence } from 'framer-motion';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
QueueSong,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
QueueSong,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { useParams } from 'react-router';
|
||||
|
|
@ -42,230 +42,232 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr
|
|||
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
|
||||
return {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
|
||||
return {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
}, [page?.table.id, playlistId]);
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
const p = usePlaylistDetailTablePagination(playlistId);
|
||||
const pagination = {
|
||||
currentPage: p?.currentPage || 0,
|
||||
itemsPerPage: p?.itemsPerPage || 100,
|
||||
scrollOffset: p?.scrollOffset || 0,
|
||||
totalItems: p?.totalItems || 1,
|
||||
totalPages: p?.totalPages || 1,
|
||||
};
|
||||
}, [page?.table.id, playlistId]);
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const setPagination = useSetPlaylistDetailTablePagination();
|
||||
const setTable = useSetPlaylistDetailTable();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const p = usePlaylistDetailTablePagination(playlistId);
|
||||
const pagination = {
|
||||
currentPage: p?.currentPage || 0,
|
||||
itemsPerPage: p?.itemsPerPage || 100,
|
||||
scrollOffset: p?.scrollOffset || 0,
|
||||
totalItems: p?.totalItems || 1,
|
||||
totalPages: p?.totalPages || 1,
|
||||
};
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const setPagination = useSetPlaylistDetailTablePagination();
|
||||
const setTable = useSetPlaylistDetailTable();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const onPaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setPagination(playlistId, {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
});
|
||||
},
|
||||
[
|
||||
isPaginationEnabled,
|
||||
pagination.currentPage,
|
||||
pagination.itemsPerPage,
|
||||
playlistId,
|
||||
setPagination,
|
||||
],
|
||||
);
|
||||
|
||||
const handleColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||
|
||||
const handleScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setPagination(playlistId, { scrollOffset });
|
||||
};
|
||||
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (detailQuery?.data?.rules) {
|
||||
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}
|
||||
|
||||
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}, [detailQuery?.data?.rules]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
|
||||
playlistId,
|
||||
tableRef,
|
||||
});
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
serverId: server?.id,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
autoFitColumns={page.table.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={pagination.itemsPerPage || 100}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onGridReady={onGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey={playlistId}
|
||||
pagination={pagination}
|
||||
setIdPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const onPaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setPagination(playlistId, {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
});
|
||||
},
|
||||
[
|
||||
isPaginationEnabled,
|
||||
pagination.currentPage,
|
||||
pagination.itemsPerPage,
|
||||
playlistId,
|
||||
setPagination,
|
||||
],
|
||||
);
|
||||
|
||||
const handleColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find(
|
||||
(c) => c.column === column.getColDef().colId,
|
||||
);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||
|
||||
const handleScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setPagination(playlistId, { scrollOffset });
|
||||
};
|
||||
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (detailQuery?.data?.rules) {
|
||||
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}
|
||||
|
||||
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}, [detailQuery?.data?.rules]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
|
||||
playlistId,
|
||||
tableRef,
|
||||
});
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
autoFitColumns={page.table.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={pagination.itemsPerPage || 100}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onGridReady={onGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey={playlistId}
|
||||
pagination={pagination}
|
||||
setIdPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,40 +5,40 @@ import { Flex, Group, Stack } from '@mantine/core';
|
|||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
RiMoreFill,
|
||||
RiSettings3Fill,
|
||||
RiPlayFill,
|
||||
RiAddCircleFill,
|
||||
RiAddBoxFill,
|
||||
RiEditFill,
|
||||
RiDeleteBinFill,
|
||||
RiRefreshLine,
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
RiMoreFill,
|
||||
RiSettings3Fill,
|
||||
RiPlayFill,
|
||||
RiAddCircleFill,
|
||||
RiAddBoxFill,
|
||||
RiEditFill,
|
||||
RiDeleteBinFill,
|
||||
RiRefreshLine,
|
||||
} from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
Button,
|
||||
Slider,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
Text,
|
||||
ConfirmModal,
|
||||
toast,
|
||||
DropdownMenu,
|
||||
Button,
|
||||
Slider,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
Text,
|
||||
ConfirmModal,
|
||||
toast,
|
||||
} from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
SongListFilter,
|
||||
usePlaylistDetailStore,
|
||||
useSetPlaylistDetailFilters,
|
||||
useSetPlaylistDetailTable,
|
||||
useSetPlaylistStore,
|
||||
useSetPlaylistTablePagination,
|
||||
useCurrentServer,
|
||||
SongListFilter,
|
||||
usePlaylistDetailStore,
|
||||
useSetPlaylistDetailFilters,
|
||||
useSetPlaylistDetailTable,
|
||||
useSetPlaylistStore,
|
||||
useSetPlaylistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
|
|
@ -48,431 +48,446 @@ import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/componen
|
|||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Id', value: SongListSort.ID },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: 'Recently Played',
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Id', value: SongListSort.ID },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Added',
|
||||
value: SongListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Played',
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
handleToggleShowQueryBuilder: () => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
handleToggleShowQueryBuilder: () => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeaderFilters = ({
|
||||
tableRef,
|
||||
handleToggleShowQueryBuilder,
|
||||
tableRef,
|
||||
handleToggleShowQueryBuilder,
|
||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetPlaylistStore();
|
||||
const setFilter = useSetPlaylistDetailFilters();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetPlaylistStore();
|
||||
const setFilter = useSetPlaylistDetailFilters();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const isSmartPlaylist = detailQuery.data?.rules;
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const isSmartPlaylist = detailQuery.data?.rules;
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const cq = useContainerQuery();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const setTable = useSetPlaylistDetailTable();
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const setTable = useSetPlaylistDetailTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)
|
||||
?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
const handleItemSize = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[tableRef, page.display, server, playlistId, queryClient, setPagination],
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
|
||||
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
|
||||
};
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter(playlistId, {
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, playlistId, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ detail: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = useCallback(() => {
|
||||
if (!detailQuery.data) return;
|
||||
deletePlaylistMutation?.mutate(
|
||||
{ query: { id: detailQuery.data.id }, serverId: detailQuery.data.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `Playlist has been deleted`,
|
||||
});
|
||||
},
|
||||
},
|
||||
[tableRef, page.display, server, playlistId, queryClient, setPagination],
|
||||
);
|
||||
closeAllModals();
|
||||
}, [deletePlaylistMutation, detailQuery.data]);
|
||||
|
||||
const openDeletePlaylistModal = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
||||
<Text>Are you sure you want to delete this playlist?</Text>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist(s)',
|
||||
});
|
||||
};
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
|
||||
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter(playlistId, {
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, playlistId, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ detail: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = useCallback(() => {
|
||||
if (!detailQuery.data) return;
|
||||
deletePlaylistMutation?.mutate(
|
||||
{ query: { id: detailQuery.data.id }, serverId: detailQuery.data.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `Playlist has been deleted`,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
closeAllModals();
|
||||
}, [deletePlaylistMutation, detailQuery.data]);
|
||||
|
||||
const openDeletePlaylistModal = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
||||
<Text>Are you sure you want to delete this playlist?</Text>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist(s)',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isSm ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size="1.3rem" />
|
||||
) : (
|
||||
<RiSortDesc size="1.3rem" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue next
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiEditFill />}
|
||||
onClick={() =>
|
||||
openUpdatePlaylistModal({
|
||||
playlist: detailQuery.data!,
|
||||
server: server!,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
icon={<RiDeleteBinFill />}
|
||||
onClick={openDeletePlaylistModal}
|
||||
>
|
||||
Delete playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
onClick={handleToggleShowQueryBuilder}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
Toggle smart playlist editor
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
{cq.isSm ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size="1.3rem" />
|
||||
) : (
|
||||
<RiSortDesc size="1.3rem" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue next
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiEditFill />}
|
||||
onClick={() =>
|
||||
openUpdatePlaylistModal({
|
||||
playlist: detailQuery.data!,
|
||||
server: server!,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
icon={<RiDeleteBinFill />}
|
||||
onClick={openDeletePlaylistModal}
|
||||
>
|
||||
Delete playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
onClick={handleToggleShowQueryBuilder}
|
||||
>
|
||||
Toggle smart playlist editor
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,56 +13,60 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
|||
import { Play } from '/@/renderer/types';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
handleToggleShowQueryBuilder: () => void;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
handleToggleShowQueryBuilder: () => void;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeader = ({
|
||||
tableRef,
|
||||
itemCount,
|
||||
handleToggleShowQueryBuilder,
|
||||
tableRef,
|
||||
itemCount,
|
||||
handleToggleShowQueryBuilder,
|
||||
}: PlaylistDetailHeaderProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
playType,
|
||||
});
|
||||
};
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
if (detailQuery.isLoading) return null;
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
if (detailQuery.isLoading) return null;
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<PlaylistDetailSongListHeaderFilters
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<PlaylistDetailSongListHeaderFilters
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Stack } from '@mantine/core';
|
||||
|
|
@ -13,11 +13,11 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlaylistListStore,
|
||||
usePlaylistTablePagination,
|
||||
useSetPlaylistTable,
|
||||
useSetPlaylistTablePagination,
|
||||
useCurrentServer,
|
||||
usePlaylistListStore,
|
||||
usePlaylistTablePagination,
|
||||
useSetPlaylistTable,
|
||||
useSetPlaylistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
|
@ -31,198 +31,203 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr
|
|||
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface PlaylistListContentProps {
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistListContent = ({ tableRef, itemCount }: PlaylistListContentProps) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistListStore();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistListStore();
|
||||
|
||||
const pagination = usePlaylistTablePagination();
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const setTable = useSetPlaylistTable();
|
||||
const pagination = usePlaylistTablePagination();
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const setTable = useSetPlaylistTable();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
|
||||
const playlistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
playlistsRes?.items || [],
|
||||
playlistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
|
||||
},
|
||||
[page.filter, page.table.scrollOffset, queryClient, server],
|
||||
);
|
||||
|
||||
const onPaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setPagination({
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const handleColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
const queryKey = queryKeys.playlists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const playlistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find(
|
||||
(c) => c.column === column.getColDef().colId,
|
||||
);
|
||||
|
||||
params.successCallback(playlistsRes?.items || [], playlistsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
|
||||
},
|
||||
[page.filter, page.table.scrollOffset, queryClient, server],
|
||||
);
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const onPaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||
|
||||
setPagination({
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
);
|
||||
const handleScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setTable({ scrollOffset });
|
||||
};
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
const handleContextMenu = useHandleTableContextMenu(
|
||||
LibraryItem.PLAYLIST,
|
||||
PLAYLIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
if (!e.data) return;
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
};
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||
|
||||
const handleScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setTable({ scrollOffset });
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(
|
||||
LibraryItem.PLAYLIST,
|
||||
PLAYLIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
if (!e.data) return;
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
h="100%"
|
||||
spacing={0}
|
||||
>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={page.table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={200}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowSelection="multiple"
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onGridReady={onGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey=""
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
h="100%"
|
||||
spacing={0}
|
||||
>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={page.table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={200}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowSelection="multiple"
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onGridReady={onGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey=""
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,319 +10,327 @@ import { SortOrder, PlaylistListSort } from '/@/renderer/api/types';
|
|||
import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/renderer/components';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
PlaylistListFilter,
|
||||
useCurrentServer,
|
||||
usePlaylistListStore,
|
||||
useSetPlaylistFilters,
|
||||
useSetPlaylistStore,
|
||||
useSetPlaylistTable,
|
||||
useSetPlaylistTablePagination,
|
||||
PlaylistListFilter,
|
||||
useCurrentServer,
|
||||
usePlaylistListStore,
|
||||
useSetPlaylistFilters,
|
||||
useSetPlaylistStore,
|
||||
useSetPlaylistTable,
|
||||
useSetPlaylistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Owner', value: PlaylistListSort.OWNER },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Public', value: PlaylistListSort.PUBLIC },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Updated At', value: PlaylistListSort.UPDATED_AT },
|
||||
],
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Owner', value: PlaylistListSort.OWNER },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Public', value: PlaylistListSort.PUBLIC },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Updated At', value: PlaylistListSort.UPDATED_AT },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface PlaylistListHeaderFiltersProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFiltersProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistListStore();
|
||||
const setPage = useSetPlaylistStore();
|
||||
const setFilter = useSetPlaylistFilters();
|
||||
const setTable = useSetPlaylistTable();
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const cq = useContainerQuery();
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistListStore();
|
||||
const setPage = useSetPlaylistStore();
|
||||
const setFilter = useSetPlaylistFilters();
|
||||
const setTable = useSetPlaylistTable();
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === page.filter.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(
|
||||
FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]
|
||||
).find((f) => f.value === page.filter.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters?: PlaylistListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters?: PlaylistListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const pageFilters = filters || page.filter;
|
||||
const pageFilters = filters || page.filter;
|
||||
|
||||
const queryKey = queryKeys.playlists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
});
|
||||
const queryKey = queryKeys.playlists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
});
|
||||
|
||||
const playlistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const playlistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
playlistsRes?.items || [],
|
||||
playlistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(playlistsRes?.items || [], playlistsRes?.totalRecordCount || 0);
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
},
|
||||
[page.filter, queryClient, server, setPagination, tableRef],
|
||||
);
|
||||
[page.filter, queryClient, server, setPagination, tableRef],
|
||||
);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as PlaylistListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as PlaylistListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder =
|
||||
page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const display = e.currentTarget.value as ListDisplayType;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const display = e.currentTarget.value as ListDisplayType;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
);
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(
|
||||
tableRef.current.props.infiniteInitialRowCount,
|
||||
);
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
return setTable({ columns: [...existingColumns, newColumn] });
|
||||
}
|
||||
return setTable({ columns: [...existingColumns, newColumn] });
|
||||
}
|
||||
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
return setTable({ columns: newColumns });
|
||||
};
|
||||
return setTable({ columns: newColumns });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowHeight = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
const handleRowHeight = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
};
|
||||
const handleRefresh = () => {
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === page.filter.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isSm ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{page.filter.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size="1.3rem" />
|
||||
) : (
|
||||
<RiSortDesc size="1.3rem" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight || 0}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleRowHeight}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={PLAYLIST_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === page.filter.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isSm ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{page.filter.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size="1.3rem" />
|
||||
) : (
|
||||
<RiSortDesc size="1.3rem" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight || 0}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleRowHeight}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={PLAYLIST_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,53 +11,57 @@ import { useCurrentServer } from '/@/renderer/store';
|
|||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
interface PlaylistListHeaderProps {
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistListHeader = ({ itemCount, tableRef }: PlaylistListHeaderProps) => {
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const handleCreatePlaylistModal = () => {
|
||||
openModal({
|
||||
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
|
||||
onClose: () => {
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
},
|
||||
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
|
||||
title: 'Create Playlist',
|
||||
});
|
||||
};
|
||||
const handleCreatePlaylistModal = () => {
|
||||
openModal({
|
||||
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
|
||||
onClose: () => {
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
},
|
||||
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
|
||||
title: 'Create Playlist',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
</LibraryHeaderBar>
|
||||
<Button onClick={handleCreatePlaylistModal}>Create playlist</Button>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<PlaylistListHeaderFilters tableRef={tableRef} />
|
||||
</Paper>
|
||||
</LibraryHeaderBar>
|
||||
<Button onClick={handleCreatePlaylistModal}>Create playlist</Button>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<PlaylistListHeaderFilters tableRef={tableRef} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,468 +6,470 @@ import get from 'lodash/get';
|
|||
import setWith from 'lodash/setWith';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MotionFlex,
|
||||
NumberInput,
|
||||
QueryBuilder,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MotionFlex,
|
||||
NumberInput,
|
||||
QueryBuilder,
|
||||
ScrollArea,
|
||||
Select,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
convertNDQueryToQueryGroup,
|
||||
convertQueryGroupToNDQuery,
|
||||
convertNDQueryToQueryGroup,
|
||||
convertQueryGroupToNDQuery,
|
||||
} from '/@/renderer/features/playlists/utils';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
|
||||
import { SongListSort } from '/@/renderer/api/types';
|
||||
import {
|
||||
NDSongQueryBooleanOperators,
|
||||
NDSongQueryDateOperators,
|
||||
NDSongQueryFields,
|
||||
NDSongQueryNumberOperators,
|
||||
NDSongQueryStringOperators,
|
||||
NDSongQueryBooleanOperators,
|
||||
NDSongQueryDateOperators,
|
||||
NDSongQueryFields,
|
||||
NDSongQueryNumberOperators,
|
||||
NDSongQueryStringOperators,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
|
||||
type AddArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
};
|
||||
|
||||
type DeleteArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
};
|
||||
|
||||
interface PlaylistQueryBuilderProps {
|
||||
isSaving?: boolean;
|
||||
limit?: number;
|
||||
onSave?: (
|
||||
parsedFilter: any,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => void;
|
||||
onSaveAs?: (
|
||||
parsedFilter: any,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => void;
|
||||
query: any;
|
||||
sortBy: SongListSort;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
isSaving?: boolean;
|
||||
limit?: number;
|
||||
onSave?: (
|
||||
parsedFilter: any,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => void;
|
||||
onSaveAs?: (
|
||||
parsedFilter: any,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => void;
|
||||
query: any;
|
||||
sortBy: SongListSort;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY = {
|
||||
group: [],
|
||||
rules: [
|
||||
{
|
||||
field: '',
|
||||
operator: '',
|
||||
uniqueId: nanoid(),
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
type: 'all' as 'all' | 'any',
|
||||
uniqueId: nanoid(),
|
||||
};
|
||||
|
||||
export type PlaylistQueryBuilderRef = {
|
||||
getFilters: () => {
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
filters: QueryBuilderGroup;
|
||||
};
|
||||
};
|
||||
|
||||
export const PlaylistQueryBuilder = forwardRef(
|
||||
(
|
||||
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const [filters, setFilters] = useState<QueryBuilderGroup>(
|
||||
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
|
||||
);
|
||||
|
||||
const extraFiltersForm = useForm({
|
||||
initialValues: {
|
||||
limit,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFilters: () => ({
|
||||
extraFilters: extraFiltersForm.values,
|
||||
filters,
|
||||
}),
|
||||
}));
|
||||
|
||||
const handleResetFilters = () => {
|
||||
if (query) {
|
||||
setFilters(convertNDQueryToQueryGroup(query));
|
||||
} else {
|
||||
setFilters(DEFAULT_QUERY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters(DEFAULT_QUERY);
|
||||
};
|
||||
|
||||
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
|
||||
};
|
||||
|
||||
const handleSaveAs = () => {
|
||||
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
|
||||
};
|
||||
|
||||
const handleAddRuleGroup = (args: AddArgs) => {
|
||||
const { level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const getPath = (level: number) => {
|
||||
if (level === 0) return 'group';
|
||||
|
||||
const str = [];
|
||||
for (const index of groupIndex) {
|
||||
str.push(`group[${index}]`);
|
||||
}
|
||||
|
||||
return `${str.join('.')}.group`;
|
||||
};
|
||||
|
||||
const path = getPath(level);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
[
|
||||
...get(filtersCopy, path),
|
||||
{
|
||||
group: [],
|
||||
rules: [
|
||||
{
|
||||
field: '',
|
||||
operator: '',
|
||||
uniqueId: nanoid(),
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
type: 'any',
|
||||
uniqueId: nanoid(),
|
||||
},
|
||||
],
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleDeleteRuleGroup = (args: DeleteArgs) => {
|
||||
const { uniqueId, level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const getPath = (level: number) => {
|
||||
if (level === 0) return 'group';
|
||||
|
||||
const str = [];
|
||||
for (let i = 0; i < groupIndex.length; i += 1) {
|
||||
if (i !== groupIndex.length - 1) {
|
||||
str.push(`group[${groupIndex[i]}]`);
|
||||
} else {
|
||||
str.push(`group`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${str.join('.')}`;
|
||||
};
|
||||
|
||||
const path = getPath(level);
|
||||
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
[
|
||||
...get(filtersCopy, path).filter(
|
||||
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
|
||||
),
|
||||
],
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const getRulePath = (level: number, groupIndex: number[]) => {
|
||||
if (level === 0) return 'rules';
|
||||
|
||||
const str = [];
|
||||
for (const index of groupIndex) {
|
||||
str.push(`group[${index}]`);
|
||||
}
|
||||
|
||||
return `${str.join('.')}.rules`;
|
||||
};
|
||||
|
||||
const handleAddRule = (args: AddArgs) => {
|
||||
const { level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
[
|
||||
...get(filtersCopy, path),
|
||||
{
|
||||
group: [],
|
||||
rules: [
|
||||
{
|
||||
field: '',
|
||||
operator: '',
|
||||
uniqueId: nanoid(),
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (args: DeleteArgs) => {
|
||||
const { uniqueId, level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleChangeField = (args: any) => {
|
||||
const { uniqueId, level, groupIndex, value } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
|
||||
if (rule.uniqueId !== uniqueId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
field: value,
|
||||
operator: '',
|
||||
value: '',
|
||||
};
|
||||
}),
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleChangeType = (args: any) => {
|
||||
const { level, groupIndex, value } = args;
|
||||
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
if (level === 0) {
|
||||
return setFilterHandler({ ...filtersCopy, type: value });
|
||||
}
|
||||
|
||||
const getTypePath = () => {
|
||||
const str = [];
|
||||
for (let i = 0; i < groupIndex.length; i += 1) {
|
||||
str.push(`group[${groupIndex[i]}]`);
|
||||
}
|
||||
|
||||
return `${str.join('.')}`;
|
||||
};
|
||||
|
||||
const path = getTypePath();
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
{
|
||||
...get(filtersCopy, path),
|
||||
type: value,
|
||||
},
|
||||
clone,
|
||||
);
|
||||
],
|
||||
type: 'all' as 'all' | 'any',
|
||||
uniqueId: nanoid(),
|
||||
};
|
||||
|
||||
return setFilterHandler(updatedFilters);
|
||||
export type PlaylistQueryBuilderRef = {
|
||||
getFilters: () => {
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
filters: QueryBuilderGroup;
|
||||
};
|
||||
};
|
||||
|
||||
const handleChangeOperator = (args: any) => {
|
||||
const { uniqueId, level, groupIndex, value } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
export const PlaylistQueryBuilder = forwardRef(
|
||||
(
|
||||
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const [filters, setFilters] = useState<QueryBuilderGroup>(
|
||||
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
|
||||
);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
|
||||
if (rule.uniqueId !== uniqueId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
operator: value,
|
||||
};
|
||||
}),
|
||||
clone,
|
||||
);
|
||||
const extraFiltersForm = useForm({
|
||||
initialValues: {
|
||||
limit,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFilters: () => ({
|
||||
extraFilters: extraFiltersForm.values,
|
||||
filters,
|
||||
}),
|
||||
}));
|
||||
|
||||
const handleChangeValue = (args: any) => {
|
||||
const { uniqueId, level, groupIndex, value } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
const handleResetFilters = () => {
|
||||
if (query) {
|
||||
setFilters(convertNDQueryToQueryGroup(query));
|
||||
} else {
|
||||
setFilters(DEFAULT_QUERY);
|
||||
}
|
||||
};
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
|
||||
if (rule.uniqueId !== uniqueId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
value,
|
||||
};
|
||||
}),
|
||||
clone,
|
||||
);
|
||||
const handleClearFilters = () => {
|
||||
setFilters(DEFAULT_QUERY);
|
||||
};
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Random', type: 'string', value: 'random' },
|
||||
...NDSongQueryFields,
|
||||
];
|
||||
const handleSave = () => {
|
||||
onSave?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionFlex
|
||||
direction="column"
|
||||
h="calc(100% - 2.5rem)"
|
||||
justify="space-between"
|
||||
>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
p="1rem"
|
||||
>
|
||||
<QueryBuilder
|
||||
data={filters}
|
||||
filters={NDSongQueryFields}
|
||||
groupIndex={[]}
|
||||
level={0}
|
||||
operators={{
|
||||
boolean: NDSongQueryBooleanOperators,
|
||||
date: NDSongQueryDateOperators,
|
||||
number: NDSongQueryNumberOperators,
|
||||
string: NDSongQueryStringOperators,
|
||||
}}
|
||||
uniqueId={filters.uniqueId}
|
||||
onAddRule={handleAddRule}
|
||||
onAddRuleGroup={handleAddRuleGroup}
|
||||
onChangeField={handleChangeField}
|
||||
onChangeOperator={handleChangeOperator}
|
||||
onChangeType={handleChangeType}
|
||||
onChangeValue={handleChangeValue}
|
||||
onClearFilters={handleClearFilters}
|
||||
onDeleteRule={handleDeleteRule}
|
||||
onDeleteRuleGroup={handleDeleteRuleGroup}
|
||||
onResetFilters={handleResetFilters}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<Group
|
||||
noWrap
|
||||
align="flex-end"
|
||||
m="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<Select
|
||||
searchable
|
||||
data={sortOptions}
|
||||
label="Sort"
|
||||
maxWidth="20%"
|
||||
width={150}
|
||||
{...extraFiltersForm.getInputProps('sortBy')}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
const handleSaveAs = () => {
|
||||
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
|
||||
};
|
||||
|
||||
const handleAddRuleGroup = (args: AddArgs) => {
|
||||
const { level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const getPath = (level: number) => {
|
||||
if (level === 0) return 'group';
|
||||
|
||||
const str = [];
|
||||
for (const index of groupIndex) {
|
||||
str.push(`group[${index}]`);
|
||||
}
|
||||
|
||||
return `${str.join('.')}.group`;
|
||||
};
|
||||
|
||||
const path = getPath(level);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
[
|
||||
...get(filtersCopy, path),
|
||||
{
|
||||
group: [],
|
||||
rules: [
|
||||
{
|
||||
field: '',
|
||||
operator: '',
|
||||
uniqueId: nanoid(),
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
type: 'any',
|
||||
uniqueId: nanoid(),
|
||||
},
|
||||
],
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleDeleteRuleGroup = (args: DeleteArgs) => {
|
||||
const { uniqueId, level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const getPath = (level: number) => {
|
||||
if (level === 0) return 'group';
|
||||
|
||||
const str = [];
|
||||
for (let i = 0; i < groupIndex.length; i += 1) {
|
||||
if (i !== groupIndex.length - 1) {
|
||||
str.push(`group[${groupIndex[i]}]`);
|
||||
} else {
|
||||
str.push(`group`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${str.join('.')}`;
|
||||
};
|
||||
|
||||
const path = getPath(level);
|
||||
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
[
|
||||
...get(filtersCopy, path).filter(
|
||||
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
|
||||
),
|
||||
],
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const getRulePath = (level: number, groupIndex: number[]) => {
|
||||
if (level === 0) return 'rules';
|
||||
|
||||
const str = [];
|
||||
for (const index of groupIndex) {
|
||||
str.push(`group[${index}]`);
|
||||
}
|
||||
|
||||
return `${str.join('.')}.rules`;
|
||||
};
|
||||
|
||||
const handleAddRule = (args: AddArgs) => {
|
||||
const { level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
[
|
||||
...get(filtersCopy, path),
|
||||
{
|
||||
field: '',
|
||||
operator: '',
|
||||
uniqueId: nanoid(),
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (args: DeleteArgs) => {
|
||||
const { uniqueId, level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).filter(
|
||||
(rule: QueryBuilderRule) => rule.uniqueId !== uniqueId,
|
||||
),
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleChangeField = (args: any) => {
|
||||
const { uniqueId, level, groupIndex, value } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
|
||||
if (rule.uniqueId !== uniqueId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
field: value,
|
||||
operator: '',
|
||||
value: '',
|
||||
};
|
||||
}),
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleChangeType = (args: any) => {
|
||||
const { level, groupIndex, value } = args;
|
||||
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
if (level === 0) {
|
||||
return setFilterHandler({ ...filtersCopy, type: value });
|
||||
}
|
||||
|
||||
const getTypePath = () => {
|
||||
const str = [];
|
||||
for (let i = 0; i < groupIndex.length; i += 1) {
|
||||
str.push(`group[${groupIndex[i]}]`);
|
||||
}
|
||||
|
||||
return `${str.join('.')}`;
|
||||
};
|
||||
|
||||
const path = getTypePath();
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
{
|
||||
label: 'Ascending',
|
||||
value: 'asc',
|
||||
...get(filtersCopy, path),
|
||||
type: value,
|
||||
},
|
||||
{
|
||||
label: 'Descending',
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
label="Order"
|
||||
maxWidth="20%"
|
||||
width={125}
|
||||
{...extraFiltersForm.getInputProps('sortOrder')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Limit"
|
||||
maxWidth="20%"
|
||||
width={75}
|
||||
{...extraFiltersForm.getInputProps('limit')}
|
||||
/>
|
||||
</Group>
|
||||
{onSave && onSaveAs && (
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
clone,
|
||||
);
|
||||
|
||||
return setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleChangeOperator = (args: any) => {
|
||||
const { uniqueId, level, groupIndex, value } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
|
||||
if (rule.uniqueId !== uniqueId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
operator: value,
|
||||
};
|
||||
}),
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const handleChangeValue = (args: any) => {
|
||||
const { uniqueId, level, groupIndex, value } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
|
||||
const path = getRulePath(level, groupIndex);
|
||||
const updatedFilters = setWith(
|
||||
filtersCopy,
|
||||
path,
|
||||
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
|
||||
if (rule.uniqueId !== uniqueId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
value,
|
||||
};
|
||||
}),
|
||||
clone,
|
||||
);
|
||||
|
||||
setFilterHandler(updatedFilters);
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Random', type: 'string', value: 'random' },
|
||||
...NDSongQueryFields,
|
||||
];
|
||||
|
||||
return (
|
||||
<MotionFlex
|
||||
direction="column"
|
||||
h="calc(100% - 2.5rem)"
|
||||
justify="space-between"
|
||||
>
|
||||
<Button
|
||||
loading={isSaving}
|
||||
variant="filled"
|
||||
onClick={handleSaveAs}
|
||||
>
|
||||
Save as
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
p="0.5em"
|
||||
variant="default"
|
||||
>
|
||||
<RiMore2Fill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiSaveLine color="var(--danger-color)" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save and replace
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</MotionFlex>
|
||||
);
|
||||
},
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
p="1rem"
|
||||
>
|
||||
<QueryBuilder
|
||||
data={filters}
|
||||
filters={NDSongQueryFields}
|
||||
groupIndex={[]}
|
||||
level={0}
|
||||
operators={{
|
||||
boolean: NDSongQueryBooleanOperators,
|
||||
date: NDSongQueryDateOperators,
|
||||
number: NDSongQueryNumberOperators,
|
||||
string: NDSongQueryStringOperators,
|
||||
}}
|
||||
uniqueId={filters.uniqueId}
|
||||
onAddRule={handleAddRule}
|
||||
onAddRuleGroup={handleAddRuleGroup}
|
||||
onChangeField={handleChangeField}
|
||||
onChangeOperator={handleChangeOperator}
|
||||
onChangeType={handleChangeType}
|
||||
onChangeValue={handleChangeValue}
|
||||
onClearFilters={handleClearFilters}
|
||||
onDeleteRule={handleDeleteRule}
|
||||
onDeleteRuleGroup={handleDeleteRuleGroup}
|
||||
onResetFilters={handleResetFilters}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<Group
|
||||
noWrap
|
||||
align="flex-end"
|
||||
m="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<Select
|
||||
searchable
|
||||
data={sortOptions}
|
||||
label="Sort"
|
||||
maxWidth="20%"
|
||||
width={150}
|
||||
{...extraFiltersForm.getInputProps('sortBy')}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{
|
||||
label: 'Ascending',
|
||||
value: 'asc',
|
||||
},
|
||||
{
|
||||
label: 'Descending',
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
label="Order"
|
||||
maxWidth="20%"
|
||||
width={125}
|
||||
{...extraFiltersForm.getInputProps('sortOrder')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Limit"
|
||||
maxWidth="20%"
|
||||
width={75}
|
||||
{...extraFiltersForm.getInputProps('limit')}
|
||||
/>
|
||||
</Group>
|
||||
{onSave && onSaveAs && (
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
>
|
||||
<Button
|
||||
loading={isSaving}
|
||||
variant="filled"
|
||||
onClick={handleSaveAs}
|
||||
>
|
||||
Save as
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
p="0.5em"
|
||||
variant="default"
|
||||
>
|
||||
<RiMore2Fill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiSaveLine color="var(--danger-color)" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save and replace
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</MotionFlex>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,90 +6,90 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea
|
|||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface SaveAsPlaylistFormProps {
|
||||
body: Partial<CreatePlaylistBody>;
|
||||
onCancel: () => void;
|
||||
onSuccess: (data: CreatePlaylistResponse) => void;
|
||||
serverId: string | undefined;
|
||||
body: Partial<CreatePlaylistBody>;
|
||||
onCancel: () => void;
|
||||
onSuccess: (data: CreatePlaylistResponse) => void;
|
||||
serverId: string | undefined;
|
||||
}
|
||||
|
||||
export const SaveAsPlaylistForm = ({
|
||||
body,
|
||||
serverId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
body,
|
||||
serverId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: SaveAsPlaylistFormProps) => {
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
const form = useForm<CreatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
...body?._custom?.navidrome,
|
||||
const form = useForm<CreatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
...body?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
comment: body.comment || '',
|
||||
name: body.name || '',
|
||||
},
|
||||
},
|
||||
comment: body.comment || '',
|
||||
name: body.name || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{ body: values, serverId },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success({ message: `Playlist has been created` });
|
||||
onSuccess(data);
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{ body: values, serverId },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success({ message: `Playlist has been created` });
|
||||
onSuccess(data);
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ import { openModal, closeAllModals } from '@mantine/modals';
|
|||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
PlaylistDetailResponse,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
UpdatePlaylistBody,
|
||||
UpdatePlaylistQuery,
|
||||
User,
|
||||
UserListQuery,
|
||||
UserListSort,
|
||||
PlaylistDetailResponse,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
UpdatePlaylistBody,
|
||||
UpdatePlaylistQuery,
|
||||
User,
|
||||
UserListQuery,
|
||||
UserListSort,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
||||
|
|
@ -20,146 +20,146 @@ import { queryClient } from '/@/renderer/lib/react-query';
|
|||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface UpdatePlaylistFormProps {
|
||||
body: Partial<UpdatePlaylistBody>;
|
||||
onCancel: () => void;
|
||||
query: UpdatePlaylistQuery;
|
||||
users?: User[];
|
||||
body: Partial<UpdatePlaylistBody>;
|
||||
onCancel: () => void;
|
||||
query: UpdatePlaylistQuery;
|
||||
users?: User[];
|
||||
}
|
||||
|
||||
export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlaylistFormProps) => {
|
||||
const mutation = useUpdatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
const mutation = useUpdatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
const userList = users?.map((user) => ({
|
||||
label: user.name,
|
||||
value: user.id,
|
||||
}));
|
||||
const userList = users?.map((user) => ({
|
||||
label: user.name,
|
||||
value: user.id,
|
||||
}));
|
||||
|
||||
const form = useForm<UpdatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: body?._custom?.navidrome?.owner || '',
|
||||
ownerId: body?._custom?.navidrome?.ownerId || '',
|
||||
public: body?._custom?.navidrome?.public || false,
|
||||
rules: undefined,
|
||||
sync: body?._custom?.navidrome?.sync || false,
|
||||
const form = useForm<UpdatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: body?._custom?.navidrome?.owner || '',
|
||||
ownerId: body?._custom?.navidrome?.ownerId || '',
|
||||
public: body?._custom?.navidrome?.public || false,
|
||||
rules: undefined,
|
||||
sync: body?._custom?.navidrome?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: body?.comment || '',
|
||||
name: body?.name || '',
|
||||
},
|
||||
},
|
||||
comment: body?.comment || '',
|
||||
name: body?.name || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{
|
||||
body: values,
|
||||
query,
|
||||
serverId: server?.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error updating playlist' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: `Playlist has been saved` });
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{
|
||||
body: values,
|
||||
query,
|
||||
serverId: server?.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error updating playlist' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: `Playlist has been saved` });
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
<Select
|
||||
data={userList || []}
|
||||
{...form.getInputProps('_custom.navidrome.ownerId')}
|
||||
label="Owner"
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
<Select
|
||||
data={userList || []}
|
||||
{...form.getInputProps('_custom.navidrome.ownerId')}
|
||||
label="Owner"
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const openUpdatePlaylistModal = async (args: {
|
||||
playlist: PlaylistDetailResponse;
|
||||
server: ServerListItem;
|
||||
playlist: PlaylistDetailResponse;
|
||||
server: ServerListItem;
|
||||
}) => {
|
||||
const { playlist, server } = args;
|
||||
const { playlist, server } = args;
|
||||
|
||||
const query: UserListQuery = {
|
||||
sortBy: UserListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
const query: UserListQuery = {
|
||||
sortBy: UserListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
if (!server) return;
|
||||
if (!server) return;
|
||||
|
||||
const users = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey: queryKeys.users.list(server?.id || '', query),
|
||||
});
|
||||
const users = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey: queryKeys.users.list(server?.id || '', query),
|
||||
});
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<UpdatePlaylistForm
|
||||
body={{
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: playlist?.owner || undefined,
|
||||
ownerId: playlist?.ownerId || undefined,
|
||||
public: playlist?.public || false,
|
||||
rules: playlist?.rules || undefined,
|
||||
sync: playlist?.sync || undefined,
|
||||
},
|
||||
},
|
||||
comment: playlist?.description || undefined,
|
||||
genres: playlist?.genres,
|
||||
name: playlist?.name,
|
||||
}}
|
||||
query={{ id: playlist?.id }}
|
||||
users={users?.items}
|
||||
onCancel={closeAllModals}
|
||||
/>
|
||||
),
|
||||
title: 'Edit playlist',
|
||||
});
|
||||
openModal({
|
||||
children: (
|
||||
<UpdatePlaylistForm
|
||||
body={{
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: playlist?.owner || undefined,
|
||||
ownerId: playlist?.ownerId || undefined,
|
||||
public: playlist?.public || false,
|
||||
rules: playlist?.rules || undefined,
|
||||
sync: playlist?.sync || undefined,
|
||||
},
|
||||
},
|
||||
comment: playlist?.description || undefined,
|
||||
genres: playlist?.genres,
|
||||
name: playlist?.name,
|
||||
}}
|
||||
query={{ id: playlist?.id }}
|
||||
users={users?.items}
|
||||
onCancel={closeAllModals}
|
||||
/>
|
||||
),
|
||||
title: 'Edit playlist',
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,31 +7,31 @@ import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
|||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAddToPlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
AddToPlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<AddToPlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.addToPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { serverId } = variables;
|
||||
return useMutation<
|
||||
AddToPlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<AddToPlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.addToPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { serverId } = variables;
|
||||
|
||||
if (!serverId) return;
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,26 +7,26 @@ import { AxiosError } from 'axios';
|
|||
import { queryKeys } from '../../../api/query-keys';
|
||||
|
||||
export const useCreatePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
CreatePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<CreatePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.createPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_args, variables) => {
|
||||
const server = getServerById(variables.serverId);
|
||||
if (server) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return useMutation<
|
||||
CreatePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<CreatePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.createPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_args, variables) => {
|
||||
const server = getServerById(variables.serverId);
|
||||
if (server) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,28 +7,28 @@ import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
|||
import { getServerById, useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const useDeletePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
DeletePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<DeletePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.deletePlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onMutate: () => {
|
||||
queryClient.cancelQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
return null;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return useMutation<
|
||||
DeletePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<DeletePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.deletePlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onMutate: () => {
|
||||
queryClient.cancelQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
return null;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,30 +7,30 @@ import { MutationOptions } from '/@/renderer/lib/react-query';
|
|||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useRemoveFromPlaylist = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
RemoveFromPlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<RemoveFromPlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.removeFromPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { serverId } = variables;
|
||||
return useMutation<
|
||||
RemoveFromPlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<RemoveFromPlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.removeFromPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { serverId } = variables;
|
||||
|
||||
if (!serverId) return;
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,31 +7,31 @@ import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
|||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useUpdatePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
UpdatePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<UpdatePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updatePlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { query, serverId } = variables;
|
||||
return useMutation<
|
||||
UpdatePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<UpdatePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updatePlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { query, serverId } = variables;
|
||||
|
||||
if (!serverId) return;
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId));
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId));
|
||||
|
||||
if (query?.id) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, query.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
if (query?.id) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, query.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,16 +6,16 @@ import { getServerById } from '/@/renderer/store';
|
|||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistDetail = (args: QueryHookArgs<PlaylistDetailQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistDetail({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistDetail({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,21 +6,21 @@ import { getServerById } from '/@/renderer/store';
|
|||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistList = (args: {
|
||||
options?: QueryOptions;
|
||||
query: PlaylistListQuery;
|
||||
serverId?: string;
|
||||
options?: QueryOptions;
|
||||
query: PlaylistListQuery;
|
||||
serverId?: string;
|
||||
}) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
cacheTime: 1000 * 60 * 60,
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.list(server?.id || '', query),
|
||||
...options,
|
||||
});
|
||||
return useQuery({
|
||||
cacheTime: 1000 * 60 * 60,
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.list(server?.id || '', query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,41 +6,48 @@ import { getServerById } from '/@/renderer/store';
|
|||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistSongList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
return useQuery({
|
||||
enabled: !!server,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled: !!server,
|
||||
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return pages?.length;
|
||||
}
|
||||
return useInfiniteQuery({
|
||||
enabled: !!server,
|
||||
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return pages?.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query: { ...query, limit: query.limit || 50, startIndex: pageParam * (query.limit || 50) },
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query: {
|
||||
...query,
|
||||
limit: query.limit || 50,
|
||||
startIndex: pageParam * (query.limit || 50),
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,59 +13,61 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
|||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
const PlaylistDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const background = useFastAverageColor(
|
||||
detailQuery?.data?.imageUrl,
|
||||
!detailQuery?.isLoading,
|
||||
'sqrt',
|
||||
);
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const background = useFastAverageColor(
|
||||
detailQuery?.data?.imageUrl,
|
||||
!detailQuery?.isLoading,
|
||||
'sqrt',
|
||||
);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
if (!background) return null;
|
||||
if (!background) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-${playlistId}`}>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
target: headerRef,
|
||||
}}
|
||||
>
|
||||
<PlaylistDetailHeader
|
||||
ref={headerRef}
|
||||
background={background}
|
||||
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
/>
|
||||
<PlaylistDetailContent tableRef={tableRef} />
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-${playlistId}`}>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>
|
||||
{detailQuery?.data?.name}
|
||||
</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
target: headerRef,
|
||||
}}
|
||||
>
|
||||
<PlaylistDetailHeader
|
||||
ref={headerRef}
|
||||
background={background}
|
||||
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
/>
|
||||
<PlaylistDetailContent tableRef={tableRef} />
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistDetailRoute;
|
||||
|
|
|
|||
|
|
@ -20,217 +20,228 @@ import { PlaylistSongListQuery, ServerType, SongListSort, SortOrder } from '/@/r
|
|||
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
|
||||
const PlaylistDetailSongListRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const navigate = useNavigate();
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const createPlaylistMutation = useCreatePlaylist({});
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const createPlaylistMutation = useCreatePlaylist({});
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleSave = (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => {
|
||||
const rules = {
|
||||
...filter,
|
||||
limit: extraFilters.limit || undefined,
|
||||
order: extraFilters.sortOrder || 'desc',
|
||||
sort: extraFilters.sortBy || 'dateAdded',
|
||||
const handleSave = (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => {
|
||||
const rules = {
|
||||
...filter,
|
||||
limit: extraFilters.limit || undefined,
|
||||
order: extraFilters.sortOrder || 'desc',
|
||||
sort: extraFilters.sortBy || 'dateAdded',
|
||||
};
|
||||
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
createPlaylistMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules,
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || '',
|
||||
name: detailQuery?.data?.name,
|
||||
},
|
||||
serverId: detailQuery?.data?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success({ message: 'Playlist has been saved' });
|
||||
navigate(
|
||||
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {
|
||||
playlistId: data?.id || '',
|
||||
}),
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
deletePlaylistMutation.mutate({
|
||||
query: { id: playlistId },
|
||||
serverId: detailQuery?.data?.serverId,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!detailQuery?.data) return;
|
||||
const handleSaveAs = (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => {
|
||||
openModal({
|
||||
children: (
|
||||
<SaveAsPlaylistForm
|
||||
body={{
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: {
|
||||
...filter,
|
||||
limit: extraFilters.limit || undefined,
|
||||
order: extraFilters.sortOrder || 'desc',
|
||||
sort: extraFilters.sortBy || 'dateAdded',
|
||||
},
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || '',
|
||||
name: detailQuery?.data?.name,
|
||||
}}
|
||||
serverId={detailQuery?.data?.serverId}
|
||||
onCancel={closeAllModals}
|
||||
onSuccess={(data) =>
|
||||
navigate(
|
||||
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {
|
||||
playlistId: data?.id || '',
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
title: 'Save as',
|
||||
});
|
||||
};
|
||||
|
||||
createPlaylistMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules,
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || '',
|
||||
name: detailQuery?.data?.name,
|
||||
},
|
||||
serverId: detailQuery?.data?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success({ message: 'Playlist has been saved' });
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }), {
|
||||
replace: true,
|
||||
});
|
||||
deletePlaylistMutation.mutate({
|
||||
query: { id: playlistId },
|
||||
serverId: detailQuery?.data?.serverId,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveAs = (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => {
|
||||
openModal({
|
||||
children: (
|
||||
<SaveAsPlaylistForm
|
||||
body={{
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: {
|
||||
...filter,
|
||||
limit: extraFilters.limit || undefined,
|
||||
order: extraFilters.sortOrder || 'desc',
|
||||
sort: extraFilters.sortBy || 'dateAdded',
|
||||
const smartPlaylistVariants: Variants = {
|
||||
animate: (custom: { isQueryBuilderExpanded: boolean }) => {
|
||||
return {
|
||||
maxHeight: custom.isQueryBuilderExpanded ? '35vh' : '3.5em',
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || '',
|
||||
name: detailQuery?.data?.name,
|
||||
}}
|
||||
serverId={detailQuery?.data?.serverId}
|
||||
onCancel={closeAllModals}
|
||||
onSuccess={(data) =>
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }))
|
||||
}
|
||||
/>
|
||||
),
|
||||
title: 'Save as',
|
||||
});
|
||||
};
|
||||
|
||||
const smartPlaylistVariants: Variants = {
|
||||
animate: (custom: { isQueryBuilderExpanded: boolean }) => {
|
||||
return {
|
||||
maxHeight: custom.isQueryBuilderExpanded ? '35vh' : '3.5em',
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: 'easeInOut',
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -25,
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -25,
|
||||
},
|
||||
};
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -25,
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -25,
|
||||
},
|
||||
};
|
||||
|
||||
const isSmartPlaylist =
|
||||
!detailQuery?.isLoading && detailQuery?.data?.rules && server?.type === ServerType.NAVIDROME;
|
||||
const isSmartPlaylist =
|
||||
!detailQuery?.isLoading &&
|
||||
detailQuery?.data?.rules &&
|
||||
server?.type === ServerType.NAVIDROME;
|
||||
|
||||
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
|
||||
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
|
||||
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
|
||||
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setIsQueryBuilderExpanded((prev) => !prev);
|
||||
};
|
||||
const handleToggleExpand = () => {
|
||||
setIsQueryBuilderExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleToggleShowQueryBuilder = () => {
|
||||
setShowQueryBuilder((prev) => !prev);
|
||||
setIsQueryBuilderExpanded(true);
|
||||
};
|
||||
const handleToggleShowQueryBuilder = () => {
|
||||
setShowQueryBuilder((prev) => !prev);
|
||||
setIsQueryBuilderExpanded(true);
|
||||
};
|
||||
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
|
||||
const itemCountCheck = usePlaylistSongList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
const itemCountCheck = usePlaylistSongList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
<PlaylistDetailSongListHeader
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<AnimatePresence
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
initial={false}
|
||||
>
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<motion.div
|
||||
animate="animate"
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
variants={smartPlaylistVariants}
|
||||
>
|
||||
<Paper
|
||||
h="100%"
|
||||
pos="relative"
|
||||
w="100%"
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
<PlaylistDetailSongListHeader
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<AnimatePresence
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
initial={false}
|
||||
>
|
||||
<Group
|
||||
pt="1rem"
|
||||
px="1rem"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
variant="default"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{isQueryBuilderExpanded ? (
|
||||
<RiArrowUpSLine size={20} />
|
||||
) : (
|
||||
<RiArrowDownSLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Text>Query Editor</Text>
|
||||
</Group>
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||
isSaving={createPlaylistMutation?.isLoading}
|
||||
limit={detailQuery?.data?.rules?.limit}
|
||||
query={detailQuery?.data?.rules}
|
||||
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
|
||||
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
/>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<motion.div
|
||||
animate="animate"
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
variants={smartPlaylistVariants}
|
||||
>
|
||||
<Paper
|
||||
h="100%"
|
||||
pos="relative"
|
||||
w="100%"
|
||||
>
|
||||
<Group
|
||||
pt="1rem"
|
||||
px="1rem"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
variant="default"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{isQueryBuilderExpanded ? (
|
||||
<RiArrowUpSLine size={20} />
|
||||
) : (
|
||||
<RiArrowDownSLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Text>Query Editor</Text>
|
||||
</Group>
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||
isSaving={createPlaylistMutation?.isLoading}
|
||||
limit={detailQuery?.data?.rules?.limit}
|
||||
query={detailQuery?.data?.rules}
|
||||
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
|
||||
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
/>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistDetailSongListRoute;
|
||||
|
|
|
|||
|
|
@ -8,37 +8,37 @@ import { AnimatedPage } from '/@/renderer/features/shared';
|
|||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const PlaylistListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
|
||||
const itemCountCheck = usePlaylistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
const itemCountCheck = usePlaylistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PlaylistListHeader
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<PlaylistListContent tableRef={tableRef} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PlaylistListHeader
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<PlaylistListContent tableRef={tableRef} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistListRoute;
|
||||
|
|
|
|||
|
|
@ -3,126 +3,126 @@ import { NDSongQueryFields } from '/@/renderer/api/navidrome.types';
|
|||
import { QueryBuilderGroup } from '/@/renderer/types';
|
||||
|
||||
export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
|
||||
if (groups.length === 0) {
|
||||
return data;
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const filterGroups: any[] = [];
|
||||
const filterGroups: any[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const rootType = group.type;
|
||||
const query: any = {
|
||||
[rootType]: [],
|
||||
};
|
||||
for (const group of groups) {
|
||||
const rootType = group.type;
|
||||
const query: any = {
|
||||
[rootType]: [],
|
||||
};
|
||||
|
||||
for (const rule of group.rules) {
|
||||
if (rule.field && rule.operator) {
|
||||
const [table, field] = rule.field.split('.');
|
||||
const operator = rule.operator;
|
||||
const value = field !== 'releaseDate' ? rule.value : new Date(rule.value);
|
||||
for (const rule of group.rules) {
|
||||
if (rule.field && rule.operator) {
|
||||
const [table, field] = rule.field.split('.');
|
||||
const operator = rule.operator;
|
||||
const value = field !== 'releaseDate' ? rule.value : new Date(rule.value);
|
||||
|
||||
switch (table) {
|
||||
default:
|
||||
query[rootType].push({
|
||||
[operator]: {
|
||||
[table]: value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
switch (table) {
|
||||
default:
|
||||
query[rootType].push({
|
||||
[operator]: {
|
||||
[table]: value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (group.group.length > 0) {
|
||||
const b = parseQueryBuilderChildren(group.group, data);
|
||||
b.forEach((c) => query[rootType].push(c));
|
||||
}
|
||||
|
||||
data.push(query);
|
||||
filterGroups.push(query);
|
||||
}
|
||||
|
||||
if (group.group.length > 0) {
|
||||
const b = parseQueryBuilderChildren(group.group, data);
|
||||
b.forEach((c) => query[rootType].push(c));
|
||||
}
|
||||
|
||||
data.push(query);
|
||||
filterGroups.push(query);
|
||||
}
|
||||
|
||||
return filterGroups;
|
||||
return filterGroups;
|
||||
};
|
||||
|
||||
// Convert QueryBuilderGroup to default query
|
||||
export const convertQueryGroupToNDQuery = (filter: QueryBuilderGroup) => {
|
||||
const rootQueryType = filter.type;
|
||||
const rootQuery = {
|
||||
[rootQueryType]: [] as any[],
|
||||
};
|
||||
const rootQueryType = filter.type;
|
||||
const rootQuery = {
|
||||
[rootQueryType]: [] as any[],
|
||||
};
|
||||
|
||||
for (const rule of filter.rules) {
|
||||
if (rule.field && rule.operator) {
|
||||
const [field] = rule.field.split('.');
|
||||
const operator = rule.operator;
|
||||
let value = rule.value;
|
||||
for (const rule of filter.rules) {
|
||||
if (rule.field && rule.operator) {
|
||||
const [field] = rule.field.split('.');
|
||||
const operator = rule.operator;
|
||||
let value = rule.value;
|
||||
|
||||
const booleanFields = NDSongQueryFields.filter(
|
||||
(queryField) => queryField.type === 'boolean',
|
||||
).map((field) => field.value);
|
||||
const booleanFields = NDSongQueryFields.filter(
|
||||
(queryField) => queryField.type === 'boolean',
|
||||
).map((field) => field.value);
|
||||
|
||||
// Convert string values to boolean
|
||||
if (booleanFields.includes(field)) {
|
||||
value = value === 'true';
|
||||
}
|
||||
// Convert string values to boolean
|
||||
if (booleanFields.includes(field)) {
|
||||
value = value === 'true';
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
default:
|
||||
rootQuery[rootQueryType].push({
|
||||
[operator]: {
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
switch (field) {
|
||||
default:
|
||||
rootQuery[rootQueryType].push({
|
||||
[operator]: {
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groups = parseQueryBuilderChildren(filter.group, []);
|
||||
for (const group of groups) {
|
||||
rootQuery[rootQueryType].push(group);
|
||||
}
|
||||
const groups = parseQueryBuilderChildren(filter.group, []);
|
||||
for (const group of groups) {
|
||||
rootQuery[rootQueryType].push(group);
|
||||
}
|
||||
|
||||
return rootQuery;
|
||||
return rootQuery;
|
||||
};
|
||||
|
||||
// Convert default query to QueryBuilderGroup
|
||||
export const convertNDQueryToQueryGroup = (query: Record<string, any>) => {
|
||||
const rootType = Object.keys(query)[0];
|
||||
const rootGroup: QueryBuilderGroup = {
|
||||
group: [],
|
||||
rules: [],
|
||||
type: rootType as 'any' | 'all',
|
||||
uniqueId: nanoid(),
|
||||
};
|
||||
|
||||
for (const rule of query[rootType]) {
|
||||
if (rule.any || rule.all) {
|
||||
const group = convertNDQueryToQueryGroup(rule);
|
||||
rootGroup.group.push(group);
|
||||
} else {
|
||||
const operator = Object.keys(rule)[0];
|
||||
const field = Object.keys(rule[operator])[0];
|
||||
let value = rule[operator][field];
|
||||
|
||||
const booleanFields = NDSongQueryFields.filter(
|
||||
(queryField) => queryField.type === 'boolean',
|
||||
).map((field) => field.value);
|
||||
|
||||
// Convert boolean values to string
|
||||
if (booleanFields.includes(field)) {
|
||||
value = value.toString();
|
||||
}
|
||||
|
||||
rootGroup.rules.push({
|
||||
field,
|
||||
operator,
|
||||
const rootType = Object.keys(query)[0];
|
||||
const rootGroup: QueryBuilderGroup = {
|
||||
group: [],
|
||||
rules: [],
|
||||
type: rootType as 'any' | 'all',
|
||||
uniqueId: nanoid(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return rootGroup;
|
||||
for (const rule of query[rootType]) {
|
||||
if (rule.any || rule.all) {
|
||||
const group = convertNDQueryToQueryGroup(rule);
|
||||
rootGroup.group.push(group);
|
||||
} else {
|
||||
const operator = Object.keys(rule)[0];
|
||||
const field = Object.keys(rule[operator])[0];
|
||||
let value = rule[operator][field];
|
||||
|
||||
const booleanFields = NDSongQueryFields.filter(
|
||||
(queryField) => queryField.type === 'boolean',
|
||||
).map((field) => field.value);
|
||||
|
||||
// Convert boolean values to string
|
||||
if (booleanFields.includes(field)) {
|
||||
value = value.toString();
|
||||
}
|
||||
|
||||
rootGroup.rules.push({
|
||||
field,
|
||||
operator,
|
||||
uniqueId: nanoid(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rootGroup;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue