url based navigation
This commit is contained in:
parent
35da4f83ce
commit
ea2942edc1
@ -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"
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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">
|
||||
Create New Playlist
|
||||
</Button>
|
||||
</Stack>
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
onClick={onOpen}
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
width="100%"
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "sm",
|
||||
}}
|
||||
>
|
||||
Create New Playlist
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@ -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) => (
|
||||
<MenuItem
|
||||
key={playlist.name}
|
||||
value={playlist.name}
|
||||
onClick={() => handleBulkAddToPlaylist(playlist.name)}
|
||||
>
|
||||
{playlist.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<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}
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
<ChakraProvider theme={theme}>
|
||||
<App />
|
||||
</ChakraProvider>
|
||||
<BrowserRouter>
|
||||
<ChakraProvider theme={theme}>
|
||||
<App />
|
||||
</ChakraProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@ -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`);
|
||||
|
||||
7
packages/frontend/src/types/Playlist.ts
Normal file
7
packages/frontend/src/types/Playlist.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Playlist {
|
||||
_id: string;
|
||||
name: string;
|
||||
songs: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user