diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1f262373e..a881e0d18 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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)" }, diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index e23614b47..c4d57eeea 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 6827bcef1..4cc796bda 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -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; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 2101ca64f..a0232745b 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -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, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 30b1ffa92..f9bf203cc 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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; diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index 42372ac68..a6d691a64 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -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 diff --git a/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx b/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx new file mode 100644 index 000000000..4a68e2068 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx @@ -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({ + 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({ + onClick: handleMoveUp, + onLongPress: handleMoveToTop, + }); + + const downButtonHandlers = useLongPress({ + onClick: handleMoveDown, + onLongPress: handleMoveToBottom, + }); + + return ( + + + + + + {t('action.moveUp', { postProcess: 'sentenceCase' })} + + + {t('action.holdToMoveToTop', { + postProcess: 'sentenceCase', + })} + + + + ), + }} + variant="default" + /> + + + + {t('action.moveDown', { postProcess: 'sentenceCase' })} + + + {t('action.holdToMoveToBottom', { + postProcess: 'sentenceCase', + })} + + + + ), + }} + variant="default" + /> + + + + ); +}; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 74d540f82..9f9057bc0 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -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 ; + case TableColumn.PLAYLIST_REORDER: + return ; + case TableColumn.ROW_INDEX: return ; @@ -1213,6 +1260,11 @@ const columnLabelMap: Record = { [TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', { postProcess: 'upperCase', }) as string, + [TableColumn.PLAYLIST_REORDER]: ( + + + + ), [TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', { postProcess: 'upperCase', }) as string, diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index ef71d045b..acaf31fca 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -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) { diff --git a/src/renderer/context/list-context.tsx b/src/renderer/context/list-context.tsx index 7adc593e9..5ab406a0f 100644 --- a/src/renderer/context/list-context.tsx +++ b/src/renderer/context/list-context.tsx @@ -5,11 +5,14 @@ import { ItemListKey } from '/@/shared/types/types'; interface ListContextProps { customFilters?: Record; 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({ diff --git a/src/renderer/events/events.ts b/src/renderer/events/events.ts index f6024f5ab..1db436725 100644 --- a/src/renderer/events/events.ts +++ b/src/renderer/events/events.ts @@ -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[]; diff --git a/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx b/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx index fea72c383..5fca1aa71 100644 --- a/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx +++ b/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx @@ -24,8 +24,6 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu return { ids }; }, [items]); - console.log('items', items, ids); - return ( } diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index c8ddbed00..d250494b3 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -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 ( }> - + ); }; export type OverridePlaylistSongListQuery = Omit, '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(data); + + const tableRef = useRef(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 => 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 ( + + ); + } + default: + return null; + } +}; + +const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => { + const { isSmartPlaylist, mode } = useListContext(); + + if (isSmartPlaylist) { + return ; + } + + switch (mode) { + case 'edit': + return ; + case 'view': + return ; + default: + return null; + } +}; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 17884ac06..210943a51 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -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 ( - + - + + {isViewEditMode && } + {isViewEditMode && ( + + )} { ); }; +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 ( + + ); +}; // const GenreFilterSelection = () => { // const { t } = useTranslation(); // const { playlistId } = useParams() as { playlistId: string }; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index bddacd32c..c52b6ccd0 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -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 = ({ - + ); diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx index 5dcece674..392d12089 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx @@ -96,6 +96,16 @@ export const PlaylistDetailSongListTable = forwardRef { + return (item: unknown) => { + if (!item || typeof item !== 'object') { + return 'id'; + } + const song = item as Song; + return song.playlistItemId || song.id; + }; + }, []); + return ( + ); + }, +); + +export const PlaylistDetailSongListEditTable = forwardRef( + ( + { + 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 = 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 ( + ) => { + 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 ( + + {t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/playlists/mutations/replace-playlist-mutation.ts b/src/renderer/features/playlists/mutations/replace-playlist-mutation.ts new file mode 100644 index 000000000..b1cbb1611 --- /dev/null +++ b/src/renderer/features/playlists/mutations/replace-playlist-mutation.ts @@ -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({ + 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, + }); +}; diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index 03904519f..0ac088a93 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -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(undefined); const [listData, setListData] = useState([]); + 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 ( diff --git a/src/renderer/features/shared/components/list-refresh-button.tsx b/src/renderer/features/shared/components/list-refresh-button.tsx index e7d656b99..d27dacf80 100644 --- a/src/renderer/features/shared/components/list-refresh-button.tsx +++ b/src/renderer/features/shared/components/list-refresh-button.tsx @@ -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 ; + return ; }; diff --git a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx index 5c501a74d..c344b05e8 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -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 ( - + - {target ? target : } + {target ? ( + target + ) : ( + + )} {FILTERS[itemType][server.type].map((f) => ( diff --git a/src/renderer/features/shared/components/list-sort-order-toggle-button.tsx b/src/renderer/features/shared/components/list-sort-order-toggle-button.tsx index d0f52f45c..34c09168a 100644 --- a/src/renderer/features/shared/components/list-sort-order-toggle-button.tsx +++ b/src/renderer/features/shared/components/list-sort-order-toggle-button.tsx @@ -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 ( - + ); }; diff --git a/src/renderer/features/shared/components/order-toggle-button.tsx b/src/renderer/features/shared/components/order-toggle-button.tsx index 561e468fb..ee48ecce4 100644 --- a/src/renderer/features/shared/components/order-toggle-button.tsx +++ b/src/renderer/features/shared/components/order-toggle-button.tsx @@ -5,15 +5,22 @@ import { SortOrder } from '/@/shared/types/domain-types'; interface OrderToggleButtonProps { buttonProps?: Partial; + 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 ( string[]; getItem: () => unknown[]; itemType?: LibraryItem; + metadata?: Record; onDragStart?: () => void; onDrop?: () => void; onGenerateDragPreview?: (data: BaseEventPayload) => void; @@ -66,13 +67,16 @@ export const useDragDrop = ({ 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 = ({ 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({ diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 0b3ffb8c8..e7c6dca16 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -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, diff --git a/src/shared/components/modal/modal.tsx b/src/shared/components/modal/modal.tsx index d9a1945ae..6bbdb42ee 100644 --- a/src/shared/components/modal/modal.tsx +++ b/src/shared/components/modal/modal.tsx @@ -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 { children?: ReactNode; handlers: { @@ -106,7 +112,7 @@ export const ConfirmModal = ({ {children} -