Add ability to add/remove songs from playlist (#17)
* Add api for add/remove playlist items * Add playlistItemId property to normalized Song - This is used for Navidrome to delete songs from playlists * Add mutations for add/remove from playlist * Add context modal for playlist add * Add remove from playlist from context menu * Set jellyfin to use playlistItemId * Adjust font sizing * Add playlist add from detail pages * Bump mantine to v6-alpha.2
This commit is contained in:
parent
be39c2bc1f
commit
59f4f43e84
23 changed files with 1120 additions and 982 deletions
1424
package-lock.json
generated
1424
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
@ -254,14 +254,14 @@
|
||||||
"@ag-grid-community/react": "^28.2.1",
|
"@ag-grid-community/react": "^28.2.1",
|
||||||
"@ag-grid-community/styles": "^28.2.1",
|
"@ag-grid-community/styles": "^28.2.1",
|
||||||
"@emotion/react": "^11.10.4",
|
"@emotion/react": "^11.10.4",
|
||||||
"@mantine/core": "^6.0.0-alpha.0",
|
"@mantine/core": "^6.0.0-alpha.2",
|
||||||
"@mantine/dates": "^6.0.0-alpha.0",
|
"@mantine/dates": "^6.0.0-alpha.2",
|
||||||
"@mantine/dropzone": "^6.0.0-alpha.0",
|
"@mantine/dropzone": "^6.0.0-alpha.2",
|
||||||
"@mantine/form": "^6.0.0-alpha.0",
|
"@mantine/form": "^6.0.0-alpha.2",
|
||||||
"@mantine/hooks": "^6.0.0-alpha.0",
|
"@mantine/hooks": "^6.0.0-alpha.2",
|
||||||
"@mantine/modals": "^6.0.0-alpha.0",
|
"@mantine/modals": "^6.0.0-alpha.2",
|
||||||
"@mantine/notifications": "^6.0.0-alpha.0",
|
"@mantine/notifications": "^6.0.0-alpha.2",
|
||||||
"@mantine/utils": "^6.0.0-alpha.0",
|
"@mantine/utils": "^6.0.0-alpha.2",
|
||||||
"@tanstack/react-query": "^4.16.1",
|
"@tanstack/react-query": "^4.16.1",
|
||||||
"@tanstack/react-query-devtools": "^4.16.1",
|
"@tanstack/react-query-devtools": "^4.16.1",
|
||||||
"@tanstack/react-virtual": "^3.0.0-beta.39",
|
"@tanstack/react-virtual": "^3.0.0-beta.39",
|
||||||
|
|
|
@ -39,11 +39,16 @@ import type {
|
||||||
FavoriteArgs,
|
FavoriteArgs,
|
||||||
TopSongListArgs,
|
TopSongListArgs,
|
||||||
RawTopSongListResponse,
|
RawTopSongListResponse,
|
||||||
|
AddToPlaylistArgs,
|
||||||
|
RawAddToPlaylistResponse,
|
||||||
|
RemoveFromPlaylistArgs,
|
||||||
|
RawRemoveFromPlaylistResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||||
|
|
||||||
export type ControllerEndpoint = Partial<{
|
export type ControllerEndpoint = Partial<{
|
||||||
|
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
|
||||||
clearPlaylist: () => void;
|
clearPlaylist: () => void;
|
||||||
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
||||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
|
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
|
||||||
|
@ -69,6 +74,7 @@ export type ControllerEndpoint = Partial<{
|
||||||
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
||||||
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
||||||
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
|
||||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||||
}>;
|
}>;
|
||||||
|
@ -81,6 +87,7 @@ type ApiController = {
|
||||||
|
|
||||||
const endpoints: ApiController = {
|
const endpoints: ApiController = {
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
|
addToPlaylist: jellyfinApi.addToPlaylist,
|
||||||
clearPlaylist: undefined,
|
clearPlaylist: undefined,
|
||||||
createFavorite: jellyfinApi.createFavorite,
|
createFavorite: jellyfinApi.createFavorite,
|
||||||
createPlaylist: jellyfinApi.createPlaylist,
|
createPlaylist: jellyfinApi.createPlaylist,
|
||||||
|
@ -106,10 +113,12 @@ const endpoints: ApiController = {
|
||||||
getSongList: jellyfinApi.getSongList,
|
getSongList: jellyfinApi.getSongList,
|
||||||
getTopSongs: undefined,
|
getTopSongs: undefined,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
|
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
||||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||||
updateRating: undefined,
|
updateRating: undefined,
|
||||||
},
|
},
|
||||||
navidrome: {
|
navidrome: {
|
||||||
|
addToPlaylist: navidromeApi.addToPlaylist,
|
||||||
clearPlaylist: undefined,
|
clearPlaylist: undefined,
|
||||||
createFavorite: subsonicApi.createFavorite,
|
createFavorite: subsonicApi.createFavorite,
|
||||||
createPlaylist: navidromeApi.createPlaylist,
|
createPlaylist: navidromeApi.createPlaylist,
|
||||||
|
@ -135,6 +144,7 @@ const endpoints: ApiController = {
|
||||||
getSongList: navidromeApi.getSongList,
|
getSongList: navidromeApi.getSongList,
|
||||||
getTopSongs: subsonicApi.getTopSongList,
|
getTopSongs: subsonicApi.getTopSongList,
|
||||||
getUserList: navidromeApi.getUserList,
|
getUserList: navidromeApi.getUserList,
|
||||||
|
removeFromPlaylist: navidromeApi.removeFromPlaylist,
|
||||||
updatePlaylist: navidromeApi.updatePlaylist,
|
updatePlaylist: navidromeApi.updatePlaylist,
|
||||||
updateRating: subsonicApi.updateRating,
|
updateRating: subsonicApi.updateRating,
|
||||||
},
|
},
|
||||||
|
@ -239,6 +249,14 @@ const deletePlaylist = async (args: DeletePlaylistArgs) => {
|
||||||
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
|
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addToPlaylist = async (args: AddToPlaylistArgs) => {
|
||||||
|
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
|
||||||
|
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
||||||
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
|
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
|
||||||
};
|
};
|
||||||
|
@ -270,6 +288,7 @@ const getTopSongList = async (args: TopSongListArgs) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
|
addToPlaylist,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
|
@ -287,6 +306,7 @@ export const controller = {
|
||||||
getSongList,
|
getSongList,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
|
removeFromPlaylist,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
updateRating,
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import ky from 'ky';
|
import ky from 'ky';
|
||||||
import { nanoid } from 'nanoid/non-secure';
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
import type {
|
import type {
|
||||||
|
JFAddToPlaylist,
|
||||||
|
JFAddToPlaylistParams,
|
||||||
JFAlbum,
|
JFAlbum,
|
||||||
JFAlbumArtist,
|
JFAlbumArtist,
|
||||||
JFAlbumArtistDetail,
|
JFAlbumArtistDetail,
|
||||||
|
@ -27,6 +29,8 @@ import type {
|
||||||
JFPlaylistDetailResponse,
|
JFPlaylistDetailResponse,
|
||||||
JFPlaylistList,
|
JFPlaylistList,
|
||||||
JFPlaylistListResponse,
|
JFPlaylistListResponse,
|
||||||
|
JFRemoveFromPlaylist,
|
||||||
|
JFRemoveFromPlaylistParams,
|
||||||
JFSong,
|
JFSong,
|
||||||
JFSongList,
|
JFSongList,
|
||||||
JFSongListParams,
|
JFSongListParams,
|
||||||
|
@ -64,6 +68,8 @@ import {
|
||||||
UpdatePlaylistArgs,
|
UpdatePlaylistArgs,
|
||||||
UpdatePlaylistResponse,
|
UpdatePlaylistResponse,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
|
RemoveFromPlaylistArgs,
|
||||||
|
AddToPlaylistArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
|
@ -362,6 +368,45 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<JFAddToPlaylist> => {
|
||||||
|
const { query, body, server, signal } = args;
|
||||||
|
|
||||||
|
const searchParams: JFAddToPlaylistParams = {
|
||||||
|
ids: body.songId,
|
||||||
|
userId: server?.userId || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await api
|
||||||
|
.post(`playlists/${query.id}/items`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams: parseSearchParams(searchParams),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<JFPlaylistDetailResponse>();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<JFRemoveFromPlaylist> => {
|
||||||
|
const { query, server, signal } = args;
|
||||||
|
|
||||||
|
const searchParams: JFRemoveFromPlaylistParams = {
|
||||||
|
entryIds: query.songId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await api
|
||||||
|
.delete(`playlists/${query.id}/items`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams: parseSearchParams(searchParams),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<JFPlaylistDetailResponse>();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
|
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
|
||||||
const { query, server, signal } = args;
|
const { query, server, signal } = args;
|
||||||
|
|
||||||
|
@ -677,6 +722,7 @@ const normalizeSong = (
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||||
|
playlistItemId: item.PlaylistItemId,
|
||||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||||
|
@ -863,6 +909,7 @@ const normalizePlaylist = (
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export const jellyfinApi = {
|
export const jellyfinApi = {
|
||||||
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
@ -879,6 +926,7 @@ export const jellyfinApi = {
|
||||||
getPlaylistList,
|
getPlaylistList,
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
getSongList,
|
getSongList,
|
||||||
|
removeFromPlaylist,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,25 @@ export type JFSongList = {
|
||||||
totalRecordCount: number;
|
totalRecordCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JFAddToPlaylistResponse = {
|
||||||
|
added: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JFAddToPlaylistParams = {
|
||||||
|
ids: string[];
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JFAddToPlaylist = null;
|
||||||
|
|
||||||
|
export type JFRemoveFromPlaylistResponse = null;
|
||||||
|
|
||||||
|
export type JFRemoveFromPlaylistParams = {
|
||||||
|
entryIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JFRemoveFromPlaylist = null;
|
||||||
|
|
||||||
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||||
Items: JFPlaylist[];
|
Items: JFPlaylist[];
|
||||||
}
|
}
|
||||||
|
@ -252,6 +271,7 @@ export type JFSong = {
|
||||||
MediaType: string;
|
MediaType: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
ParentIndexNumber: number;
|
ParentIndexNumber: number;
|
||||||
|
PlaylistItemId?: string;
|
||||||
PremiereDate?: string;
|
PremiereDate?: string;
|
||||||
ProductionYear: number;
|
ProductionYear: number;
|
||||||
RunTimeTicks: number;
|
RunTimeTicks: number;
|
||||||
|
|
|
@ -41,6 +41,12 @@ import type {
|
||||||
NDUserListResponse,
|
NDUserListResponse,
|
||||||
NDUserListParams,
|
NDUserListParams,
|
||||||
NDUser,
|
NDUser,
|
||||||
|
NDAddToPlaylist,
|
||||||
|
NDAddToPlaylistBody,
|
||||||
|
NDAddToPlaylistResponse,
|
||||||
|
NDRemoveFromPlaylistParams,
|
||||||
|
NDRemoveFromPlaylistResponse,
|
||||||
|
NDRemoveFromPlaylist,
|
||||||
} from '/@/renderer/api/navidrome.types';
|
} from '/@/renderer/api/navidrome.types';
|
||||||
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||||
import {
|
import {
|
||||||
|
@ -73,6 +79,8 @@ import {
|
||||||
sortOrderMap,
|
sortOrderMap,
|
||||||
User,
|
User,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
|
AddToPlaylistArgs,
|
||||||
|
RemoveFromPlaylistArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { toast } from '/@/renderer/components/toast';
|
import { toast } from '/@/renderer/components/toast';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
@ -472,6 +480,44 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlayli
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
|
||||||
|
const { query, body, server, signal } = args;
|
||||||
|
|
||||||
|
const json: NDAddToPlaylistBody = {
|
||||||
|
ids: body.songId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await api
|
||||||
|
.post(`api/playlist/${query.id}/tracks`, {
|
||||||
|
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||||
|
json,
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<NDAddToPlaylistResponse>();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
|
||||||
|
const { query, server, signal } = args;
|
||||||
|
|
||||||
|
const searchParams: NDRemoveFromPlaylistParams = {
|
||||||
|
id: query.songId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await api
|
||||||
|
.delete(`api/playlist/${query.id}/tracks`, {
|
||||||
|
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams: parseSearchParams(searchParams),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<NDRemoveFromPlaylistResponse>();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const getCoverArtUrl = (args: {
|
const getCoverArtUrl = (args: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
coverArtId: string;
|
coverArtId: string;
|
||||||
|
@ -501,10 +547,12 @@ const normalizeSong = (
|
||||||
imageSize?: number,
|
imageSize?: number,
|
||||||
): Song => {
|
): Song => {
|
||||||
let id;
|
let id;
|
||||||
|
let playlistItemId;
|
||||||
|
|
||||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||||
if ('mediaFileId' in item) {
|
if ('mediaFileId' in item) {
|
||||||
id = item.mediaFileId;
|
id = item.mediaFileId;
|
||||||
|
playlistItemId = item.id;
|
||||||
} else {
|
} else {
|
||||||
id = item.id;
|
id = item.id;
|
||||||
}
|
}
|
||||||
|
@ -542,6 +590,7 @@ const normalizeSong = (
|
||||||
name: item.title,
|
name: item.title,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
|
playlistItemId,
|
||||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||||
releaseYear: String(item.year),
|
releaseYear: String(item.year),
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
|
@ -675,6 +724,7 @@ const normalizeUser = (item: NDUser): User => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const navidromeApi = {
|
export const navidromeApi = {
|
||||||
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
|
@ -689,6 +739,7 @@ export const navidromeApi = {
|
||||||
getSongDetail,
|
getSongDetail,
|
||||||
getSongList,
|
getSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
|
removeFromPlaylist,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -274,6 +274,26 @@ export type NDAlbumArtistListParams = {
|
||||||
} & NDPagination &
|
} & NDPagination &
|
||||||
NDOrder;
|
NDOrder;
|
||||||
|
|
||||||
|
export type NDAddToPlaylistResponse = {
|
||||||
|
added: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDAddToPlaylistBody = {
|
||||||
|
ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDAddToPlaylist = null;
|
||||||
|
|
||||||
|
export type NDRemoveFromPlaylistResponse = {
|
||||||
|
ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDRemoveFromPlaylistParams = {
|
||||||
|
id: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDRemoveFromPlaylist = null;
|
||||||
|
|
||||||
export type NDCreatePlaylistParams = {
|
export type NDCreatePlaylistParams = {
|
||||||
comment?: string;
|
comment?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -205,6 +205,7 @@ export type Song = {
|
||||||
name: string;
|
name: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
playCount: number;
|
playCount: number;
|
||||||
|
playlistItemId?: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseYear: string | null;
|
releaseYear: string | null;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
@ -777,6 +778,32 @@ export type RatingQuery = { id: string[]; rating: number };
|
||||||
|
|
||||||
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Add to playlist
|
||||||
|
export type RawAddToPlaylistResponse = null | undefined;
|
||||||
|
|
||||||
|
export type AddToPlaylistQuery = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddToPlaylistBody = {
|
||||||
|
songId: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddToPlaylistArgs = {
|
||||||
|
body: AddToPlaylistBody;
|
||||||
|
query: AddToPlaylistQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Remove from playlist
|
||||||
|
export type RawRemoveFromPlaylistResponse = null | undefined;
|
||||||
|
|
||||||
|
export type RemoveFromPlaylistQuery = {
|
||||||
|
id: string;
|
||||||
|
songId: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Create Playlist
|
// Create Playlist
|
||||||
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
|
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
|
||||||
|
|
||||||
|
@ -850,6 +877,7 @@ export type PlaylistListQuery = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
ndParams?: {
|
ndParams?: {
|
||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
|
smart?: boolean;
|
||||||
};
|
};
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
sortBy: PlaylistListSort;
|
sortBy: PlaylistListSort;
|
||||||
|
|
|
@ -15,6 +15,7 @@ import '@ag-grid-community/styles/ag-grid.css';
|
||||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||||
|
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||||
|
|
||||||
|
@ -53,10 +54,10 @@ export const App = () => {
|
||||||
},
|
},
|
||||||
fontFamily: 'var(--content-font-family)',
|
fontFamily: 'var(--content-font-family)',
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
lg: '1.5rem',
|
lg: '1.1rem',
|
||||||
md: '1.1rem',
|
md: '1rem',
|
||||||
sm: '0.9rem',
|
sm: '0.9rem',
|
||||||
xl: '2rem',
|
xl: '1.5rem',
|
||||||
xs: '0.8rem',
|
xs: '0.8rem',
|
||||||
},
|
},
|
||||||
headings: {
|
headings: {
|
||||||
|
@ -90,7 +91,7 @@ export const App = () => {
|
||||||
transition: 'slide-down',
|
transition: 'slide-down',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
modals={{ base: BaseContextModal }}
|
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||||
>
|
>
|
||||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { MouseEvent } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { UnstyledButtonProps } from '@mantine/core';
|
import type { UnstyledButtonProps } from '@mantine/core';
|
||||||
import { Group } from '@mantine/core';
|
import { Group } from '@mantine/core';
|
||||||
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { _Button } from '/@/renderer/components/button';
|
import { _Button } from '/@/renderer/components/button';
|
||||||
|
@ -137,6 +138,19 @@ export const CardControls = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAddToPlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openContextModal({
|
||||||
|
innerProps: {
|
||||||
|
albumId: itemType === LibraryItem.ALBUM ? [itemData.id] : undefined,
|
||||||
|
artistId: itemType === LibraryItem.ALBUM_ARTIST ? [itemData.id] : undefined,
|
||||||
|
},
|
||||||
|
modal: 'addToPlaylist',
|
||||||
|
size: 'md',
|
||||||
|
title: 'Add to playlist',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCardControlsContainer>
|
<GridCardControlsContainer>
|
||||||
<BottomControls>
|
<BottomControls>
|
||||||
|
@ -190,8 +204,9 @@ export const CardControls = ({
|
||||||
{type.label}
|
{type.label}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
|
||||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
Add to playlist
|
||||||
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Dropdown>
|
</DropdownMenu.Dropdown>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { MouseEvent } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { UnstyledButtonProps } from '@mantine/core';
|
import type { UnstyledButtonProps } from '@mantine/core';
|
||||||
import { Group } from '@mantine/core';
|
import { Group } from '@mantine/core';
|
||||||
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { _Button } from '/@/renderer/components/button';
|
import { _Button } from '/@/renderer/components/button';
|
||||||
|
@ -151,6 +152,19 @@ export const GridCardControls = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAddToPlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openContextModal({
|
||||||
|
innerProps: {
|
||||||
|
albumId: itemType === LibraryItem.ALBUM ? [itemData.id] : undefined,
|
||||||
|
artistId: itemType === LibraryItem.ALBUM_ARTIST ? [itemData.id] : undefined,
|
||||||
|
},
|
||||||
|
modal: 'addToPlaylist',
|
||||||
|
size: 'md',
|
||||||
|
title: 'Add to playlist',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCardControlsContainer>
|
<GridCardControlsContainer>
|
||||||
{/* <TopControls /> */}
|
{/* <TopControls /> */}
|
||||||
|
@ -206,8 +220,9 @@ export const GridCardControls = ({
|
||||||
{type.label}
|
{type.label}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
|
||||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
Add to playlist
|
||||||
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Dropdown>
|
</DropdownMenu.Dropdown>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||||
import { Box, Group, Stack } from '@mantine/core';
|
import { Box, Group, Stack } from '@mantine/core';
|
||||||
import { useSetState } from '@mantine/hooks';
|
import { useSetState } from '@mantine/hooks';
|
||||||
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||||
import { generatePath, useParams } from 'react-router';
|
import { generatePath, useParams } from 'react-router';
|
||||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||||
|
@ -182,6 +183,17 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||||
|
|
||||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||||
|
|
||||||
|
const handleAddToPlaylist = () => {
|
||||||
|
openContextModal({
|
||||||
|
innerProps: {
|
||||||
|
albumId: [albumId],
|
||||||
|
},
|
||||||
|
modal: 'addToPlaylist',
|
||||||
|
size: 'md',
|
||||||
|
title: 'Add to playlist',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentContainer>
|
<ContentContainer>
|
||||||
<Box component="section">
|
<Box component="section">
|
||||||
|
@ -227,7 +239,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
<DropdownMenu.Divider />
|
<DropdownMenu.Divider />
|
||||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
<DropdownMenu.Item onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
|
||||||
</DropdownMenu.Dropdown>
|
</DropdownMenu.Dropdown>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from '/@/renderer/components';
|
} from '/@/renderer/components';
|
||||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||||
import { Box, Group, Stack } from '@mantine/core';
|
import { Box, Group, Stack } from '@mantine/core';
|
||||||
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||||
import { generatePath, useParams } from 'react-router';
|
import { generatePath, useParams } from 'react-router';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
@ -266,6 +267,17 @@ export const AlbumArtistDetailContent = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddToPlaylist = () => {
|
||||||
|
openContextModal({
|
||||||
|
innerProps: {
|
||||||
|
artistId: [albumArtistId],
|
||||||
|
},
|
||||||
|
modal: 'addToPlaylist',
|
||||||
|
size: 'md',
|
||||||
|
title: 'Add to playlist',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||||
|
|
||||||
const showBiography =
|
const showBiography =
|
||||||
|
@ -318,7 +330,7 @@ export const AlbumArtistDetailContent = () => {
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
<DropdownMenu.Divider />
|
<DropdownMenu.Divider />
|
||||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
<DropdownMenu.Item onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
|
||||||
</DropdownMenu.Dropdown>
|
</DropdownMenu.Dropdown>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -4,9 +4,20 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ divider: true, id: 'playNext' },
|
||||||
{ disabled: true, id: 'addToPlaylist' },
|
{ divider: true, id: 'addToPlaylist' },
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
|
{ disabled: true, id: 'setRating' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
|
{ id: 'play' },
|
||||||
|
{ id: 'playLast' },
|
||||||
|
{ divider: true, id: 'playNext' },
|
||||||
|
{ id: 'addToPlaylist' },
|
||||||
|
{ divider: true, id: 'removeFromPlaylist' },
|
||||||
|
{ id: 'addToFavorites' },
|
||||||
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ disabled: true, id: 'setRating' },
|
{ disabled: true, id: 'setRating' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -14,7 +25,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ divider: true, id: 'playNext' },
|
||||||
{ disabled: true, id: 'addToPlaylist' },
|
{ divider: true, id: 'addToPlaylist' },
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ id: 'removeFromFavorites' },
|
{ id: 'removeFromFavorites' },
|
||||||
{ disabled: true, id: 'setRating' },
|
{ disabled: true, id: 'setRating' },
|
||||||
|
@ -24,9 +35,9 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ divider: true, id: 'playNext' },
|
||||||
{ disabled: true, id: 'addToPlaylist' },
|
{ divider: true, id: 'addToPlaylist' },
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ disabled: true, id: 'setRating' },
|
{ disabled: true, id: 'setRating' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Divider, Group, Stack } from '@mantine/core';
|
import { Divider, Group, Stack } from '@mantine/core';
|
||||||
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
|
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
|
||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
|
||||||
import { createContext, Fragment, useState } from 'react';
|
import { createContext, Fragment, useState } from 'react';
|
||||||
import { LibraryItem } from '/@/renderer/api/types';
|
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||||
import { ConfirmModal, ContextMenu, ContextMenuButton, Text, toast } from '/@/renderer/components';
|
import { ConfirmModal, ContextMenu, ContextMenuButton, Text, toast } from '/@/renderer/components';
|
||||||
import {
|
import {
|
||||||
OpenContextMenuProps,
|
OpenContextMenuProps,
|
||||||
|
@ -10,7 +10,9 @@ import {
|
||||||
} from '/@/renderer/features/context-menu/events';
|
} from '/@/renderer/features/context-menu/events';
|
||||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||||
import { useDeletePlaylist } from '/@/renderer/features/playlists';
|
import { useDeletePlaylist } from '/@/renderer/features/playlists';
|
||||||
|
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
|
||||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { Play } from '/@/renderer/types';
|
import { Play } from '/@/renderer/types';
|
||||||
|
|
||||||
type ContextMenuContextProps = {
|
type ContextMenuContextProps = {
|
||||||
|
@ -33,6 +35,8 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const clickOutsideRef = useClickOutside(() => setOpened(false));
|
const clickOutsideRef = useClickOutside(() => setOpened(false));
|
||||||
const viewport = useViewportSize();
|
const viewport = useViewportSize();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const serverType = server?.type;
|
||||||
const [ref, menuRect] = useResizeObserver();
|
const [ref, menuRect] = useResizeObserver();
|
||||||
const [ctx, setCtx] = useSetState<OpenContextMenuProps>({
|
const [ctx, setCtx] = useSetState<OpenContextMenuProps>({
|
||||||
data: [],
|
data: [],
|
||||||
|
@ -47,7 +51,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
|
|
||||||
const openContextMenu = (args: OpenContextMenuProps) => {
|
const openContextMenu = (args: OpenContextMenuProps) => {
|
||||||
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes } = args;
|
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args;
|
||||||
|
|
||||||
const shouldReverseY = yPos + menuRect.height > viewport.height;
|
const shouldReverseY = yPos + menuRect.height > viewport.height;
|
||||||
const shouldReverseX = xPos + menuRect.width > viewport.width;
|
const shouldReverseX = xPos + menuRect.width > viewport.width;
|
||||||
|
@ -56,6 +60,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos;
|
const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos;
|
||||||
|
|
||||||
setCtx({
|
setCtx({
|
||||||
|
context,
|
||||||
data,
|
data,
|
||||||
dataNodes,
|
dataNodes,
|
||||||
menuItems,
|
menuItems,
|
||||||
|
@ -216,13 +221,78 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddToPlaylist = () => {
|
||||||
|
if (!ctx.dataNodes) return;
|
||||||
|
openContextModal({
|
||||||
|
innerProps: {
|
||||||
|
albumId:
|
||||||
|
ctx.type === LibraryItem.ALBUM ? ctx.dataNodes.map((node) => node.data.id) : undefined,
|
||||||
|
artistId:
|
||||||
|
ctx.type === LibraryItem.ARTIST ? ctx.dataNodes.map((node) => node.data.id) : undefined,
|
||||||
|
songId:
|
||||||
|
ctx.type === LibraryItem.SONG ? ctx.dataNodes.map((node) => node.data.id) : undefined,
|
||||||
|
},
|
||||||
|
modal: 'addToPlaylist',
|
||||||
|
size: 'md',
|
||||||
|
title: 'Add to playlist',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylistMutation = useRemoveFromPlaylist();
|
||||||
|
|
||||||
|
const handleRemoveFromPlaylist = () => {
|
||||||
|
const songId =
|
||||||
|
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
|
||||||
|
? ctx.dataNodes?.map((node) => node.data.playlistItemId)
|
||||||
|
: ctx.dataNodes?.map((node) => node.data.id)) || [];
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
removeFromPlaylistMutation.mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
id: ctx.context.playlistId,
|
||||||
|
songId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error({
|
||||||
|
message: err.message,
|
||||||
|
title: 'Error removing song(s) from playlist',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success({
|
||||||
|
message: `${songId.length} song(s) were removed from the playlist`,
|
||||||
|
title: 'Song(s) removed from playlist',
|
||||||
|
});
|
||||||
|
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
|
||||||
|
closeAllModals();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
children: (
|
||||||
|
<ConfirmModal
|
||||||
|
loading={removeFromPlaylistMutation.isLoading}
|
||||||
|
onConfirm={confirm}
|
||||||
|
>
|
||||||
|
Are you sure you want to remove the following song(s) from the playlist?
|
||||||
|
</ConfirmModal>
|
||||||
|
),
|
||||||
|
title: 'Remove song(s) from playlist',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const contextMenuItems = {
|
const contextMenuItems = {
|
||||||
addToFavorites: {
|
addToFavorites: {
|
||||||
id: 'addToFavorites',
|
id: 'addToFavorites',
|
||||||
label: 'Add to favorites',
|
label: 'Add to favorites',
|
||||||
onClick: handleAddToFavorites,
|
onClick: handleAddToFavorites,
|
||||||
},
|
},
|
||||||
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
|
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: handleAddToPlaylist },
|
||||||
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
||||||
deletePlaylist: {
|
deletePlaylist: {
|
||||||
id: 'deletePlaylist',
|
id: 'deletePlaylist',
|
||||||
|
@ -249,6 +319,11 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
label: 'Remove from favorites',
|
label: 'Remove from favorites',
|
||||||
onClick: handleRemoveFromFavorites,
|
onClick: handleRemoveFromFavorites,
|
||||||
},
|
},
|
||||||
|
removeFromPlaylist: {
|
||||||
|
id: 'removeFromPlaylist',
|
||||||
|
label: 'Remove from playlist',
|
||||||
|
onClick: handleRemoveFromPlaylist,
|
||||||
|
},
|
||||||
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
|
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { MutableRefObject } from 'react';
|
||||||
import { LibraryItem } from '/@/renderer/api/types';
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
|
||||||
export type OpenContextMenuProps = {
|
export type OpenContextMenuProps = {
|
||||||
|
context?: any;
|
||||||
data: any[];
|
data: any[];
|
||||||
dataNodes?: RowNode[];
|
dataNodes?: RowNode[];
|
||||||
menuItems: SetContextMenuItems;
|
menuItems: SetContextMenuItems;
|
||||||
|
@ -24,6 +25,7 @@ export type ContextMenuItem =
|
||||||
| 'playLast'
|
| 'playLast'
|
||||||
| 'playNext'
|
| 'playNext'
|
||||||
| 'addToPlaylist'
|
| 'addToPlaylist'
|
||||||
|
| 'removeFromPlaylist'
|
||||||
| 'addToFavorites'
|
| 'addToFavorites'
|
||||||
| 'removeFromFavorites'
|
| 'removeFromFavorites'
|
||||||
| 'setRating'
|
| 'setRating'
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/conte
|
||||||
export const useHandleTableContextMenu = (
|
export const useHandleTableContextMenu = (
|
||||||
itemType: LibraryItem,
|
itemType: LibraryItem,
|
||||||
contextMenuItems: SetContextMenuItems,
|
contextMenuItems: SetContextMenuItems,
|
||||||
|
context?: any,
|
||||||
) => {
|
) => {
|
||||||
const handleContextMenu = (e: CellContextMenuEvent) => {
|
const handleContextMenu = (e: CellContextMenuEvent) => {
|
||||||
if (!e.event) return;
|
if (!e.event) return;
|
||||||
|
@ -25,6 +26,7 @@ export const useHandleTableContextMenu = (
|
||||||
}
|
}
|
||||||
|
|
||||||
openContextMenu({
|
openContextMenu({
|
||||||
|
context,
|
||||||
data: selectedRows,
|
data: selectedRows,
|
||||||
dataNodes: selectedNodes,
|
dataNodes: selectedNodes,
|
||||||
menuItems: contextMenuItems,
|
menuItems: contextMenuItems,
|
||||||
|
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { Box, Group, Stack } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { PlaylistListSort, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||||
|
import { Button, MultiSelect, Switch, toast } from '/@/renderer/components';
|
||||||
|
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
||||||
|
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||||
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
|
||||||
|
export const AddToPlaylistContextModal = ({
|
||||||
|
id,
|
||||||
|
innerProps,
|
||||||
|
}: ContextModalProps<{
|
||||||
|
albumId?: string[];
|
||||||
|
artistId?: string[];
|
||||||
|
songId?: string[];
|
||||||
|
}>) => {
|
||||||
|
const { albumId, artistId, songId } = innerProps;
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const addToPlaylistMutation = useAddToPlaylist();
|
||||||
|
|
||||||
|
const playlistList = usePlaylistList({
|
||||||
|
ndParams: {
|
||||||
|
smart: false,
|
||||||
|
},
|
||||||
|
sortBy: PlaylistListSort.NAME,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
startIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }) =>
|
||||||
|
api.controller.getSongList({ query, server, signal }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return api.normalize.songList(songsRes, server);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }) =>
|
||||||
|
api.controller.getSongList({ query, server, signal }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return api.normalize.songList(songsRes, server);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||||
|
|
||||||
|
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
|
||||||
|
api.controller.getPlaylistSongList({
|
||||||
|
query: { id: playlistId, startIndex: 0 },
|
||||||
|
server,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const playlistSongIds = api.normalize
|
||||||
|
.songList(playlistSongsRes, server)
|
||||||
|
.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) {
|
||||||
|
addToPlaylistMutation.mutate(
|
||||||
|
{
|
||||||
|
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||||
|
query: { id: playlistId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -28,7 +28,7 @@ import {
|
||||||
VirtualTable,
|
VirtualTable,
|
||||||
} from '/@/renderer/components';
|
} from '/@/renderer/components';
|
||||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
import { PLAYLIST_SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||||
import { UpdatePlaylistForm } from '/@/renderer/features/playlists/components/update-playlist-form';
|
import { UpdatePlaylistForm } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||||
|
@ -90,7 +90,11 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
|
||||||
[page.table.columns],
|
[page.table.columns],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
const handleContextMenu = useHandleTableContextMenu(
|
||||||
|
LibraryItem.SONG,
|
||||||
|
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||||
|
{ playlistId },
|
||||||
|
);
|
||||||
|
|
||||||
const playlistSongData = useMemo(
|
const playlistSongData = useMemo(
|
||||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p.items),
|
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p.items),
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
import { PLAYLIST_SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import {
|
import {
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
|
@ -185,7 +185,11 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||||
setPagination(playlistId, { scrollOffset });
|
setPagination(playlistId, { scrollOffset });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
const handleContextMenu = useHandleTableContextMenu(
|
||||||
|
LibraryItem.SONG,
|
||||||
|
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||||
|
{ playlistId, tableRef },
|
||||||
|
);
|
||||||
|
|
||||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||||
if (!e.data) return;
|
if (!e.data) return;
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
export * from './queries/playlist-list-query';
|
export * from './queries/playlist-list-query';
|
||||||
|
export * from './components/add-to-playlist-context-modal';
|
||||||
export * from './components/create-playlist-form';
|
export * from './components/create-playlist-form';
|
||||||
export * from './mutations/delete-playlist-mutation';
|
export * from './mutations/delete-playlist-mutation';
|
||||||
export * from './mutations/create-playlist-mutation';
|
export * from './mutations/create-playlist-mutation';
|
||||||
export * from './mutations/update-playlist-mutation';
|
export * from './mutations/update-playlist-mutation';
|
||||||
|
export * from './mutations/add-to-playlist-mutation';
|
||||||
|
export * from './mutations/remove-from-playlist-mutation';
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { HTTPError } from 'ky';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { AddToPlaylistArgs, RawAddToPlaylistResponse } from '/@/renderer/api/types';
|
||||||
|
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
|
||||||
|
export const useAddToPlaylist = (options?: MutationOptions) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
|
||||||
|
return useMutation<RawAddToPlaylistResponse, HTTPError, Omit<AddToPlaylistArgs, 'server'>, null>({
|
||||||
|
mutationFn: (args) => api.controller.addToPlaylist({ ...args, server }),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
queryKeys.playlists.detail(server?.id || '', variables.query.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
queryKeys.playlists.detailSongList(server?.id || '', variables.query.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { HTTPError } from 'ky';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { RawRemoveFromPlaylistResponse, RemoveFromPlaylistArgs } from '/@/renderer/api/types';
|
||||||
|
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
|
||||||
|
export const useRemoveFromPlaylist = (options?: MutationOptions) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
RawRemoveFromPlaylistResponse,
|
||||||
|
HTTPError,
|
||||||
|
Omit<RemoveFromPlaylistArgs, 'server'>,
|
||||||
|
null
|
||||||
|
>({
|
||||||
|
mutationFn: (args) => api.controller.removeFromPlaylist({ ...args, server }),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
queryKeys.playlists.detail(server?.id || '', variables.query.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
queryKeys.playlists.detailSongList(server?.id || '', variables.query.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
Reference in a new issue