diff --git a/packages/backend/src/routes/playlists.ts b/packages/backend/src/routes/playlists.ts index 8b24f85..97f127d 100644 --- a/packages/backend/src/routes/playlists.ts +++ b/packages/backend/src/routes/playlists.ts @@ -69,8 +69,10 @@ router.get('/structure', async (req: Request, res: Response) => { // Save playlists in batch (replacing all existing ones) router.post('/batch', async (req, res) => { try { - await Playlist.deleteMany({}); // Clear existing playlists - const playlists = await Playlist.create(req.body); + // Replace all playlists atomically + await Playlist.deleteMany({}); + const payload = Array.isArray(req.body) ? req.body : []; + const playlists = await Playlist.insertMany(payload, { ordered: false }); res.json(playlists); } catch (error) { console.error('Error saving playlists:', error); diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 4f159c9..3b54475 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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 React, { useState, useRef, useEffect, useCallback } from "react"; import { useNavigate, useLocation, Routes, Route } from "react-router-dom"; @@ -76,6 +76,7 @@ const RekordboxReader: React.FC = () => { const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false); const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false); const { currentSong, playSong, closePlayer } = useMusicPlayer(); + const toast = useToast(); // Memoized song selection handler to prevent unnecessary re-renders const handleSongSelect = useCallback((song: Song) => { @@ -231,31 +232,66 @@ const RekordboxReader: React.FC = () => { }; const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => { - const updatedPlaylists = playlists.map(node => { - if (node.name === playlistName && node.type === 'playlist') { - return { - ...node, - tracks: [...new Set([...(node.tracks || []), ...songIds])] - }; + // Fetch FULL playlists to avoid losing tracks (structure view strips them) + const fullTree = await api.getPlaylists(); + + const applyAdd = (nodes: PlaylistNode[]): PlaylistNode[] => { + 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 { - ...node, - children: node.children.map(child => { - if (child.name === playlistName && child.type === 'playlist') { - return { - ...child, - tracks: [...new Set([...(child.tracks || []), ...songIds])] - }; - } - return child; - }) - }; - } - return node; + return null; + }; + const target = findNode(playlists); + if (!target) return; + 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); + 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[]) => { @@ -424,6 +460,7 @@ const RekordboxReader: React.FC = () => { onPlaylistDelete={handlePlaylistDelete} onFolderCreate={handleCreateFolder} onPlaylistMove={handleMovePlaylist} + onDropSongs={handleDropSongsToPlaylist} /> ); diff --git a/packages/frontend/src/components/BackgroundJobProgress.tsx b/packages/frontend/src/components/BackgroundJobProgress.tsx index 0d08576..810fe6f 100644 --- a/packages/frontend/src/components/BackgroundJobProgress.tsx +++ b/packages/frontend/src/components/BackgroundJobProgress.tsx @@ -98,7 +98,7 @@ export const BackgroundJobProgress: React.FC = ({ clearInterval(intervalRef.current); } - intervalRef.current = setInterval(async () => { + const tick = async () => { try { // Always reload job list to detect newly started jobs const jobsData = await api.getAllJobs(); @@ -116,13 +116,23 @@ export const BackgroundJobProgress: React.FC = ({ } catch (err) { // 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 const stopPolling = () => { if (intervalRef.current) { - clearInterval(intervalRef.current); + clearTimeout(intervalRef.current as any); intervalRef.current = null; } }; diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index 6a7a3ac..e610cde 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -49,7 +49,8 @@ const SongItem = memo<{ onToggleSelection: (songId: string) => void; showCheckbox: boolean; 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 const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); const handleClick = useCallback(() => { @@ -83,6 +84,10 @@ const SongItem = memo<{ _hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }} onClick={handleClick} transition="background-color 0.2s" + draggable + onDragStart={(e) => { + onDragStart(e, [song.id]); + }} > {showCheckbox && ( = memo(({ onPlaySong }) => { 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); @@ -227,6 +234,22 @@ export const PaginatedSongList: React.FC = memo(({ // Memoized search handler with debouncing // 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 const songItems = useMemo(() => { return songs.map(song => ( @@ -239,9 +262,10 @@ export const PaginatedSongList: React.FC = memo(({ onToggleSelection={toggleSelection} showCheckbox={selectedSongs.size > 0 || depth === 0} 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 const totalDuration = useMemo(() => { @@ -309,6 +333,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 9f71b86..9cf9826 100644 --- a/packages/frontend/src/components/PlaylistManager.tsx +++ b/packages/frontend/src/components/PlaylistManager.tsx @@ -17,7 +17,7 @@ import { MenuList, MenuItem, Text, - HStack, + // HStack, Collapse, MenuDivider, MenuGroup, @@ -36,6 +36,7 @@ interface PlaylistManagerProps { onPlaylistDelete: (name: string) => void; onFolderCreate: (name: string) => void; onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void; + onDropSongs?: (playlistName: string, songIds: string[]) => void; } // Memoized button styles to prevent unnecessary re-renders @@ -91,6 +92,7 @@ interface PlaylistItemProps { onPlaylistDelete: (name: string) => void; onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void; allFolders: { name: string }[]; + onDropSongs?: (playlistName: string, songIds: string[]) => void; } const PlaylistItem: React.FC = React.memo(({ @@ -101,8 +103,10 @@ const PlaylistItem: React.FC = React.memo(({ onPlaylistDelete, onPlaylistMove, allFolders, + onDropSongs, }) => { const [isOpen, setIsOpen] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); // Memoize click handlers to prevent recreation const handlePlaylistClick = useCallback(() => { @@ -137,6 +141,30 @@ const PlaylistItem: React.FC = React.memo(({ /> } + 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 && ( = React.memo(({ onPlaylistDelete={onPlaylistDelete} onPlaylistMove={onPlaylistMove} allFolders={allFolders} + onDropSongs={onDropSongs} /> ))} @@ -196,6 +225,36 @@ const PlaylistItem: React.FC = React.memo(({ borderRight="1px solid" borderRightColor="whiteAlpha.200" 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 && ( = ({ onPlaylistDelete, onFolderCreate, onPlaylistMove, + onDropSongs, }) => { const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure(); const { isOpen: isFolderModalOpen, onOpen: onFolderModalOpen, onClose: onFolderModalClose } = useDisclosure(); @@ -378,6 +438,7 @@ export const PlaylistManager: React.FC = ({ onPlaylistDelete={onPlaylistDelete} onPlaylistMove={onPlaylistMove} allFolders={allFolders} + onDropSongs={onDropSongs} /> ))}