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:
parent
083eca58cf
commit
6917c22b94
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user