From 6917c22b94311eced001dfb4404cb5d00a0609c1 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 13:00:40 +0200 Subject: [PATCH] feat(dnd): drag songs (with multiselect) into sidebar playlists; drop handler adds to playlist with duplicate confirm (allow or skip) --- packages/frontend/src/App.tsx | 30 +++++++++++++++++++ .../src/components/PaginatedSongList.tsx | 20 +++++++++++-- .../src/components/PlaylistManager.tsx | 25 +++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 4f159c9..efbd11d 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -258,6 +258,35 @@ const RekordboxReader: React.FC = () => { setPlaylists(savedPlaylists); }; + // Handle drop from song list into playlist (with duplicate check and user choice) + const handleDropSongsToPlaylist = async (playlistName: string, songIds: string[]) => { + // Find target playlist current tracks + const findNode = (nodes: PlaylistNode[]): PlaylistNode | null => { + for (const n of nodes) { + if (n.name === playlistName && n.type === 'playlist') return n; + if (n.type === 'folder' && n.children) { + const found = findNode(n.children); + if (found) return found; + } + } + return null; + }; + const target = findNode(playlists); + const existing = new Set(target?.tracks || []); + const dupes = songIds.filter(id => existing.has(id)); + + let proceedMode: 'skip' | 'allow' = 'skip'; + if (dupes.length > 0) { + // Simple confirm flow: OK = allow duplicates, Cancel = skip duplicates + const allow = window.confirm(`${dupes.length} duplicate${dupes.length>1?'s':''} detected in "${playlistName}". Press OK to add anyway (allow duplicates) or Cancel to skip duplicates.`); + proceedMode = allow ? 'allow' : 'skip'; + } + + const finalIds = proceedMode === 'skip' ? songIds.filter(id => !existing.has(id)) : songIds; + if (finalIds.length === 0) return; + await handleAddSongsToPlaylist(finalIds, playlistName); + }; + const handleRemoveFromPlaylist = async (songIds: string[]) => { if (currentPlaylist === "All Songs") return; @@ -424,6 +453,7 @@ const RekordboxReader: React.FC = () => { onPlaylistDelete={handlePlaylistDelete} onFolderCreate={handleCreateFolder} onPlaylistMove={handleMovePlaylist} + onDropSongs={handleDropSongsToPlaylist} /> ); diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index 6a7a3ac..04224db 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -49,7 +49,8 @@ const SongItem = memo<{ onToggleSelection: (songId: string) => void; showCheckbox: boolean; onPlaySong?: (song: Song) => void; -}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong }) => { + onDragStart: (songIds: string[]) => void; +}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart }) => { // Memoize the formatted duration to prevent recalculation const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); const handleClick = useCallback(() => { @@ -83,6 +84,13 @@ const SongItem = memo<{ _hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }} onClick={handleClick} transition="background-color 0.2s" + draggable + onDragStart={(e) => { + // Mark this item as part of the drag selection using a custom type + onDragStart([song.id]); + e.dataTransfer.setData('application/json', JSON.stringify({ type: 'songs', songIds: [song.id] })); + e.dataTransfer.effectAllowed = 'copyMove'; + }} > {showCheckbox && ( = memo(({ onPlaySong }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); + const dragSelectionRef = useRef(null); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure(); const observerRef = useRef(null); @@ -227,6 +236,12 @@ export const PaginatedSongList: React.FC = memo(({ // Memoized search handler with debouncing // Search handled inline via localSearchQuery effect + // Provide drag payload: if multiple selected, drag all; else drag the single item + const handleDragStart = useCallback((songIdsFallback: string[]) => { + const ids = selectedSongs.size > 0 ? Array.from(selectedSongs) : songIdsFallback; + dragSelectionRef.current = ids; + }, [selectedSongs]); + // Memoized song items to prevent unnecessary re-renders const songItems = useMemo(() => { return songs.map(song => ( @@ -239,9 +254,10 @@ export const PaginatedSongList: React.FC = memo(({ onToggleSelection={toggleSelection} showCheckbox={selectedSongs.size > 0 || depth === 0} onPlaySong={onPlaySong} + onDragStart={handleDragStart} /> )); - }, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong]); // Removed handleSongSelect since it's already memoized + }, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized // Use total playlist duration if available, otherwise calculate from current songs const totalDuration = useMemo(() => { diff --git a/packages/frontend/src/components/PlaylistManager.tsx b/packages/frontend/src/components/PlaylistManager.tsx index 9f71b86..a0dd91c 100644 --- a/packages/frontend/src/components/PlaylistManager.tsx +++ b/packages/frontend/src/components/PlaylistManager.tsx @@ -17,7 +17,7 @@ import { MenuList, MenuItem, Text, - HStack, + // HStack, Collapse, MenuDivider, MenuGroup, @@ -36,6 +36,7 @@ interface PlaylistManagerProps { onPlaylistDelete: (name: string) => void; onFolderCreate: (name: string) => void; onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void; + onDropSongs?: (playlistName: string, songIds: string[]) => void; } // Memoized button styles to prevent unnecessary re-renders @@ -91,6 +92,7 @@ interface PlaylistItemProps { onPlaylistDelete: (name: string) => void; onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void; allFolders: { name: string }[]; + onDropSongs?: (playlistName: string, songIds: string[]) => void; } const PlaylistItem: React.FC = React.memo(({ @@ -101,6 +103,7 @@ const PlaylistItem: React.FC = React.memo(({ onPlaylistDelete, onPlaylistMove, allFolders, + onDropSongs, }) => { const [isOpen, setIsOpen] = useState(false); @@ -137,6 +140,16 @@ const PlaylistItem: React.FC = React.memo(({ /> } + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + try { + const data = e.dataTransfer.getData('application/json'); + const parsed = JSON.parse(data); + if (parsed?.type === 'songs' && Array.isArray(parsed.songIds) && onDropSongs) { + onDropSongs(node.name, parsed.songIds); + } + } catch {} + }} > {level > 0 && ( = React.memo(({ borderRight="1px solid" borderRightColor="whiteAlpha.200" position="relative" + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + try { + const data = e.dataTransfer.getData('application/json'); + const parsed = JSON.parse(data); + if (parsed?.type === 'songs' && Array.isArray(parsed.songIds) && onDropSongs) { + onDropSongs(node.name, parsed.songIds); + } + } catch {} + }} > {level > 0 && (