mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add support for full playlist re-order (#1327)
This commit is contained in:
@@ -13,6 +13,10 @@
|
||||
"moveToNext": "move to next",
|
||||
"moveToBottom": "move to bottom",
|
||||
"moveToTop": "move to top",
|
||||
"moveUp": "move up",
|
||||
"moveDown": "move down",
|
||||
"holdToMoveToTop": "hold to move to top",
|
||||
"holdToMoveToBottom": "hold to move to bottom",
|
||||
"moveItems": "move items",
|
||||
"shuffle": "shuffle",
|
||||
"shuffleAll": "shuffle all",
|
||||
@@ -69,6 +73,7 @@
|
||||
"dismiss": "dismiss",
|
||||
"doNotShowAgain": "do not show this again",
|
||||
"duration": "duration",
|
||||
"view": "view",
|
||||
"edit": "edit",
|
||||
"enable": "enable",
|
||||
"expand": "expand",
|
||||
@@ -317,6 +322,7 @@
|
||||
},
|
||||
"editPlaylist": {
|
||||
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
|
||||
"editNote": "manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?",
|
||||
"success": "$t(entity.playlist_one) updated successfully",
|
||||
"title": "edit $t(entity.playlist_one)"
|
||||
},
|
||||
|
||||
@@ -628,6 +628,20 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
replacePlaylist(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: replacePlaylist`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'replacePlaylist',
|
||||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
scrobble(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { set } from 'idb-keyval';
|
||||
import chunk from 'lodash/chunk';
|
||||
import filter from 'lodash/filter';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
@@ -1162,6 +1163,113 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
replacePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
// 1. Fetch existing songs from the playlist
|
||||
const existingSongsRes = await jfApiClient(apiClientProps).getPlaylistSongList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
|
||||
IncludeItemTypes: 'Audio',
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSongsRes.status !== 200) {
|
||||
throw new Error('Failed to fetch existing playlist songs');
|
||||
}
|
||||
|
||||
const existingSongs = existingSongsRes.body.Items.map((item) =>
|
||||
jfNormalize.song(item, apiClientProps.server),
|
||||
);
|
||||
|
||||
// 2. Get playlist detail to get the name
|
||||
const playlistDetailRes = await jfApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
|
||||
Ids: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (playlistDetailRes.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
|
||||
const playlist = jfNormalize.playlist(playlistDetailRes.body, apiClientProps.server);
|
||||
|
||||
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
|
||||
const backup = {
|
||||
id: query.id,
|
||||
name: playlist.name,
|
||||
songIds: existingSongs.map((song) => song.id),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Store backup in IndexedDB using idb-keyval
|
||||
const backupKey = `playlist-backup-${query.id}`;
|
||||
await set(backupKey, backup);
|
||||
|
||||
// 4. Remove all songs from the playlist
|
||||
if (existingSongs.length > 0) {
|
||||
const existingPlaylistItemIds = existingSongs
|
||||
.map((song) => song.playlistItemId)
|
||||
.filter((id): id is string => id !== undefined && id !== null);
|
||||
|
||||
if (existingPlaylistItemIds.length > 0) {
|
||||
const chunks = chunk(existingPlaylistItemIds, MAX_ITEMS_PER_PLAYLIST_ADD);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const removeRes = await jfApiClient(apiClientProps).removeFromPlaylist({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
EntryIds: chunk.join(','),
|
||||
},
|
||||
});
|
||||
|
||||
if (removeRes.status !== 204) {
|
||||
throw new Error('Failed to remove songs from playlist');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Add the new song ids to the playlist
|
||||
if (body.songId.length > 0) {
|
||||
const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const addRes = await jfApiClient(apiClientProps).addToPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Ids: chunk.join(','),
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (addRes.status !== 204) {
|
||||
throw new Error('Failed to add songs to playlist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
scrobble: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { set } from 'idb-keyval';
|
||||
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
@@ -782,6 +784,95 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
replacePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
// 1. Fetch existing songs from the playlist without any sorts
|
||||
const existingSongsRes = await ndApiClient(apiClientProps as any).getPlaylistSongList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
_end: -1,
|
||||
_order: 'ASC',
|
||||
_start: 0,
|
||||
...excludeMissing(apiClientProps.server),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSongsRes.status !== 200) {
|
||||
throw new Error('Failed to fetch existing playlist songs');
|
||||
}
|
||||
|
||||
const existingSongs = existingSongsRes.body.data.map((item) =>
|
||||
ndNormalize.song(item, apiClientProps.server),
|
||||
);
|
||||
|
||||
// 2. Get playlist detail to get the name
|
||||
const playlistDetailRes = await ndApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (playlistDetailRes.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
|
||||
const playlist = ndNormalize.playlist(playlistDetailRes.body.data, apiClientProps.server);
|
||||
|
||||
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
|
||||
const backup = {
|
||||
id: query.id,
|
||||
name: playlist.name,
|
||||
songIds: existingSongs.map((song) => song.id),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Store backup in IndexedDB using idb-keyval
|
||||
const backupKey = `playlist-backup-${query.id}`;
|
||||
await set(backupKey, backup);
|
||||
|
||||
// 4. Remove all songs from the playlist
|
||||
if (existingSongs.length > 0) {
|
||||
const existingPlaylistItemIds = existingSongs
|
||||
.map((song) => song.playlistItemId)
|
||||
.filter((id): id is string => id !== undefined && id !== null);
|
||||
|
||||
if (existingPlaylistItemIds.length > 0) {
|
||||
const removeRes = await ndApiClient(apiClientProps).removeFromPlaylist({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
id: existingPlaylistItemIds,
|
||||
},
|
||||
});
|
||||
|
||||
if (removeRes.status !== 200) {
|
||||
throw new Error('Failed to remove songs from playlist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Add the new song ids to the playlist
|
||||
if (body.songId.length > 0) {
|
||||
const addRes = await ndApiClient(apiClientProps).addToPlaylist({
|
||||
body: {
|
||||
ids: body.songId,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (addRes.status !== 200) {
|
||||
throw new Error('Failed to add songs to playlist');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
scrobble: SubsonicController.scrobble,
|
||||
search: SubsonicController.search,
|
||||
setRating: SubsonicController.setRating,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ServerInferResponses } from '@ts-rest/core';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { set } from 'idb-keyval';
|
||||
import filter from 'lodash/filter';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import md5 from 'md5';
|
||||
@@ -1479,6 +1480,87 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
replacePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
// 1. Fetch existing songs from the playlist
|
||||
const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({
|
||||
query: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSongsRes.status !== 200) {
|
||||
throw new Error('Failed to fetch existing playlist songs');
|
||||
}
|
||||
|
||||
const existingSongs =
|
||||
existingSongsRes.body.playlist.entry?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
) || [];
|
||||
|
||||
// 2. Get playlist detail to get the name
|
||||
const playlistDetailRes = await ssApiClient(apiClientProps).getPlaylist({
|
||||
query: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (playlistDetailRes.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
|
||||
const playlist = ssNormalize.playlist(
|
||||
playlistDetailRes.body.playlist,
|
||||
apiClientProps.server,
|
||||
);
|
||||
|
||||
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
|
||||
const backup = {
|
||||
id: query.id,
|
||||
name: playlist.name,
|
||||
songIds: existingSongs.map((song) => song.id),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Store backup in IndexedDB using idb-keyval
|
||||
const backupKey = `playlist-backup-${query.id}`;
|
||||
await set(backupKey, backup);
|
||||
|
||||
// 4. Remove all songs from the playlist (Subsonic uses indices, not IDs)
|
||||
if (existingSongs.length > 0) {
|
||||
// Get indices of all songs (0-based)
|
||||
// Remove in reverse order to avoid index shifting issues
|
||||
const songIndices = existingSongs.map((_, index) => index).reverse();
|
||||
|
||||
const removeRes = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
playlistId: query.id,
|
||||
songIndexToRemove: songIndices.map((index) => index.toString()),
|
||||
},
|
||||
});
|
||||
|
||||
if (removeRes.status !== 200) {
|
||||
throw new Error('Failed to remove songs from playlist');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Add the new song ids to the playlist
|
||||
if (body.songId.length > 0) {
|
||||
const addRes = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
playlistId: query.id,
|
||||
songIdToAdd: body.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (addRes.status !== 200) {
|
||||
throw new Error('Failed to add songs to playlist');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
scrobble: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -295,8 +295,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(item, itemType);
|
||||
|
||||
// For context menus, prioritize the itemType prop when it's PLAYLIST_SONG or QUEUE_SONG
|
||||
// This is because playlist/queue songs are Song objects (_itemType: SONG) but need special context menus
|
||||
// Otherwise, use the item's _itemType if available, or fall back to the mapped itemType
|
||||
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import {
|
||||
ItemTableListInnerColumn,
|
||||
TableColumnContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLongPress } from '/@/shared/hooks/use-long-press';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
||||
|
||||
export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId?: string };
|
||||
const isHeaderEnabled = !!props.enableHeader;
|
||||
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
|
||||
const item = isDataRow ? props.data[props.rowIndex] : null;
|
||||
|
||||
const isPlaylistSong = props.itemType === LibraryItem.PLAYLIST_SONG;
|
||||
|
||||
const { isDraggedOver, ref: dragRef } = useDragDrop<HTMLButtonElement>({
|
||||
drag: {
|
||||
getId: () => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, props.internalState);
|
||||
return draggedItems.map((draggedItem) => draggedItem.id);
|
||||
},
|
||||
getItem: () => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, props.internalState);
|
||||
return draggedItems;
|
||||
},
|
||||
itemType: LibraryItem.PLAYLIST_SONG,
|
||||
metadata: { fromReorderHandle: true },
|
||||
onDragStart: () => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, props.internalState);
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging(draggedItems);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging([]);
|
||||
}
|
||||
},
|
||||
operation: [DragOperation.REORDER],
|
||||
target: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,
|
||||
},
|
||||
drop: {
|
||||
canDrop: (args) => {
|
||||
// Only allow drops from PLAYLIST_SONG items
|
||||
return (
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
isPlaylistSong &&
|
||||
isDataRow
|
||||
);
|
||||
},
|
||||
getData: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return {
|
||||
id: [],
|
||||
item: [],
|
||||
itemType: LibraryItem.PLAYLIST_SONG,
|
||||
type: DragTarget.SONG,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: [(item as unknown as { id: string }).id],
|
||||
item: [item as unknown as unknown[]],
|
||||
itemType: LibraryItem.PLAYLIST_SONG,
|
||||
type: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,
|
||||
};
|
||||
},
|
||||
onDrag: () => {
|
||||
// Visual feedback is handled by isDraggedOver state
|
||||
},
|
||||
onDragLeave: () => {
|
||||
// Visual feedback is handled by isDraggedOver state
|
||||
},
|
||||
onDrop: (args) => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle drops from PLAYLIST_SONG items
|
||||
if (args.source.itemType !== LibraryItem.PLAYLIST_SONG) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceItems = (args.source.item || []) as any[];
|
||||
const targetItem = item as any;
|
||||
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
playlistId
|
||||
) {
|
||||
// Emit event to reorder playlist songs
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: args.edge,
|
||||
playlistId,
|
||||
sourceIds: args.source.id,
|
||||
targetId: targetItem.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging([]);
|
||||
}
|
||||
},
|
||||
},
|
||||
isEnabled: isPlaylistSong && isDataRow && !!item,
|
||||
});
|
||||
|
||||
const draggedOverEdge: 'bottom' | 'top' | null =
|
||||
isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null;
|
||||
|
||||
const getValidDataItems = useCallback(() => {
|
||||
return props.data.filter((d) => d !== null && (d as any).id);
|
||||
}, [props.data]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let topmostIndex = validItems.length;
|
||||
for (const selectedItem of selectedItems) {
|
||||
const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);
|
||||
if (index !== -1 && index < topmostIndex) {
|
||||
topmostIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (topmostIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItem = validItems[topmostIndex - 1];
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'top',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (targetItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const handleMoveToTop = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstItem = validItems[0];
|
||||
|
||||
const isAlreadyAtTop = selectedItems.some(
|
||||
(selectedItem) => (selectedItem as any).id === (firstItem as any).id,
|
||||
);
|
||||
|
||||
if (!firstItem || isAlreadyAtTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'top',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (firstItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bottommostIndex = -1;
|
||||
for (const selectedItem of selectedItems) {
|
||||
const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);
|
||||
if (index !== -1 && index > bottommostIndex) {
|
||||
bottommostIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (bottommostIndex === -1 || bottommostIndex >= validItems.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItem = validItems[bottommostIndex + 1];
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'bottom',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (targetItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const handleMoveToBottom = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastItem = validItems[validItems.length - 1];
|
||||
|
||||
const isAlreadyAtBottom = selectedItems.some(
|
||||
(selectedItem) => (selectedItem as any).id === (lastItem as any).id,
|
||||
);
|
||||
|
||||
if (!lastItem || isAlreadyAtBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'bottom',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (lastItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const upButtonHandlers = useLongPress<HTMLButtonElement>({
|
||||
onClick: handleMoveUp,
|
||||
onLongPress: handleMoveToTop,
|
||||
});
|
||||
|
||||
const downButtonHandlers = useLongPress<HTMLButtonElement>({
|
||||
onClick: handleMoveDown,
|
||||
onLongPress: handleMoveToBottom,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableColumnContainer {...props} isDraggedOver={draggedOverEdge}>
|
||||
<ActionIconGroup w="100%">
|
||||
<ActionIcon
|
||||
{...upButtonHandlers}
|
||||
icon="arrowUp"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
tooltip={{
|
||||
label: (
|
||||
<>
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text fw={500} ta="center">
|
||||
{t('action.moveUp', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<Text fw={500} isMuted size="xs" ta="center">
|
||||
{t('action.holdToMoveToTop', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
<ActionIcon
|
||||
{...downButtonHandlers}
|
||||
icon="arrowDown"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
tooltip={{
|
||||
label: (
|
||||
<>
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text fw={500} ta="center">
|
||||
{t('action.moveDown', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<Text fw={500} isMuted size="xs" ta="center">
|
||||
{t('action.holdToMoveToBottom', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="dragVertical"
|
||||
iconProps={{ size: 'md' }}
|
||||
ref={dragRef}
|
||||
size="xs"
|
||||
style={{
|
||||
cursor: isPlaylistSong ? 'grab' : 'default',
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
</TableColumnContainer>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
|
||||
import clsx from 'clsx';
|
||||
import React, { CSSProperties, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { CellComponentProps } from 'react-window-v2';
|
||||
|
||||
import styles from './item-table-list-column.module.css';
|
||||
@@ -38,6 +39,7 @@ import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/co
|
||||
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
|
||||
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
|
||||
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
|
||||
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
|
||||
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
|
||||
import { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column';
|
||||
import { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column';
|
||||
@@ -46,6 +48,7 @@ import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/co
|
||||
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
|
||||
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
@@ -74,6 +77,7 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
|
||||
}
|
||||
|
||||
export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
const { playlistId } = useParams() as { playlistId?: string };
|
||||
const type = props.columns[props.columnIndex].id as TableColumn;
|
||||
|
||||
const isHeaderEnabled = !!props.enableHeader;
|
||||
@@ -171,7 +175,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
operation:
|
||||
props.itemType === LibraryItem.QUEUE_SONG
|
||||
? [DragOperation.REORDER, DragOperation.ADD]
|
||||
: [DragOperation.ADD],
|
||||
: props.itemType === LibraryItem.PLAYLIST_SONG
|
||||
? [DragOperation.REORDER]
|
||||
: [DragOperation.ADD],
|
||||
target: DragTargetMap[props.itemType] || DragTarget.GENERIC,
|
||||
},
|
||||
drop: {
|
||||
@@ -180,10 +186,21 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow drops for QUEUE_SONG (queue reordering)
|
||||
if (props.itemType === LibraryItem.QUEUE_SONG) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow drops for PLAYLIST_SONG (playlist reordering)
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
props.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getData: () => {
|
||||
@@ -331,6 +348,33 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PLAYLIST_SONG reordering
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true &&
|
||||
playlistId
|
||||
) {
|
||||
const sourceItems = (args.source.item || []) as any[];
|
||||
const targetItem = item as any;
|
||||
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
targetItem
|
||||
) {
|
||||
// Emit event to reorder playlist songs
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: args.edge,
|
||||
playlistId,
|
||||
sourceIds: args.source.id,
|
||||
targetId: targetItem.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging([]);
|
||||
}
|
||||
@@ -469,6 +513,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
case TableColumn.PATH:
|
||||
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.PLAYLIST_REORDER:
|
||||
return <PlaylistReorderColumn {...props} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ROW_INDEX:
|
||||
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
@@ -1213,6 +1260,11 @@ const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
|
||||
postProcess: 'upperCase',
|
||||
}) as string,
|
||||
[TableColumn.PLAYLIST_REORDER]: (
|
||||
<Flex className={styles.headerIconWrapper}>
|
||||
<Icon icon="dragVertical" />
|
||||
</Flex>
|
||||
),
|
||||
[TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', {
|
||||
postProcess: 'upperCase',
|
||||
}) as string,
|
||||
|
||||
@@ -1198,6 +1198,29 @@ const BaseItemTableList = ({
|
||||
};
|
||||
}, [enableDrag, initialize, osInstance, pinnedRightColumnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pinnedLeftColumnCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: root } = pinnedLeftColumnRef;
|
||||
|
||||
if (!root || !root.firstElementChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = root.firstElementChild as HTMLElement;
|
||||
|
||||
if (enableDrag) {
|
||||
autoScrollForElements({
|
||||
canScroll: () => true,
|
||||
element: viewport,
|
||||
getAllowedAxis: () => 'vertical',
|
||||
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
|
||||
});
|
||||
}
|
||||
}, [enableDrag, pinnedLeftColumnCount]);
|
||||
|
||||
// Initialize overlayscrollbars for right pinned columns
|
||||
useEffect(() => {
|
||||
if (pinnedRightColumnCount === 0) {
|
||||
|
||||
@@ -5,11 +5,14 @@ import { ItemListKey } from '/@/shared/types/types';
|
||||
interface ListContextProps {
|
||||
customFilters?: Record<string, unknown>;
|
||||
id?: string;
|
||||
isSmartPlaylist?: boolean;
|
||||
itemCount?: number;
|
||||
listData?: unknown[];
|
||||
mode?: 'edit' | 'view';
|
||||
pageKey: ItemListKey | string;
|
||||
setItemCount?: (itemCount: number) => void;
|
||||
setListData?: (items: unknown[]) => void;
|
||||
setMode?: (mode: 'edit' | 'view') => void;
|
||||
}
|
||||
|
||||
export const ListContext = createContext<ListContextProps>({
|
||||
|
||||
@@ -3,6 +3,11 @@ import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
export type EventMap = {
|
||||
ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
|
||||
ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload;
|
||||
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
|
||||
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
|
||||
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
|
||||
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
|
||||
PLAYLIST_REORDER: PlaylistReorderEventPayload;
|
||||
USER_FAVORITE: UserFavoriteEventPayload;
|
||||
USER_RATING: UserRatingEventPayload;
|
||||
};
|
||||
@@ -17,6 +22,18 @@ export type ItemListUpdateItemEventPayload = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type PlaylistMoveEventPayload = {
|
||||
playlistId: string;
|
||||
sourceIds: string[];
|
||||
};
|
||||
|
||||
export type PlaylistReorderEventPayload = {
|
||||
edge: 'bottom' | 'top' | null;
|
||||
playlistId: string;
|
||||
sourceIds: string[];
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export type UserFavoriteEventPayload = {
|
||||
favorite: boolean;
|
||||
id: string[];
|
||||
|
||||
@@ -24,8 +24,6 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
|
||||
return { ids };
|
||||
}, [items]);
|
||||
|
||||
console.log('items', items, ids);
|
||||
|
||||
return (
|
||||
<ContextMenu.Content
|
||||
bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||
import { PlaylistDetailSongListEditTable } from '/@/renderer/features/playlists/components/playlist-detail-song-list-table';
|
||||
import { useCurrentServer, useListSettings } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const PlaylistDetailSongListTable = lazy(() =>
|
||||
import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(
|
||||
@@ -19,9 +21,6 @@ const PlaylistDetailSongListTable = lazy(() =>
|
||||
);
|
||||
|
||||
export const PlaylistDetailSongListContent = () => {
|
||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(
|
||||
ItemListKey.PLAYLIST_SONG,
|
||||
);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const { setItemCount } = useListContext();
|
||||
@@ -71,28 +70,16 @@ export const PlaylistDetailSongListContent = () => {
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<PlaylistDetailSongListView
|
||||
data={playlistSongsQuery.data}
|
||||
display={display}
|
||||
grid={grid}
|
||||
itemsPerPage={itemsPerPage}
|
||||
pagination={pagination}
|
||||
table={table}
|
||||
/>
|
||||
<PlaylistDetailSongList data={playlistSongsQuery.data} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
|
||||
|
||||
export const PlaylistDetailSongListView = ({
|
||||
data,
|
||||
display,
|
||||
table,
|
||||
}: ItemListSettings & {
|
||||
data: PlaylistSongListResponse;
|
||||
}) => {
|
||||
export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const server = useCurrentServer();
|
||||
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
|
||||
|
||||
switch (display) {
|
||||
case ListDisplayType.TABLE: {
|
||||
@@ -114,3 +101,149 @@ export const PlaylistDetailSongListView = ({
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
|
||||
|
||||
const [localData, setLocalData] = useState<PlaylistSongListResponse>(data);
|
||||
|
||||
const tableRef = useRef<ItemListHandle | null>(null);
|
||||
|
||||
// Listen for playlist reorder events
|
||||
useEffect(() => {
|
||||
const handleReorder = (payload: {
|
||||
edge: 'bottom' | 'top' | null;
|
||||
playlistId: string;
|
||||
sourceIds: string[];
|
||||
targetId: string;
|
||||
}) => {
|
||||
// Only handle events for this playlist
|
||||
if (payload.playlistId !== playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalData((prev) => {
|
||||
if (!prev?.items || !payload.edge) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Create a list of IDs in current order
|
||||
const currentIds = prev.items.map((item) => item.id);
|
||||
|
||||
// Find the target index
|
||||
const targetIndex = currentIds.indexOf(payload.targetId);
|
||||
if (targetIndex === -1) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Remove all source IDs from their current positions
|
||||
const idsWithoutSources = currentIds.filter(
|
||||
(id) => !payload.sourceIds.includes(id),
|
||||
);
|
||||
|
||||
// Calculate the insertion index based on the original target position
|
||||
const sourcesBeforeTarget = payload.sourceIds.filter((id) => {
|
||||
const sourceIndex = currentIds.indexOf(id);
|
||||
return sourceIndex !== -1 && sourceIndex < targetIndex;
|
||||
}).length;
|
||||
|
||||
// Calculate the insert index in the filtered list
|
||||
const insertIndexInFiltered =
|
||||
payload.edge === 'top'
|
||||
? targetIndex - sourcesBeforeTarget
|
||||
: targetIndex - sourcesBeforeTarget + 1;
|
||||
|
||||
// Ensure insertIndex is within bounds
|
||||
const insertIndex = Math.max(
|
||||
0,
|
||||
Math.min(insertIndexInFiltered, idsWithoutSources.length),
|
||||
);
|
||||
|
||||
// Insert source IDs at the calculated position
|
||||
const reorderedIds = [
|
||||
...idsWithoutSources.slice(0, insertIndex),
|
||||
...payload.sourceIds,
|
||||
...idsWithoutSources.slice(insertIndex),
|
||||
];
|
||||
|
||||
// Create a map for quick lookup
|
||||
const itemMap = new Map(prev.items.map((item) => [item.id, item]));
|
||||
|
||||
// Reorder items based on new ID order
|
||||
const reorderedItems = reorderedIds
|
||||
.map((id) => itemMap.get(id))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
items: reorderedItems,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
eventEmitter.on('PLAYLIST_REORDER', handleReorder);
|
||||
|
||||
return () => {
|
||||
eventEmitter.off('PLAYLIST_REORDER', handleReorder);
|
||||
};
|
||||
}, [playlistId]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
align: 'center' as 'center' | 'end' | 'start',
|
||||
id: TableColumn.PLAYLIST_REORDER,
|
||||
isEnabled: true,
|
||||
pinned: 'left' as 'left' | 'right' | null,
|
||||
width: 100,
|
||||
},
|
||||
...table.columns,
|
||||
];
|
||||
}, [table.columns]);
|
||||
|
||||
const { setListData } = useListContext();
|
||||
|
||||
useEffect(() => {
|
||||
setListData?.(localData.items);
|
||||
}, [localData, setListData]);
|
||||
|
||||
switch (display) {
|
||||
case ListDisplayType.TABLE: {
|
||||
return (
|
||||
<PlaylistDetailSongListEditTable
|
||||
autoFitColumns={table.autoFitColumns}
|
||||
columns={columns}
|
||||
data={localData}
|
||||
enableAlternateRowColors={table.enableAlternateRowColors}
|
||||
enableHorizontalBorders={table.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={table.enableRowHoverHighlight}
|
||||
enableVerticalBorders={table.enableVerticalBorders}
|
||||
ref={tableRef}
|
||||
serverId={server.id}
|
||||
size={table.size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const { isSmartPlaylist, mode } = useListContext();
|
||||
|
||||
if (isSmartPlaylist) {
|
||||
return <PlaylistDetailSongListView data={data} />;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'edit':
|
||||
return <PlaylistDetailSongListEdit data={data} />;
|
||||
case 'view':
|
||||
return <PlaylistDetailSongListView data={data} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
+72
-3
@@ -1,7 +1,12 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
@@ -9,14 +14,25 @@ import { ListRefreshButton } from '/@/renderer/features/shared/components/list-r
|
||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useCurrentServerId } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
export const PlaylistDetailSongListHeaderFilters = () => {
|
||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
isSmartPlaylist?: boolean;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeaderFilters = ({
|
||||
isSmartPlaylist,
|
||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { mode, setMode } = useListContext();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
@@ -34,23 +50,42 @@ export const PlaylistDetailSongListHeaderFilters = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const { ref: containerRef, ...breakpoints } = useContainerQuery();
|
||||
|
||||
const isViewEditMode = !isSmartPlaylist && breakpoints.isSm;
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Flex justify="space-between" ref={containerRef}>
|
||||
<Group gap="sm" w="100%">
|
||||
<ListSortByDropdown
|
||||
defaultSortByValue={SongListSort.ID}
|
||||
disabled={isEditMode}
|
||||
itemType={LibraryItem.PLAYLIST_SONG}
|
||||
listKey={ItemListKey.PLAYLIST_SONG}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<ListSortOrderToggleButton
|
||||
defaultSortOrder={SortOrder.ASC}
|
||||
disabled={isEditMode}
|
||||
listKey={ItemListKey.PLAYLIST_SONG}
|
||||
/>
|
||||
<ListRefreshButton listKey={ItemListKey.PLAYLIST_SONG} />
|
||||
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} />
|
||||
<MoreButton onClick={handleMore} />
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{isViewEditMode && <SaveAndReplaceButton mode={mode} />}
|
||||
{isViewEditMode && (
|
||||
<Button
|
||||
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{mode === 'edit'
|
||||
? t('common.view', { postProcess: 'titleCase' })
|
||||
: t('common.edit', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
)}
|
||||
<ListConfigMenu
|
||||
displayTypes={[
|
||||
{
|
||||
@@ -66,6 +101,40 @@ export const PlaylistDetailSongListHeaderFilters = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const openSaveAndReplaceModal = (playlistId: string, listData: unknown[]) => {
|
||||
openContextModal({
|
||||
innerProps: { listData, playlistId },
|
||||
modalKey: 'saveAndReplace',
|
||||
size: 'sm',
|
||||
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
|
||||
});
|
||||
};
|
||||
|
||||
const SaveAndReplaceButton = ({ mode }: { mode: 'edit' | 'view' | undefined }) => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const { listData } = useListContext();
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
if (!playlistId || !listData) return;
|
||||
openSaveAndReplaceModal(playlistId, listData);
|
||||
}, [playlistId, listData]);
|
||||
|
||||
if (mode === 'view') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftSection={<Icon color="error" icon="save" />}
|
||||
onClick={handleOpenModal}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
// const GenreFilterSelection = () => {
|
||||
// const { t } = useTranslation();
|
||||
// const { playlistId } = useParams() as { playlistId: string };
|
||||
|
||||
@@ -22,7 +22,7 @@ interface PlaylistDetailSongListHeaderProps {
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeader = ({
|
||||
isSmartPlaylist: isSmartPlaylistProp,
|
||||
isSmartPlaylist,
|
||||
}: PlaylistDetailSongListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
@@ -35,7 +35,6 @@ export const PlaylistDetailSongListHeader = ({
|
||||
initialData: location.state?.item,
|
||||
});
|
||||
|
||||
const isSmartPlaylist = isSmartPlaylistProp ?? detailQuery?.data?.rules;
|
||||
const playlistDuration = detailQuery?.data?.duration;
|
||||
|
||||
return (
|
||||
@@ -64,7 +63,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
<ListSearchInput />
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<PlaylistDetailSongListHeaderFilters />
|
||||
<PlaylistDetailSongListHeaderFilters isSmartPlaylist={isSmartPlaylist} />
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -96,6 +96,16 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
const getRowId = useMemo(() => {
|
||||
return (item: unknown) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return 'id';
|
||||
}
|
||||
const song = item as Song;
|
||||
return song.playlistItemId || song.id;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ItemTableList
|
||||
activeRowId={currentSong?.id}
|
||||
@@ -109,7 +119,97 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
enableSelection={enableSelection}
|
||||
enableVerticalBorders={enableVerticalBorders}
|
||||
getRowId="playlistItemId"
|
||||
getRowId={getRowId}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
}}
|
||||
itemType={LibraryItem.PLAYLIST_SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
overrideControls={overrideControls}
|
||||
ref={ref}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PlaylistDetailSongListEditTable = forwardRef<any, PlaylistDetailSongListTableProps>(
|
||||
(
|
||||
{
|
||||
autoFitColumns = false,
|
||||
columns,
|
||||
data,
|
||||
enableAlternateRowColors = false,
|
||||
enableHorizontalBorders = false,
|
||||
enableRowHoverHighlight = true,
|
||||
enableSelection = true,
|
||||
enableVerticalBorders = false,
|
||||
saveScrollOffset = true,
|
||||
size = 'default',
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
|
||||
enabled: saveScrollOffset,
|
||||
});
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.PLAYLIST_SONG,
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.PLAYLIST_SONG,
|
||||
});
|
||||
|
||||
const player = usePlayer();
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const overrideControls: Partial<ItemControls> = useMemo(() => {
|
||||
return {
|
||||
onDoubleClick: ({ index, internalState, item, meta }) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
const getRowId = useMemo(() => {
|
||||
return (item: unknown) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return 'id';
|
||||
}
|
||||
const song = item as Song;
|
||||
return song.playlistItemId || song.id;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ItemTableList
|
||||
activeRowId={currentSong?.id}
|
||||
autoFitColumns={autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={data.items}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableExpansion={false}
|
||||
enableHorizontalBorders={enableHorizontalBorders}
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
enableSelection={enableSelection}
|
||||
enableVerticalBorders={enableVerticalBorders}
|
||||
getRowId={getRowId}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useReplacePlaylist } from '/@/renderer/features/playlists/mutations/replace-playlist-mutation';
|
||||
import { useCurrentServerId } from '/@/renderer/store';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export const SaveAndReplaceContextModal = ({
|
||||
innerProps,
|
||||
}: ContextModalProps<{ listData: unknown[]; playlistId: string }>) => {
|
||||
const { t } = useTranslation();
|
||||
const { listData, playlistId } = innerProps;
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
const replacePlaylistMutation = useReplacePlaylist({});
|
||||
|
||||
// Get current songs from list data
|
||||
const currentSongIds = useMemo(() => {
|
||||
if (!listData || !Array.isArray(listData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return listData
|
||||
.filter((item): item is Song => {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'id' in item &&
|
||||
typeof (item as any).id === 'string'
|
||||
);
|
||||
})
|
||||
.map((song) => song.id);
|
||||
}, [listData]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!serverId || !playlistId) {
|
||||
console.error('serverId or playlistId is not defined');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSongIds.length === 0) {
|
||||
console.error('currentSongIds is empty');
|
||||
toast.error({
|
||||
message: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
replacePlaylistMutation.mutate(
|
||||
{
|
||||
apiClientProps: { serverId },
|
||||
body: {
|
||||
songId: currentSongIds,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: t('error.genericError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
closeAllModals();
|
||||
toast.success({
|
||||
message: t('form.editPlaylist.success', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [t, currentSongIds, serverId, playlistId, replacePlaylistMutation]);
|
||||
|
||||
return (
|
||||
<ConfirmModal loading={replacePlaylistMutation.isPending} onConfirm={handleConfirm}>
|
||||
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServerId } from '/@/renderer/store';
|
||||
import { ReplacePlaylistArgs, ReplacePlaylistResponse } from '/@/shared/types/domain-types';
|
||||
|
||||
export const useReplacePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
const { addRecentPlaylist } = useRecentPlaylists(serverId);
|
||||
|
||||
return useMutation<ReplacePlaylistResponse, AxiosError, ReplacePlaylistArgs, null>({
|
||||
mutationFn: (args) => {
|
||||
return api.controller.replacePlaylist({
|
||||
...args,
|
||||
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables, context) => {
|
||||
const { apiClientProps } = variables;
|
||||
const serverId = apiClientProps.serverId;
|
||||
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
exact: false,
|
||||
queryKey: queryKeys.playlists.list(serverId),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists.detail(serverId, variables.query.id),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists.songList(serverId, variables.query.id),
|
||||
});
|
||||
|
||||
addRecentPlaylist(variables.query.id);
|
||||
|
||||
options?.onSuccess?.(_data, variables, context);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -386,10 +386,11 @@ const PlaylistDetailSongListRoute = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const isSmartPlaylist =
|
||||
const isSmartPlaylist = Boolean(
|
||||
!detailQuery?.isLoading &&
|
||||
detailQuery?.data?.rules &&
|
||||
server?.type === ServerType.NAVIDROME;
|
||||
detailQuery?.data?.rules &&
|
||||
server?.type === ServerType.NAVIDROME,
|
||||
);
|
||||
|
||||
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
|
||||
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
|
||||
@@ -406,18 +407,22 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||
const [listData, setListData] = useState<unknown[]>([]);
|
||||
const [mode, setMode] = useState<'edit' | 'view'>('view');
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
customFilters: undefined,
|
||||
id: playlistId,
|
||||
isSmartPlaylist,
|
||||
itemCount,
|
||||
listData,
|
||||
mode,
|
||||
pageKey: ItemListKey.PLAYLIST_SONG,
|
||||
setItemCount,
|
||||
setListData,
|
||||
setMode,
|
||||
};
|
||||
}, [playlistId, itemCount, listData]);
|
||||
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]);
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { RefreshButton } from '/@/renderer/features/shared/components/refresh-bu
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface ListRefreshButtonProps {
|
||||
disabled?: boolean;
|
||||
listKey: ItemListKey;
|
||||
}
|
||||
|
||||
export const ListRefreshButton = ({ listKey }: ListRefreshButtonProps) => {
|
||||
export const ListRefreshButton = ({ disabled, listKey }: ListRefreshButtonProps) => {
|
||||
const handleRefresh = useCallback(() => {
|
||||
eventEmitter.emit('ITEM_LIST_REFRESH', { key: listKey });
|
||||
}, [listKey]);
|
||||
|
||||
return <RefreshButton onClick={handleRefresh} />;
|
||||
return <RefreshButton disabled={disabled} onClick={handleRefresh} />;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface ListSortByDropdownProps {
|
||||
defaultSortByValue: string;
|
||||
disabled?: boolean;
|
||||
itemType: LibraryItem;
|
||||
listKey: ItemListKey;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -26,6 +27,7 @@ interface ListSortByDropdownProps {
|
||||
|
||||
export const ListSortByDropdown = ({
|
||||
defaultSortByValue,
|
||||
disabled,
|
||||
itemType,
|
||||
listKey,
|
||||
onChange,
|
||||
@@ -44,9 +46,15 @@ export const ListSortByDropdown = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu disabled={disabled} position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
{target ? target : <Button variant="subtle">{sortByLabel}</Button>}
|
||||
{target ? (
|
||||
target
|
||||
) : (
|
||||
<Button disabled={disabled} variant="subtle">
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[itemType][server.type].map((f) => (
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface ListSortOrderToggleButtonProps {
|
||||
defaultSortOrder: SortOrder;
|
||||
disabled?: boolean;
|
||||
listKey: ItemListKey;
|
||||
}
|
||||
|
||||
export const ListSortOrderToggleButton = ({
|
||||
defaultSortOrder,
|
||||
disabled,
|
||||
listKey,
|
||||
}: ListSortOrderToggleButtonProps) => {
|
||||
const { setSortOrder, sortOrder } = useSortOrderFilter(defaultSortOrder, listKey);
|
||||
@@ -20,6 +22,10 @@ export const ListSortOrderToggleButton = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={sortOrder as SortOrder} />
|
||||
<OrderToggleButton
|
||||
disabled={disabled}
|
||||
onToggle={handleToggleSortOrder}
|
||||
sortOrder={sortOrder as SortOrder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,15 +5,22 @@ import { SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
interface OrderToggleButtonProps {
|
||||
buttonProps?: Partial<ActionIconProps>;
|
||||
disabled?: boolean;
|
||||
onToggle: () => void;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
export const OrderToggleButton = ({ buttonProps, onToggle, sortOrder }: OrderToggleButtonProps) => {
|
||||
export const OrderToggleButton = ({
|
||||
buttonProps,
|
||||
disabled,
|
||||
onToggle,
|
||||
sortOrder,
|
||||
}: OrderToggleButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
icon={sortOrder === SortOrder.ASC ? 'sortAsc' : 'sortDesc'}
|
||||
iconProps={{
|
||||
size: 'lg',
|
||||
|
||||
@@ -27,6 +27,7 @@ interface UseDraggableProps {
|
||||
getId: () => string[];
|
||||
getItem: () => unknown[];
|
||||
itemType?: LibraryItem;
|
||||
metadata?: Record<string, unknown>;
|
||||
onDragStart?: () => void;
|
||||
onDrop?: () => void;
|
||||
onGenerateDragPreview?: (data: BaseEventPayload<ElementDragType>) => void;
|
||||
@@ -66,13 +67,16 @@ export const useDragDrop = <TElement extends HTMLElement>({
|
||||
const id = drag.getId();
|
||||
const item = drag.getItem();
|
||||
|
||||
const data = dndUtils.generateDragData({
|
||||
id,
|
||||
item,
|
||||
itemType: drag.itemType,
|
||||
operation: drag.operation,
|
||||
type: drag.target,
|
||||
});
|
||||
const data = dndUtils.generateDragData(
|
||||
{
|
||||
id,
|
||||
item,
|
||||
itemType: drag.itemType,
|
||||
operation: drag.operation,
|
||||
type: drag.target,
|
||||
},
|
||||
drag.metadata,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onDragStart: () => {
|
||||
@@ -88,13 +92,16 @@ export const useDragDrop = <TElement extends HTMLElement>({
|
||||
return drag.onGenerateDragPreview(data);
|
||||
}
|
||||
|
||||
const dragData = dndUtils.generateDragData({
|
||||
id: drag.getId(),
|
||||
item: drag.getItem(),
|
||||
itemType: drag.itemType,
|
||||
operation: drag.operation,
|
||||
type: drag.target,
|
||||
}) as DragData;
|
||||
const dragData = dndUtils.generateDragData(
|
||||
{
|
||||
id: drag.getId(),
|
||||
item: drag.getItem(),
|
||||
itemType: drag.itemType,
|
||||
operation: drag.operation,
|
||||
type: drag.target,
|
||||
},
|
||||
drag.metadata,
|
||||
) as DragData;
|
||||
|
||||
disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });
|
||||
setCustomNativeDragPreview({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HashRouter, Route, Routes } from 'react-router';
|
||||
|
||||
import { ShuffleAllContextModal } from '/@/renderer/features/player/components/shuffle-all-modal';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists/components/add-to-playlist-context-modal';
|
||||
import { SaveAndReplaceContextModal } from '/@/renderer/features/playlists/components/save-and-replace-context-modal';
|
||||
import { SettingsContextModal } from '/@/renderer/features/settings/components/settings-modal';
|
||||
import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';
|
||||
import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-context-modal';
|
||||
@@ -83,6 +84,7 @@ export const AppRouter = () => {
|
||||
modals={{
|
||||
addToPlaylist: AddToPlaylistContextModal,
|
||||
base: BaseContextModal,
|
||||
saveAndReplace: SaveAndReplaceContextModal,
|
||||
settings: SettingsContextModal,
|
||||
shareItem: ShareItemContextModal,
|
||||
shuffleAll: ShuffleAllContextModal,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Modal as MantineModal, ModalProps as MantineModalProps } from '@mantine/core';
|
||||
import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
||||
import {
|
||||
closeAllModals as closeAllModalsMantine,
|
||||
ContextModalProps,
|
||||
ModalsProvider as MantineModalsProvider,
|
||||
ModalsProviderProps as MantineModalsProviderProps,
|
||||
openModal as openModalMantine,
|
||||
} from '@mantine/modals';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
@@ -15,6 +17,10 @@ import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
export const openModal = openModalMantine;
|
||||
|
||||
export const closeAllModals = closeAllModalsMantine;
|
||||
|
||||
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
||||
children?: ReactNode;
|
||||
handlers: {
|
||||
@@ -106,7 +112,7 @@ export const ConfirmModal = ({
|
||||
<Stack>
|
||||
<Flex>{children}</Flex>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleCancel} variant="default">
|
||||
<Button disabled={loading} onClick={handleCancel} variant="default">
|
||||
{labels?.cancel ? labels.cancel : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -957,6 +957,22 @@ export type RemoveFromPlaylistQuery = {
|
||||
// Remove from playlist
|
||||
export type RemoveFromPlaylistResponse = null | undefined;
|
||||
|
||||
export type ReplacePlaylistArgs = BaseEndpointArgs & {
|
||||
body: ReplacePlaylistBody;
|
||||
query: ReplacePlaylistQuery;
|
||||
};
|
||||
|
||||
export type ReplacePlaylistBody = {
|
||||
songId: string[];
|
||||
};
|
||||
|
||||
export type ReplacePlaylistQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
// Replace playlist
|
||||
export type ReplacePlaylistResponse = null | undefined;
|
||||
|
||||
export type SetRatingArgs = BaseEndpointArgs & { query: RatingQuery };
|
||||
|
||||
export type ShareItemArgs = BaseEndpointArgs & { body: ShareItemBody };
|
||||
@@ -1284,6 +1300,7 @@ export type ControllerEndpoint = {
|
||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
@@ -1379,6 +1396,9 @@ export type InternalControllerEndpoint = {
|
||||
removeFromPlaylist: (
|
||||
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
||||
) => Promise<RemoveFromPlaylistResponse>;
|
||||
replacePlaylist: (
|
||||
args: ReplaceApiClientProps<ReplacePlaylistArgs>,
|
||||
) => Promise<ReplacePlaylistResponse>;
|
||||
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
|
||||
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
|
||||
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
|
||||
|
||||
@@ -177,6 +177,7 @@ export enum TableColumn {
|
||||
OWNER = 'username',
|
||||
PATH = 'path',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAYLIST_REORDER = 'playlistReorder',
|
||||
RELEASE_DATE = 'releaseDate',
|
||||
ROW_INDEX = 'rowIndex',
|
||||
SIZE = 'size',
|
||||
|
||||
Reference in New Issue
Block a user