Merge branch 'feature/dnd-playlist'

This commit is contained in:
Geert Rademakes 2025-08-08 13:54:02 +02:00
commit b3b2808508
5 changed files with 173 additions and 33 deletions

View File

@ -69,8 +69,10 @@ router.get('/structure', async (req: Request, res: Response) => {
// Save playlists in batch (replacing all existing ones) // Save playlists in batch (replacing all existing ones)
router.post('/batch', async (req, res) => { router.post('/batch', async (req, res) => {
try { try {
await Playlist.deleteMany({}); // Clear existing playlists // Replace all playlists atomically
const playlists = await Playlist.create(req.body); await Playlist.deleteMany({});
const payload = Array.isArray(req.body) ? req.body : [];
const playlists = await Playlist.insertMany(payload, { ordered: false });
res.json(playlists); res.json(playlists);
} catch (error) { } catch (error) {
console.error('Error saving playlists:', error); console.error('Error saving playlists:', error);

View File

@ -1,4 +1,4 @@
import { Box, Button, Flex, Heading, Spinner, Text, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack } from "@chakra-ui/react"; import { Box, Button, Flex, Heading, Spinner, Text, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack, useToast } from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon, SettingsIcon, DownloadIcon } from "@chakra-ui/icons"; import { ChevronLeftIcon, ChevronRightIcon, SettingsIcon, DownloadIcon } from "@chakra-ui/icons";
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { useNavigate, useLocation, Routes, Route } from "react-router-dom"; import { useNavigate, useLocation, Routes, Route } from "react-router-dom";
@ -76,6 +76,7 @@ const RekordboxReader: React.FC = () => {
const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false); const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false);
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false); const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
const { currentSong, playSong, closePlayer } = useMusicPlayer(); const { currentSong, playSong, closePlayer } = useMusicPlayer();
const toast = useToast();
// Memoized song selection handler to prevent unnecessary re-renders // Memoized song selection handler to prevent unnecessary re-renders
const handleSongSelect = useCallback((song: Song) => { const handleSongSelect = useCallback((song: Song) => {
@ -231,31 +232,66 @@ const RekordboxReader: React.FC = () => {
}; };
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => { const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
const updatedPlaylists = playlists.map(node => { // Fetch FULL playlists to avoid losing tracks (structure view strips them)
if (node.name === playlistName && node.type === 'playlist') { const fullTree = await api.getPlaylists();
return {
...node, const applyAdd = (nodes: PlaylistNode[]): PlaylistNode[] => {
tracks: [...new Set([...(node.tracks || []), ...songIds])] return nodes.map(node => {
}; if (node.type === 'playlist' && node.name === playlistName) {
const current = Array.isArray(node.tracks) ? node.tracks : [];
const merged = Array.from(new Set([...current, ...songIds]));
return { ...node, tracks: merged };
}
if (node.type === 'folder' && node.children) {
return { ...node, children: applyAdd(node.children) };
}
return node;
});
};
const updatedFullTree = applyAdd(fullTree);
await api.savePlaylists(updatedFullTree);
// Reload structure for UI counters
const structure = await api.getPlaylistStructure();
setPlaylists(structure);
};
// 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;
}
} }
if (node.type === 'folder' && node.children) { return null;
return { };
...node, const target = findNode(playlists);
children: node.children.map(child => { if (!target) return;
if (child.name === playlistName && child.type === 'playlist') { const existing = new Set(target?.tracks || []);
return { const dupes = songIds.filter(id => existing.has(id));
...child,
tracks: [...new Set([...(child.tracks || []), ...songIds])] let proceedMode: 'skip' | 'allow' = 'skip';
}; if (dupes.length > 0) {
} // Simple confirm flow: OK = allow duplicates, Cancel = skip duplicates
return child; 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';
}; }
}
return node; const finalIds = proceedMode === 'skip' ? songIds.filter(id => !existing.has(id)) : songIds;
if (finalIds.length === 0) return;
await handleAddSongsToPlaylist(finalIds, playlistName);
toast({
title: 'Songs Added',
description: `${finalIds.length} song${finalIds.length === 1 ? '' : 's'} added to "${playlistName}"`,
status: 'success',
duration: 3000,
isClosable: true,
}); });
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
}; };
const handleRemoveFromPlaylist = async (songIds: string[]) => { const handleRemoveFromPlaylist = async (songIds: string[]) => {
@ -424,6 +460,7 @@ const RekordboxReader: React.FC = () => {
onPlaylistDelete={handlePlaylistDelete} onPlaylistDelete={handlePlaylistDelete}
onFolderCreate={handleCreateFolder} onFolderCreate={handleCreateFolder}
onPlaylistMove={handleMovePlaylist} onPlaylistMove={handleMovePlaylist}
onDropSongs={handleDropSongsToPlaylist}
/> />
); );

View File

@ -98,7 +98,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
intervalRef.current = setInterval(async () => { const tick = async () => {
try { try {
// Always reload job list to detect newly started jobs // Always reload job list to detect newly started jobs
const jobsData = await api.getAllJobs(); const jobsData = await api.getAllJobs();
@ -116,13 +116,23 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
} catch (err) { } catch (err) {
// ignore transient polling errors // ignore transient polling errors
} }
}, 2000); };
// Adaptive interval: 2s if active jobs, else 10s
const schedule = async () => {
await tick();
const hasActive = (jobs || []).some(j => j.status === 'running');
const delay = hasActive ? 2000 : 10000;
intervalRef.current = setTimeout(schedule, delay) as any;
};
schedule();
}; };
// Stop polling // Stop polling
const stopPolling = () => { const stopPolling = () => {
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearTimeout(intervalRef.current as any);
intervalRef.current = null; intervalRef.current = null;
} }
}; };

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: (e: React.DragEvent, songIdsFallback: 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,10 @@ 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) => {
onDragStart(e, [song.id]);
}}
> >
{showCheckbox && ( {showCheckbox && (
<Checkbox <Checkbox
@ -146,6 +151,8 @@ 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 [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);
@ -227,6 +234,22 @@ 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((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.setData('text/plain', JSON.stringify(payload));
e.dataTransfer.effectAllowed = 'copyMove';
}, [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 => (
@ -239,9 +262,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(() => {
@ -309,6 +333,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"
@ -410,7 +440,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

@ -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,8 +103,10 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
onPlaylistDelete, onPlaylistDelete,
onPlaylistMove, onPlaylistMove,
allFolders, allFolders,
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(() => {
@ -137,6 +141,30 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
/> />
</Box> </Box>
} }
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
const types = Array.from((e.dataTransfer.types || []) as any);
const hasOurType = types.includes('application/json') || types.includes('text/plain');
if (hasOurType) {
try { e.dataTransfer.dropEffect = 'copy'; } catch {}
setIsDragOver(true);
}
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
try {
const json = e.dataTransfer.getData('application/json') || e.dataTransfer.getData('text/plain');
const parsed = json ? JSON.parse(json) : null;
if (parsed?.type === 'songs' && Array.isArray(parsed.songIds) && onDropSongs) {
onDropSongs(node.name, parsed.songIds);
}
} catch {}
setIsDragOver(false);
}}
boxShadow={isDragOver ? 'inset 0 0 0 1px var(--chakra-colors-blue-400)' : 'none'}
> >
{level > 0 && ( {level > 0 && (
<Box <Box
@ -176,6 +204,7 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
onPlaylistDelete={onPlaylistDelete} onPlaylistDelete={onPlaylistDelete}
onPlaylistMove={onPlaylistMove} onPlaylistMove={onPlaylistMove}
allFolders={allFolders} allFolders={allFolders}
onDropSongs={onDropSongs}
/> />
))} ))}
</VStack> </VStack>
@ -196,6 +225,36 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
borderRight="1px solid" borderRight="1px solid"
borderRightColor="whiteAlpha.200" borderRightColor="whiteAlpha.200"
position="relative" position="relative"
border={selectedItem === node.name ? '1px solid' : undefined}
borderColor={selectedItem === node.name ? 'blue.800' : undefined}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
const types = Array.from((e.dataTransfer.types || []) as any);
const hasOurType = types.includes('application/json') || types.includes('text/plain');
if (hasOurType) {
try { e.dataTransfer.dropEffect = 'copy'; } catch {}
setIsDragOver(true);
}
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
try {
let json = e.dataTransfer.getData('application/json');
if (!json) json = e.dataTransfer.getData('text/plain');
let parsed: any = null;
if (json && json.trim().length > 0) {
parsed = JSON.parse(json);
}
if (parsed?.type === 'songs' && Array.isArray(parsed.songIds) && onDropSongs) {
onDropSongs(node.name, parsed.songIds);
}
} catch {}
setIsDragOver(false);
}}
boxShadow={isDragOver ? 'inset 0 0 0 1px var(--chakra-colors-blue-400)' : 'none'}
> >
{level > 0 && ( {level > 0 && (
<Box <Box
@ -288,6 +347,7 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
onPlaylistDelete, onPlaylistDelete,
onFolderCreate, onFolderCreate,
onPlaylistMove, onPlaylistMove,
onDropSongs,
}) => { }) => {
const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure(); const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure();
const { isOpen: isFolderModalOpen, onOpen: onFolderModalOpen, onClose: onFolderModalClose } = useDisclosure(); const { isOpen: isFolderModalOpen, onOpen: onFolderModalOpen, onClose: onFolderModalClose } = useDisclosure();
@ -378,6 +438,7 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
onPlaylistDelete={onPlaylistDelete} onPlaylistDelete={onPlaylistDelete}
onPlaylistMove={onPlaylistMove} onPlaylistMove={onPlaylistMove}
allFolders={allFolders} allFolders={allFolders}
onDropSongs={onDropSongs}
/> />
))} ))}
</VStack> </VStack>