feat(dnd): drag songs (with multiselect) into sidebar playlists; drop handler adds to playlist with duplicate confirm (allow or skip)

This commit is contained in:
Geert Rademakes 2025-08-08 13:00:40 +02:00
parent 083eca58cf
commit 6917c22b94
3 changed files with 72 additions and 3 deletions

View File

@ -258,6 +258,35 @@ const RekordboxReader: React.FC = () => {
setPlaylists(savedPlaylists); 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[]) => { const handleRemoveFromPlaylist = async (songIds: string[]) => {
if (currentPlaylist === "All Songs") return; if (currentPlaylist === "All Songs") return;
@ -424,6 +453,7 @@ const RekordboxReader: React.FC = () => {
onPlaylistDelete={handlePlaylistDelete} onPlaylistDelete={handlePlaylistDelete}
onFolderCreate={handleCreateFolder} onFolderCreate={handleCreateFolder}
onPlaylistMove={handleMovePlaylist} onPlaylistMove={handleMovePlaylist}
onDropSongs={handleDropSongsToPlaylist}
/> />
); );

View File

@ -49,7 +49,8 @@ const SongItem = memo<{
onToggleSelection: (songId: string) => void; onToggleSelection: (songId: string) => void;
showCheckbox: boolean; showCheckbox: boolean;
onPlaySong?: (song: Song) => void; 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 // Memoize the formatted duration to prevent recalculation
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@ -83,6 +84,13 @@ const SongItem = memo<{
_hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }} _hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }}
onClick={handleClick} onClick={handleClick}
transition="background-color 0.2s" 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 && ( {showCheckbox && (
<Checkbox <Checkbox
@ -146,6 +154,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
onPlaySong onPlaySong
}) => { }) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set()); const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const dragSelectionRef = useRef<string[] | null>(null);
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure(); const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure();
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
@ -227,6 +236,12 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
// Memoized search handler with debouncing // Memoized search handler with debouncing
// Search handled inline via localSearchQuery effect // 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 // Memoized song items to prevent unnecessary re-renders
const songItems = useMemo(() => { const songItems = useMemo(() => {
return songs.map(song => ( return songs.map(song => (
@ -239,9 +254,10 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
onToggleSelection={toggleSelection} onToggleSelection={toggleSelection}
showCheckbox={selectedSongs.size > 0 || depth === 0} showCheckbox={selectedSongs.size > 0 || depth === 0}
onPlaySong={onPlaySong} 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 // Use total playlist duration if available, otherwise calculate from current songs
const totalDuration = useMemo(() => { const totalDuration = useMemo(() => {

View File

@ -17,7 +17,7 @@ import {
MenuList, MenuList,
MenuItem, MenuItem,
Text, Text,
HStack, // HStack,
Collapse, Collapse,
MenuDivider, MenuDivider,
MenuGroup, MenuGroup,
@ -36,6 +36,7 @@ interface PlaylistManagerProps {
onPlaylistDelete: (name: string) => void; onPlaylistDelete: (name: string) => void;
onFolderCreate: (name: string) => void; onFolderCreate: (name: string) => void;
onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void; onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void;
onDropSongs?: (playlistName: string, songIds: string[]) => void;
} }
// Memoized button styles to prevent unnecessary re-renders // Memoized button styles to prevent unnecessary re-renders
@ -91,6 +92,7 @@ interface PlaylistItemProps {
onPlaylistDelete: (name: string) => void; onPlaylistDelete: (name: string) => void;
onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void; onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void;
allFolders: { name: string }[]; allFolders: { name: string }[];
onDropSongs?: (playlistName: string, songIds: string[]) => void;
} }
const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
@ -101,6 +103,7 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
onPlaylistDelete, onPlaylistDelete,
onPlaylistMove, onPlaylistMove,
allFolders, allFolders,
onDropSongs,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -137,6 +140,16 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
/> />
</Box> </Box>
} }
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 && ( {level > 0 && (
<Box <Box
@ -196,6 +209,16 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
borderRight="1px solid" borderRight="1px solid"
borderRightColor="whiteAlpha.200" borderRightColor="whiteAlpha.200"
position="relative" 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 && ( {level > 0 && (
<Box <Box