diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9219abc..53753d1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -23,6 +23,7 @@ "framer-motion": "^12.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-router-dom": "^7.5.2", "sax": "^1.4.1", "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1" diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 138c202..fe59bdb 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig } from "@chakra-ui/react"; -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; import { SongList } from "./components/SongList"; import { PlaylistManager } from "./components/PlaylistManager"; import { SongDetails } from "./components/SongDetails"; @@ -62,14 +63,47 @@ const StyledFileInput = () => { export default function RekordboxReader() { const { songs, playlists, setPlaylists, loading } = useXmlParser(); - const [selectedItem, setSelectedItem] = useState("All Songs"); const [selectedSong, setSelectedSong] = useState(null); + const navigate = useNavigate(); + const location = useLocation(); + const initialLoadDone = useRef(false); + + // Get the current playlist from URL or default to "All Songs" + const currentPlaylist = location.pathname === "/" + ? "All Songs" + : decodeURIComponent(location.pathname.slice(1)); + + useEffect(() => { + // Only run this check after the initial data load + if (!loading && playlists.length > 0) { + initialLoadDone.current = true; + } + + // If we've loaded the data and the playlist doesn't exist + if (initialLoadDone.current && + currentPlaylist !== "All Songs" && + !playlists.some(p => p.name === currentPlaylist)) { + navigate("/", { replace: true }); + } + }, [currentPlaylist, playlists, navigate, loading]); + + const handlePlaylistSelect = (name: string) => { + setSelectedSong(null); // Clear selected song when changing playlists + if (name === "All Songs") { + navigate("/"); + } else { + // Use encodeURIComponent to properly handle spaces and special characters + const encodedName = encodeURIComponent(name); + navigate(`/${encodedName}`); + } + }; const handleCreatePlaylist = async (name: string) => { const newPlaylist = { name, tracks: [] }; const updatedPlaylists = [...playlists, newPlaylist]; const savedPlaylists = await api.savePlaylists(updatedPlaylists); setPlaylists(savedPlaylists); + handlePlaylistSelect(name); // Navigate to the new playlist }; const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => { @@ -82,6 +116,18 @@ export default function RekordboxReader() { setPlaylists(savedPlaylists); }; + const handleRemoveFromPlaylist = async (songIds: string[]) => { + if (currentPlaylist === "All Songs") return; + + const updatedPlaylists = playlists.map((playlist) => + playlist.name === currentPlaylist + ? { ...playlist, tracks: playlist.tracks.filter(id => !songIds.includes(id)) } + : playlist + ); + const savedPlaylists = await api.savePlaylists(updatedPlaylists); + setPlaylists(savedPlaylists); + }; + const handleExport = () => { const xmlContent = exportToXml(songs, playlists); const blob = new Blob([xmlContent], { type: "application/xml" }); @@ -94,14 +140,10 @@ export default function RekordboxReader() { document.body.removeChild(a); }; - const handleSongSelect = (song: Song) => { - setSelectedSong(song); - }; - - const displayedSongs = selectedItem === "All Songs" + const displayedSongs = currentPlaylist === "All Songs" ? songs : songs.filter((song) => - playlists.find((p) => p.name === selectedItem)?.tracks.includes(song.id) + playlists.find((p) => p.name === currentPlaylist)?.tracks.includes(song.id) ); if (loading) { @@ -109,6 +151,11 @@ export default function RekordboxReader() { Loading your library... + {currentPlaylist !== "All Songs" && ( + + Navigating to playlist: {currentPlaylist} + + )} ); } @@ -130,18 +177,20 @@ export default function RekordboxReader() { diff --git a/packages/frontend/src/components/PlaylistManager.tsx b/packages/frontend/src/components/PlaylistManager.tsx index 23ecb5f..0abe1d0 100644 --- a/packages/frontend/src/components/PlaylistManager.tsx +++ b/packages/frontend/src/components/PlaylistManager.tsx @@ -1,35 +1,55 @@ import { + Box, Button, + Flex, Input, - Stack, - useDisclosure, - CloseButton, -} from "@chakra-ui/react"; -import { Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalFooter, ModalBody, -} from "@chakra-ui/modal"; -import { useState } from "react"; -import { Playlist } from "../types/interfaces"; + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useDisclosure, + VStack, +} from "@chakra-ui/react"; +import React, { useState } from "react"; +import { Playlist } from "../types/Playlist"; interface PlaylistManagerProps { playlists: Playlist[]; selectedItem: string | null; onPlaylistCreate: (name: string) => void; - onPlaylistSelect: (name: string) => void; + onPlaylistSelect: (id: string | null) => void; } +const getButtonStyles = (isSelected: boolean) => ({ + width: "100%", + justifyContent: "flex-start", + bg: isSelected ? "blue.800" : "transparent", + color: isSelected ? "white" : "gray.100", + fontWeight: isSelected ? "600" : "normal", + borderRadius: "md", + px: 4, + py: 2, + transition: "all 0.2s", + _hover: { + bg: isSelected ? "blue.600" : "whiteAlpha.200", + transform: "translateX(2px)", + }, + _active: { + bg: isSelected ? "blue.700" : "whiteAlpha.300", + }, +}); + export const PlaylistManager: React.FC = ({ playlists, selectedItem, onPlaylistCreate, onPlaylistSelect, }) => { - const { open, onOpen, onClose } = useDisclosure(); + const { isOpen, onOpen, onClose } = useDisclosure(); const [newPlaylistName, setNewPlaylistName] = useState(""); const handleCreatePlaylist = () => { @@ -41,88 +61,97 @@ export const PlaylistManager: React.FC = ({ }; return ( - <> - + + {playlists.map((playlist) => ( ))} - - + - + Create New Playlist + + + - + - - Create New Playlist - - - + Create New Playlist + + setNewPlaylistName(e.target.value)} placeholder="Enter playlist name" - autoFocus - size="lg" - _placeholder={{ color: 'gray.400' }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleCreatePlaylist(); - } + bg="gray.700" + border="none" + color="white" + _placeholder={{ color: "gray.400" }} + _focus={{ + boxShadow: "0 0 0 1px blue.500", + borderColor: "blue.500", }} /> - - - - + ); }; \ No newline at end of file diff --git a/packages/frontend/src/components/SongList.tsx b/packages/frontend/src/components/SongList.tsx index f86b45d..970feae 100644 --- a/packages/frontend/src/components/SongList.tsx +++ b/packages/frontend/src/components/SongList.tsx @@ -5,11 +5,16 @@ import { Button, IconButton, HStack, + Menu as ChakraMenu, + MenuButton as ChakraMenuButton, + MenuList as ChakraMenuList, + MenuItem, + MenuDivider, + MenuGroup, + Checkbox as ChakraCheckbox } from "@chakra-ui/react"; -import { Menu, MenuButton, MenuList, MenuItem } from "@chakra-ui/menu"; -import { Checkbox } from "@chakra-ui/checkbox"; import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input"; -import { Search2Icon } from "@chakra-ui/icons"; +import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons"; import { Song } from "../types/interfaces"; import { useState, useCallback, useMemo, forwardRef } from "react"; import { ChangeEvent, MouseEvent } from "react"; @@ -17,17 +22,21 @@ import { ChangeEvent, MouseEvent } from "react"; interface SongListProps { songs: Song[]; onAddToPlaylist: (songIds: string[], playlistName: string) => void; + onRemoveFromPlaylist?: (songIds: string[]) => void; playlists: { name: string }[]; onSongSelect: (song: Song) => void; selectedSongId: string | null; + currentPlaylist: string | null; } export const SongList: React.FC = ({ songs, onAddToPlaylist, + onRemoveFromPlaylist, playlists, onSongSelect, - selectedSongId + selectedSongId, + currentPlaylist }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); @@ -93,7 +102,7 @@ export const SongList: React.FC = ({ {/* Bulk Actions Toolbar */} - 0 && selectedSongs.size < filteredSongs.length} onChange={toggleSelectAll} @@ -109,26 +118,51 @@ export const SongList: React.FC = ({ {selectedSongs.size === 0 ? "Select All" : `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`} - + {selectedSongs.size > 0 && ( - - - Add to Playlist - - - {playlists.map((playlist) => ( - handleBulkAddToPlaylist(playlist.name)} - > - {playlist.name} - - ))} - - + + } + size="sm" + colorScheme="blue" + > + Actions + + + + {playlists + .filter(p => p.name !== currentPlaylist) + .map((playlist) => ( + { + handleBulkAddToPlaylist(playlist.name); + setSelectedSongs(new Set()); + }} + > + {playlist.name} + + ))} + + {currentPlaylist !== "All Songs" && onRemoveFromPlaylist && ( + <> + + { + onRemoveFromPlaylist(Array.from(selectedSongs)); + setSelectedSongs(new Set()); + }} + > + Remove from {currentPlaylist} + + + )} + + )} @@ -149,7 +183,7 @@ export const SongList: React.FC = ({ > - ) => { e.stopPropagation(); @@ -172,8 +206,8 @@ export const SongList: React.FC = ({ {!selectedSongs.has(song.id) && ( - - + = ({ onClick={(e: MouseEvent) => e.stopPropagation()} > ••• - - e.stopPropagation()}> + + e.stopPropagation()}> {playlists.map((playlist) => ( = ({ {playlist.name} ))} - - + + )} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index b2d2099..23cabff 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { ChakraProvider, extendTheme } from '@chakra-ui/react'; +import { BrowserRouter } from 'react-router-dom'; import './index.css'; import App from './App.tsx'; @@ -102,8 +103,10 @@ const theme = extendTheme({ createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index e050d78..bf51768 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -33,6 +33,19 @@ export const api = { return data; }, + async deleteSongs(songIds: string[]): Promise { + console.log(`Deleting ${songIds.length} songs from API...`); + const response = await fetch(`${API_URL}/songs/batch`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ids: songIds }), + }); + await handleResponse(response); + console.log(`Successfully deleted ${songIds.length} songs`); + }, + async getPlaylists(): Promise { console.log('Fetching playlists from API...'); const response = await fetch(`${API_URL}/playlists`); diff --git a/packages/frontend/src/types/Playlist.ts b/packages/frontend/src/types/Playlist.ts new file mode 100644 index 0000000..2c64f00 --- /dev/null +++ b/packages/frontend/src/types/Playlist.ts @@ -0,0 +1,7 @@ +export interface Playlist { + _id: string; + name: string; + songs: string[]; + createdAt: string; + updatedAt: string; +} \ No newline at end of file