feat(dnd): highlight playlist drop target; show drag count badge; refine drag payload and lifecycle

This commit is contained in:
Geert Rademakes 2025-08-08 13:08:22 +02:00
parent 6917c22b94
commit 597c8f994f
2 changed files with 34 additions and 9 deletions

View File

@ -49,7 +49,7 @@ const SongItem = memo<{
onToggleSelection: (songId: string) => void; onToggleSelection: (songId: string) => void;
showCheckbox: boolean; showCheckbox: boolean;
onPlaySong?: (song: Song) => void; onPlaySong?: (song: Song) => void;
onDragStart: (songIds: string[]) => void; onDragStart: (e: React.DragEvent, songIdsFallback: string[]) => void;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart }) => { }>(({ 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]);
@ -86,10 +86,7 @@ const SongItem = memo<{
transition="background-color 0.2s" transition="background-color 0.2s"
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
// Mark this item as part of the drag selection using a custom type onDragStart(e, [song.id]);
onDragStart([song.id]);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'songs', songIds: [song.id] }));
e.dataTransfer.effectAllowed = 'copyMove';
}} }}
> >
{showCheckbox && ( {showCheckbox && (
@ -155,6 +152,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
}) => { }) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set()); const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const dragSelectionRef = useRef<string[] | null>(null); const dragSelectionRef = useRef<string[] | null>(null);
const [isDragging, setIsDragging] = useState(false);
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);
@ -237,11 +235,20 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
// 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 // 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; const ids = selectedSongs.size > 0 ? Array.from(selectedSongs) : songIdsFallback;
dragSelectionRef.current = ids; 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]); }, [selectedSongs]);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
dragSelectionRef.current = null;
}, []);
// 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 => (
@ -325,6 +332,12 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
return ( return (
<Flex direction="column" height="100%"> <Flex direction="column" height="100%">
{/* Global drag badge for selected count */}
{isDragging && (
<Box position="fixed" top={3} right={3} zIndex={2000} bg="blue.600" color="white" px={3} py={1} borderRadius="md" boxShadow="lg">
Dragging {dragSelectionRef.current?.length || 0} song{(dragSelectionRef.current?.length || 0) === 1 ? '' : 's'}
</Box>
)}
{/* Sticky Header */} {/* Sticky Header */}
<Box <Box
position="sticky" position="sticky"
@ -426,7 +439,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
id="song-list-container" id="song-list-container"
> >
<Flex direction="column" gap={2}> <Flex direction="column" gap={2}>
{songItems} <Box onDragEnd={handleDragEnd}>{songItems}</Box>
{/* Loading indicator for infinite scroll or playlist switching */} {/* Loading indicator for infinite scroll or playlist switching */}
{(loading || isSwitchingPlaylist) && ( {(loading || isSwitchingPlaylist) && (

View File

@ -106,6 +106,7 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
onDropSongs, onDropSongs,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
// Memoize click handlers to prevent recreation // Memoize click handlers to prevent recreation
const handlePlaylistClick = useCallback(() => { const handlePlaylistClick = useCallback(() => {
@ -140,7 +141,11 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
/> />
</Box> </Box>
} }
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => { onDrop={(e) => {
try { try {
const data = e.dataTransfer.getData('application/json'); const data = e.dataTransfer.getData('application/json');
@ -149,7 +154,10 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
onDropSongs(node.name, parsed.songIds); onDropSongs(node.name, parsed.songIds);
} }
} catch {} } catch {}
setIsDragOver(false);
}} }}
borderColor={isDragOver ? 'blue.400' : undefined}
borderWidth={isDragOver ? '1px' : undefined}
> >
{level > 0 && ( {level > 0 && (
<Box <Box
@ -209,7 +217,8 @@ 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()} onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => { onDrop={(e) => {
try { try {
const data = e.dataTransfer.getData('application/json'); const data = e.dataTransfer.getData('application/json');
@ -218,7 +227,10 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
onDropSongs(node.name, parsed.songIds); onDropSongs(node.name, parsed.songIds);
} }
} catch {} } catch {}
setIsDragOver(false);
}} }}
borderColor={isDragOver ? 'blue.400' : undefined}
borderWidth={isDragOver ? '1px' : undefined}
> >
{level > 0 && ( {level > 0 && (
<Box <Box