From cccb5d77852fe6c1f09bbd1a7743f72209b04a26 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 14 Nov 2025 18:16:10 -0800 Subject: [PATCH] add drag to add to playlist --- .../add-to-playlist-context-modal.tsx | 35 +++++++- .../sidebar-playlist-list.module.css | 6 ++ .../components/sidebar-playlist-list.tsx | 87 ++++++++++++++++++- 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index f2f957a60..7a7aeec8d 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -46,10 +46,12 @@ export const AddToPlaylistContextModal = ({ albumId?: string[]; artistId?: string[]; genreId?: string[]; + initialSelectedIds?: string[]; + playlistId?: string[]; songId?: string[]; }>) => { const { t } = useTranslation(); - const { albumId, artistId, genreId, songId } = innerProps; + const { albumId, artistId, genreId, initialSelectedIds, playlistId, songId } = innerProps; const server = useCurrentServer(); const [isLoading, setIsLoading] = useState(false); const [search, setSearch] = useState(''); @@ -60,7 +62,7 @@ export const AddToPlaylistContextModal = ({ const form = useForm({ initialValues: { newPlaylists: [] as string[], - selectedPlaylistIds: [] as string[], + selectedPlaylistIds: initialSelectedIds || [], skipDuplicates: true, }, }); @@ -159,6 +161,28 @@ export const AddToPlaylistContextModal = ({ [server], ); + const getSongsByPlaylist = useCallback( + async (playlistId: string) => { + const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId); + + const songsRes = await queryClient.fetchQuery({ + queryFn: ({ signal }) => { + if (!server) throw new Error('No server'); + return api.controller.getPlaylistSongList({ + apiClientProps: { serverId: server?.id || '', signal }, + query: { + id: playlistId, + }, + }); + }, + queryKey, + }); + + return songsRes; + }, + [server], + ); + const handleSubmit = form.onSubmit(async (values) => { if (isLoading) { return; @@ -193,6 +217,13 @@ export const AddToPlaylistContextModal = ({ allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); } + if (playlistId && playlistId.length > 0) { + for (const id of playlistId) { + const songs = await getSongsByPlaylist(id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } + } + if (songId && songId.length > 0) { allSongIds.push(...songId); } diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css index 784e0067f..2324e1ef9 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css @@ -22,3 +22,9 @@ right: var(--theme-spacing-xs); transform: translateY(-50%); } + +.row-dragged-over { + border-radius: var(--mantine-radius-sm); + box-shadow: 0 0 0 2px var(--theme-colors-primary); + opacity: 0.8; +} diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 4da9524be..5961c058d 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -1,4 +1,4 @@ -import { closeAllModals, openModal } from '@mantine/modals'; +import { closeAllModals, openContextModal, openModal } from '@mantine/modals'; import { useQuery } from '@tanstack/react-query'; import clsx from 'clsx'; import { MouseEvent, useCallback, useMemo, useState } from 'react'; @@ -11,6 +11,7 @@ import { usePlayerContext } from '/@/renderer/features/player/context/player-con import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; +import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; import { Accordion } from '/@/shared/components/accordion/accordion'; @@ -23,8 +24,10 @@ import { Playlist, PlaylistListSort, ServerType, + Song, SortOrder, } from '/@/shared/types/domain-types'; +import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { Play } from '/@/shared/types/types'; interface PlaylistRowButtonProps extends Omit { @@ -35,14 +38,94 @@ interface PlaylistRowButtonProps extends Omit { const PlaylistRowButton = ({ name, onPlay, to, ...props }: PlaylistRowButtonProps) => { const url = generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }); + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); + const { isDraggedOver, ref } = useDragDrop({ + drop: { + canDrop: (args) => { + return ( + args.source.itemType !== undefined && + args.source.type !== DragTarget.PLAYLIST && + (args.source.operation?.includes(DragOperation.ADD) ?? false) + ); + }, + getData: () => { + return { + id: [to], + item: [], + itemType: LibraryItem.PLAYLIST, + type: DragTarget.PLAYLIST, + }; + }, + onDrag: () => { + return; + }, + onDragLeave: () => { + return; + }, + onDrop: (args) => { + const sourceItemType = args.source.itemType as LibraryItem; + const sourceIds = args.source.id; + + const modalProps: { + albumId?: string[]; + artistId?: string[]; + genreId?: string[]; + initialSelectedIds?: string[]; + playlistId?: string[]; + songId?: string[]; + } = { + initialSelectedIds: [to], + }; + + switch (sourceItemType) { + case LibraryItem.ALBUM: + modalProps.albumId = sourceIds; + break; + case LibraryItem.ALBUM_ARTIST: + case LibraryItem.ARTIST: + modalProps.artistId = sourceIds; + break; + case LibraryItem.GENRE: + modalProps.genreId = sourceIds; + break; + case LibraryItem.PLAYLIST: + modalProps.playlistId = sourceIds; + break; + case LibraryItem.PLAYLIST_SONG: + case LibraryItem.QUEUE_SONG: + case LibraryItem.SONG: + if (args.source.item && Array.isArray(args.source.item)) { + const songs = args.source.item as Song[]; + modalProps.songId = songs.map((song) => song.id); + } else { + modalProps.songId = sourceIds; + } + break; + default: + return; + } + + openContextModal({ + innerProps: modalProps, + modal: 'addToPlaylist', + title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }), + }); + }, + }, + isEnabled: true, + }); + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + ref={ref} >