From 597c8f994fd472740c13a9b6dfbe2f228e154ab2 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 13:08:22 +0200 Subject: [PATCH] feat(dnd): highlight playlist drop target; show drag count badge; refine drag payload and lifecycle --- .../src/components/PaginatedSongList.tsx | 27 ++++++++++++++----- .../src/components/PlaylistManager.tsx | 16 +++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index 04224db..7314eb3 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -49,7 +49,7 @@ const SongItem = memo<{ onToggleSelection: (songId: string) => void; showCheckbox: boolean; onPlaySong?: (song: Song) => void; - onDragStart: (songIds: string[]) => void; + onDragStart: (e: React.DragEvent, songIdsFallback: 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]); @@ -86,10 +86,7 @@ const SongItem = memo<{ 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'; + onDragStart(e, [song.id]); }} > {showCheckbox && ( @@ -155,6 +152,7 @@ export const PaginatedSongList: React.FC = memo(({ }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); const dragSelectionRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure(); const observerRef = useRef(null); @@ -237,11 +235,20 @@ export const PaginatedSongList: React.FC = memo(({ // 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 handleDragStart = useCallback((e: React.DragEvent, songIdsFallback: string[]) => { const ids = selectedSongs.size > 0 ? Array.from(selectedSongs) : songIdsFallback; dragSelectionRef.current = ids; + setIsDragging(true); + const payload = { type: 'songs', songIds: ids, count: ids.length }; + e.dataTransfer.setData('application/json', JSON.stringify(payload)); + e.dataTransfer.effectAllowed = 'copyMove'; }, [selectedSongs]); + const handleDragEnd = useCallback(() => { + setIsDragging(false); + dragSelectionRef.current = null; + }, []); + // Memoized song items to prevent unnecessary re-renders const songItems = useMemo(() => { return songs.map(song => ( @@ -325,6 +332,12 @@ export const PaginatedSongList: React.FC = memo(({ return ( + {/* Global drag badge for selected count */} + {isDragging && ( + + Dragging {dragSelectionRef.current?.length || 0} song{(dragSelectionRef.current?.length || 0) === 1 ? '' : 's'} + + )} {/* Sticky Header */} = memo(({ id="song-list-container" > - {songItems} + {songItems} {/* Loading indicator for infinite scroll or playlist switching */} {(loading || isSwitchingPlaylist) && ( diff --git a/packages/frontend/src/components/PlaylistManager.tsx b/packages/frontend/src/components/PlaylistManager.tsx index a0dd91c..06b2138 100644 --- a/packages/frontend/src/components/PlaylistManager.tsx +++ b/packages/frontend/src/components/PlaylistManager.tsx @@ -106,6 +106,7 @@ const PlaylistItem: React.FC = React.memo(({ onDropSongs, }) => { const [isOpen, setIsOpen] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); // Memoize click handlers to prevent recreation const handlePlaylistClick = useCallback(() => { @@ -140,7 +141,11 @@ const PlaylistItem: React.FC = React.memo(({ /> } - onDragOver={(e) => e.preventDefault()} + onDragOver={(e) => { + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} onDrop={(e) => { try { const data = e.dataTransfer.getData('application/json'); @@ -149,7 +154,10 @@ const PlaylistItem: React.FC = React.memo(({ onDropSongs(node.name, parsed.songIds); } } catch {} + setIsDragOver(false); }} + borderColor={isDragOver ? 'blue.400' : undefined} + borderWidth={isDragOver ? '1px' : undefined} > {level > 0 && ( = React.memo(({ borderRight="1px solid" borderRightColor="whiteAlpha.200" position="relative" - onDragOver={(e) => e.preventDefault()} + onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }} + onDragLeave={() => setIsDragOver(false)} onDrop={(e) => { try { const data = e.dataTransfer.getData('application/json'); @@ -218,7 +227,10 @@ const PlaylistItem: React.FC = React.memo(({ onDropSongs(node.name, parsed.songIds); } } catch {} + setIsDragOver(false); }} + borderColor={isDragOver ? 'blue.400' : undefined} + borderWidth={isDragOver ? '1px' : undefined} > {level > 0 && (