url based navigation

This commit is contained in:
Geert Rademakes 2025-04-24 22:11:51 +02:00
parent 35da4f83ce
commit ea2942edc1
7 changed files with 244 additions and 108 deletions

View File

@ -23,6 +23,7 @@
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.5.2",
"sax": "^1.4.1", "sax": "^1.4.1",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1" "xmlbuilder": "^15.1.1"

View File

@ -1,5 +1,6 @@
import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig } from "@chakra-ui/react"; 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 { SongList } from "./components/SongList";
import { PlaylistManager } from "./components/PlaylistManager"; import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails"; import { SongDetails } from "./components/SongDetails";
@ -62,14 +63,47 @@ const StyledFileInput = () => {
export default function RekordboxReader() { export default function RekordboxReader() {
const { songs, playlists, setPlaylists, loading } = useXmlParser(); const { songs, playlists, setPlaylists, loading } = useXmlParser();
const [selectedItem, setSelectedItem] = useState<string | null>("All Songs");
const [selectedSong, setSelectedSong] = useState<Song | null>(null); const [selectedSong, setSelectedSong] = useState<Song | null>(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 handleCreatePlaylist = async (name: string) => {
const newPlaylist = { name, tracks: [] }; const newPlaylist = { name, tracks: [] };
const updatedPlaylists = [...playlists, newPlaylist]; const updatedPlaylists = [...playlists, newPlaylist];
const savedPlaylists = await api.savePlaylists(updatedPlaylists); const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists); setPlaylists(savedPlaylists);
handlePlaylistSelect(name); // Navigate to the new playlist
}; };
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => { const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
@ -82,6 +116,18 @@ export default function RekordboxReader() {
setPlaylists(savedPlaylists); 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 handleExport = () => {
const xmlContent = exportToXml(songs, playlists); const xmlContent = exportToXml(songs, playlists);
const blob = new Blob([xmlContent], { type: "application/xml" }); const blob = new Blob([xmlContent], { type: "application/xml" });
@ -94,14 +140,10 @@ export default function RekordboxReader() {
document.body.removeChild(a); document.body.removeChild(a);
}; };
const handleSongSelect = (song: Song) => { const displayedSongs = currentPlaylist === "All Songs"
setSelectedSong(song);
};
const displayedSongs = selectedItem === "All Songs"
? songs ? songs
: songs.filter((song) => : 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) { if (loading) {
@ -109,6 +151,11 @@ export default function RekordboxReader() {
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}> <Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
<Spinner size="xl" /> <Spinner size="xl" />
<Text>Loading your library...</Text> <Text>Loading your library...</Text>
{currentPlaylist !== "All Songs" && (
<Text fontSize="sm" color="gray.500">
Navigating to playlist: {currentPlaylist}
</Text>
)}
</Flex> </Flex>
); );
} }
@ -130,18 +177,20 @@ export default function RekordboxReader() {
<Box w="200px"> <Box w="200px">
<PlaylistManager <PlaylistManager
playlists={playlists} playlists={playlists}
selectedItem={selectedItem} selectedItem={currentPlaylist}
onPlaylistCreate={handleCreatePlaylist} onPlaylistCreate={handleCreatePlaylist}
onPlaylistSelect={setSelectedItem} onPlaylistSelect={handlePlaylistSelect}
/> />
</Box> </Box>
<Box flex={1}> <Box flex={1}>
<SongList <SongList
songs={displayedSongs} songs={displayedSongs}
onAddToPlaylist={handleAddSongsToPlaylist} onAddToPlaylist={handleAddSongsToPlaylist}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
playlists={playlists} playlists={playlists}
onSongSelect={handleSongSelect} onSongSelect={setSelectedSong}
selectedSongId={selectedSong?.id || null} selectedSongId={selectedSong?.id || null}
currentPlaylist={currentPlaylist}
/> />
</Box> </Box>
<SongDetails song={selectedSong} /> <SongDetails song={selectedSong} />

View File

@ -1,35 +1,55 @@
import { import {
Box,
Button, Button,
Flex,
Input, Input,
Stack,
useDisclosure,
CloseButton,
} from "@chakra-ui/react";
import {
Modal, Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody, ModalBody,
} from "@chakra-ui/modal"; ModalCloseButton,
import { useState } from "react"; ModalContent,
import { Playlist } from "../types/interfaces"; ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useDisclosure,
VStack,
} from "@chakra-ui/react";
import React, { useState } from "react";
import { Playlist } from "../types/Playlist";
interface PlaylistManagerProps { interface PlaylistManagerProps {
playlists: Playlist[]; playlists: Playlist[];
selectedItem: string | null; selectedItem: string | null;
onPlaylistCreate: (name: string) => void; 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<PlaylistManagerProps> = ({ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
playlists, playlists,
selectedItem, selectedItem,
onPlaylistCreate, onPlaylistCreate,
onPlaylistSelect, onPlaylistSelect,
}) => { }) => {
const { open, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [newPlaylistName, setNewPlaylistName] = useState(""); const [newPlaylistName, setNewPlaylistName] = useState("");
const handleCreatePlaylist = () => { const handleCreatePlaylist = () => {
@ -41,88 +61,97 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
}; };
return ( return (
<> <Box>
<Stack gap={2}> <VStack spacing={2} align="stretch" mb={4}>
<Button <Button
onClick={() => onPlaylistSelect("All Songs")} {...getButtonStyles(selectedItem === null)}
colorScheme={selectedItem === "All Songs" ? "blue" : "gray"} onClick={() => onPlaylistSelect(null)}
size="sm"
> >
All Songs All Songs
</Button> </Button>
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<Button <Button
key={playlist.name} key={playlist._id}
{...getButtonStyles(selectedItem === playlist.name)}
onClick={() => onPlaylistSelect(playlist.name)} onClick={() => onPlaylistSelect(playlist.name)}
colorScheme={selectedItem === playlist.name ? "blue" : "gray"}
size="sm"
> >
{playlist.name} {playlist.name}
</Button> </Button>
))} ))}
<Button onClick={onOpen} colorScheme="green" size="sm"> </VStack>
Create New Playlist
</Button>
</Stack>
<Modal <Button
isOpen={open} onClick={onOpen}
colorScheme="blue"
size="sm"
width="100%"
borderRadius="md"
_hover={{
transform: "translateY(-1px)",
boxShadow: "sm",
}}
>
Create New Playlist
</Button>
<Modal
isOpen={isOpen}
onClose={onClose} onClose={onClose}
isCentered isCentered
motionPreset="slideInBottom" motionPreset="slideInBottom"
> >
<ModalOverlay <ModalOverlay bg="blackAlpha.700" backdropFilter="blur(5px)" />
style={{
backgroundColor: 'rgba(0, 0, 0, 0.4)',
backdropFilter: 'blur(2px)'
}}
/>
<ModalContent <ModalContent
padding={"1rem"} bg="gray.800"
bg="gray.20" borderRadius="xl"
bgColor={"rgba(0, 0, 0, 1)"} boxShadow="xl"
maxW="500px" border="1px"
w="90%" borderColor="gray.700"
borderRadius={25}
boxShadow={100}
margin="0 auto"
marginTop="10%"
> >
<ModalHeader color="gray.800" fontSize="lg" fontWeight="bold" pt={6} px={6}> <ModalHeader color="white">Create New Playlist</ModalHeader>
Create New Playlist <ModalCloseButton color="white" />
</ModalHeader> <ModalBody pb={6}>
<CloseButton
position="absolute"
right={'1rem'}
top={'1rem'}
onClick={onClose}
color="gray.600"
/>
<ModalBody px={6} py={8} paddingTop="2rem">
<Input <Input
value={newPlaylistName} value={newPlaylistName}
onChange={(e) => setNewPlaylistName(e.target.value)} onChange={(e) => setNewPlaylistName(e.target.value)}
placeholder="Enter playlist name" placeholder="Enter playlist name"
autoFocus bg="gray.700"
size="lg" border="none"
_placeholder={{ color: 'gray.400' }} color="white"
onKeyDown={(e) => { _placeholder={{ color: "gray.400" }}
if (e.key === 'Enter') { _focus={{
handleCreatePlaylist(); boxShadow: "0 0 0 1px blue.500",
} borderColor: "blue.500",
}} }}
/> />
</ModalBody> </ModalBody>
<ModalFooter px={6} py={4} borderTop="1px" borderColor="gray.100">
<Button colorScheme="blue" mr={3} onClick={handleCreatePlaylist} size="md" px={8}> <ModalFooter>
<Button
colorScheme="blue"
mr={3}
onClick={handleCreatePlaylist}
isDisabled={!newPlaylistName.trim()}
_hover={{
transform: "translateY(-1px)",
boxShadow: "sm",
}}
>
Create Create
</Button> </Button>
<Button onClick={onClose} size="md" variant="ghost" color="gray.600"> <Button
variant="ghost"
onClick={onClose}
color="gray.300"
_hover={{
bg: "whiteAlpha.200",
}}
>
Cancel Cancel
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
</> </Box>
); );
}; };

View File

@ -5,11 +5,16 @@ import {
Button, Button,
IconButton, IconButton,
HStack, HStack,
Menu as ChakraMenu,
MenuButton as ChakraMenuButton,
MenuList as ChakraMenuList,
MenuItem,
MenuDivider,
MenuGroup,
Checkbox as ChakraCheckbox
} from "@chakra-ui/react"; } 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 { 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 { Song } from "../types/interfaces";
import { useState, useCallback, useMemo, forwardRef } from "react"; import { useState, useCallback, useMemo, forwardRef } from "react";
import { ChangeEvent, MouseEvent } from "react"; import { ChangeEvent, MouseEvent } from "react";
@ -17,17 +22,21 @@ import { ChangeEvent, MouseEvent } from "react";
interface SongListProps { interface SongListProps {
songs: Song[]; songs: Song[];
onAddToPlaylist: (songIds: string[], playlistName: string) => void; onAddToPlaylist: (songIds: string[], playlistName: string) => void;
onRemoveFromPlaylist?: (songIds: string[]) => void;
playlists: { name: string }[]; playlists: { name: string }[];
onSongSelect: (song: Song) => void; onSongSelect: (song: Song) => void;
selectedSongId: string | null; selectedSongId: string | null;
currentPlaylist: string | null;
} }
export const SongList: React.FC<SongListProps> = ({ export const SongList: React.FC<SongListProps> = ({
songs, songs,
onAddToPlaylist, onAddToPlaylist,
onRemoveFromPlaylist,
playlists, playlists,
onSongSelect, onSongSelect,
selectedSongId selectedSongId,
currentPlaylist
}) => { }) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set()); const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -93,7 +102,7 @@ export const SongList: React.FC<SongListProps> = ({
{/* Bulk Actions Toolbar */} {/* Bulk Actions Toolbar */}
<Flex justify="space-between" align="center" mb={4} p={2} bg="gray.800" borderRadius="md"> <Flex justify="space-between" align="center" mb={4} p={2} bg="gray.800" borderRadius="md">
<HStack> <HStack>
<Checkbox <ChakraCheckbox
isChecked={selectedSongs.size === filteredSongs.length} isChecked={selectedSongs.size === filteredSongs.length}
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length} isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
onChange={toggleSelectAll} onChange={toggleSelectAll}
@ -109,26 +118,51 @@ export const SongList: React.FC<SongListProps> = ({
{selectedSongs.size === 0 {selectedSongs.size === 0
? "Select All" ? "Select All"
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`} : `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
</Checkbox> </ChakraCheckbox>
</HStack> </HStack>
{selectedSongs.size > 0 && ( {selectedSongs.size > 0 && (
<Menu> <ChakraMenu>
<MenuButton as={Button} colorScheme="blue" size="sm"> <ChakraMenuButton
Add to Playlist as={Button}
</MenuButton> rightIcon={<ChevronDownIcon />}
<MenuList> size="sm"
{playlists.map((playlist) => ( colorScheme="blue"
<MenuItem >
key={playlist.name} Actions
value={playlist.name} </ChakraMenuButton>
onClick={() => handleBulkAddToPlaylist(playlist.name)} <ChakraMenuList>
> <MenuGroup title="Add to Playlist">
{playlist.name} {playlists
</MenuItem> .filter(p => p.name !== currentPlaylist)
))} .map((playlist) => (
</MenuList> <MenuItem
</Menu> key={playlist.name}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
setSelectedSongs(new Set());
}}
>
{playlist.name}
</MenuItem>
))}
</MenuGroup>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={() => {
onRemoveFromPlaylist(Array.from(selectedSongs));
setSelectedSongs(new Set());
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</ChakraMenuList>
</ChakraMenu>
)} )}
</Flex> </Flex>
@ -149,7 +183,7 @@ export const SongList: React.FC<SongListProps> = ({
> >
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<HStack> <HStack>
<Checkbox <ChakraCheckbox
isChecked={selectedSongs.has(song.id)} isChecked={selectedSongs.has(song.id)}
onChange={(e: ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation(); e.stopPropagation();
@ -172,8 +206,8 @@ export const SongList: React.FC<SongListProps> = ({
</HStack> </HStack>
{!selectedSongs.has(song.id) && ( {!selectedSongs.has(song.id) && (
<Menu> <ChakraMenu>
<MenuButton <ChakraMenuButton
as={IconButton} as={IconButton}
aria-label="Add to playlist" aria-label="Add to playlist"
size="sm" size="sm"
@ -181,8 +215,8 @@ export const SongList: React.FC<SongListProps> = ({
onClick={(e: MouseEvent) => e.stopPropagation()} onClick={(e: MouseEvent) => e.stopPropagation()}
> >
</MenuButton> </ChakraMenuButton>
<MenuList onClick={(e: MouseEvent) => e.stopPropagation()}> <ChakraMenuList onClick={(e: MouseEvent) => e.stopPropagation()}>
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<MenuItem <MenuItem
key={playlist.name} key={playlist.name}
@ -192,8 +226,8 @@ export const SongList: React.FC<SongListProps> = ({
{playlist.name} {playlist.name}
</MenuItem> </MenuItem>
))} ))}
</MenuList> </ChakraMenuList>
</Menu> </ChakraMenu>
)} )}
</Flex> </Flex>
</Box> </Box>

View File

@ -1,6 +1,7 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { ChakraProvider, extendTheme } from '@chakra-ui/react'; import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { BrowserRouter } from 'react-router-dom';
import './index.css'; import './index.css';
import App from './App.tsx'; import App from './App.tsx';
@ -102,8 +103,10 @@ const theme = extendTheme({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ChakraProvider theme={theme}> <BrowserRouter>
<App /> <ChakraProvider theme={theme}>
</ChakraProvider> <App />
</ChakraProvider>
</BrowserRouter>
</StrictMode>, </StrictMode>,
); );

View File

@ -33,6 +33,19 @@ export const api = {
return data; return data;
}, },
async deleteSongs(songIds: string[]): Promise<void> {
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<void>(response);
console.log(`Successfully deleted ${songIds.length} songs`);
},
async getPlaylists(): Promise<Playlist[]> { async getPlaylists(): Promise<Playlist[]> {
console.log('Fetching playlists from API...'); console.log('Fetching playlists from API...');
const response = await fetch(`${API_URL}/playlists`); const response = await fetch(`${API_URL}/playlists`);

View File

@ -0,0 +1,7 @@
export interface Playlist {
_id: string;
name: string;
songs: string[];
createdAt: string;
updatedAt: string;
}