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",
"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"

View File

@ -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<string | null>("All Songs");
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 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() {
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
<Spinner size="xl" />
<Text>Loading your library...</Text>
{currentPlaylist !== "All Songs" && (
<Text fontSize="sm" color="gray.500">
Navigating to playlist: {currentPlaylist}
</Text>
)}
</Flex>
);
}
@ -130,18 +177,20 @@ export default function RekordboxReader() {
<Box w="200px">
<PlaylistManager
playlists={playlists}
selectedItem={selectedItem}
selectedItem={currentPlaylist}
onPlaylistCreate={handleCreatePlaylist}
onPlaylistSelect={setSelectedItem}
onPlaylistSelect={handlePlaylistSelect}
/>
</Box>
<Box flex={1}>
<SongList
songs={displayedSongs}
onAddToPlaylist={handleAddSongsToPlaylist}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
playlists={playlists}
onSongSelect={handleSongSelect}
onSongSelect={setSelectedSong}
selectedSongId={selectedSong?.id || null}
currentPlaylist={currentPlaylist}
/>
</Box>
<SongDetails song={selectedSong} />

View File

@ -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<PlaylistManagerProps> = ({
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<PlaylistManagerProps> = ({
};
return (
<>
<Stack gap={2}>
<Box>
<VStack spacing={2} align="stretch" mb={4}>
<Button
onClick={() => onPlaylistSelect("All Songs")}
colorScheme={selectedItem === "All Songs" ? "blue" : "gray"}
size="sm"
{...getButtonStyles(selectedItem === null)}
onClick={() => onPlaylistSelect(null)}
>
All Songs
</Button>
{playlists.map((playlist) => (
<Button
key={playlist.name}
key={playlist._id}
{...getButtonStyles(selectedItem === playlist.name)}
onClick={() => onPlaylistSelect(playlist.name)}
colorScheme={selectedItem === playlist.name ? "blue" : "gray"}
size="sm"
>
{playlist.name}
</Button>
))}
<Button onClick={onOpen} colorScheme="green" size="sm">
</VStack>
<Button
onClick={onOpen}
colorScheme="blue"
size="sm"
width="100%"
borderRadius="md"
_hover={{
transform: "translateY(-1px)",
boxShadow: "sm",
}}
>
Create New Playlist
</Button>
</Stack>
<Modal
isOpen={open}
isOpen={isOpen}
onClose={onClose}
isCentered
motionPreset="slideInBottom"
>
<ModalOverlay
style={{
backgroundColor: 'rgba(0, 0, 0, 0.4)',
backdropFilter: 'blur(2px)'
}}
/>
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(5px)" />
<ModalContent
padding={"1rem"}
bg="gray.20"
bgColor={"rgba(0, 0, 0, 1)"}
maxW="500px"
w="90%"
borderRadius={25}
boxShadow={100}
margin="0 auto"
marginTop="10%"
bg="gray.800"
borderRadius="xl"
boxShadow="xl"
border="1px"
borderColor="gray.700"
>
<ModalHeader color="gray.800" fontSize="lg" fontWeight="bold" pt={6} px={6}>
Create New Playlist
</ModalHeader>
<CloseButton
position="absolute"
right={'1rem'}
top={'1rem'}
onClick={onClose}
color="gray.600"
/>
<ModalBody px={6} py={8} paddingTop="2rem">
<ModalHeader color="white">Create New Playlist</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6}>
<Input
value={newPlaylistName}
onChange={(e) => 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",
}}
/>
</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
</Button>
<Button onClick={onClose} size="md" variant="ghost" color="gray.600">
<Button
variant="ghost"
onClick={onClose}
color="gray.300"
_hover={{
bg: "whiteAlpha.200",
}}
>
Cancel
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
</Box>
);
};

View File

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

View File

@ -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(
<StrictMode>
<BrowserRouter>
<ChakraProvider theme={theme}>
<App />
</ChakraProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@ -33,6 +33,19 @@ export const api = {
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[]> {
console.log('Fetching playlists from API...');
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;
}