Added config page!

This commit is contained in:
Geert Rademakes 2025-04-25 10:29:24 +02:00
parent 5c1cf64c4c
commit db4408b953
4 changed files with 387 additions and 282 deletions

View File

@ -1,10 +1,11 @@
import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, useDisclosure, Container, Menu, MenuButton, MenuList, MenuItem, useToken } from "@chakra-ui/react"; import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, useDisclosure, Container, Menu, MenuButton, MenuList, MenuItem, useToken } from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon, HamburgerIcon, ViewIcon, SettingsIcon, DragHandleIcon } from "@chakra-ui/icons"; import { ChevronLeftIcon, ChevronRightIcon, HamburgerIcon, ViewIcon, SettingsIcon, DragHandleIcon } from "@chakra-ui/icons";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation, Routes, Route } 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";
import { Configuration } from "./pages/Configuration";
import { useXmlParser } from "./hooks/useXmlParser"; import { useXmlParser } from "./hooks/useXmlParser";
import { exportToXml } from "./services/xmlService"; import { exportToXml } from "./services/xmlService";
import { api } from "./services/api"; import { api } from "./services/api";
@ -430,157 +431,140 @@ export default function RekordboxReader() {
fontSize="20px" fontSize="20px"
/> />
)} )}
<Heading size={isMobile ? "sm" : "md"}>Rekordbox Reader</Heading> <Heading
size={isMobile ? "sm" : "md"}
{/* Desktop Actions */} cursor="pointer"
{!isMobile && ( onClick={() => navigate('/')}
<Flex gap={2} align="center" ml="auto"> _hover={{ color: 'blue.300' }}
<StyledFileInput /> transition="color 0.2s"
{songs.length > 0 && ( >
<Button onClick={handleExport} size="sm" width="auto"> Rekordbox Reader
Export XML </Heading>
</Button>
)}
</Flex>
)}
{/* Mobile Actions */} {/* Configuration Button */}
{isMobile && ( <IconButton
<Menu> icon={<SettingsIcon />}
<MenuButton aria-label="Configuration"
as={IconButton} variant="ghost"
icon={<SettingsIcon />} ml="auto"
variant="ghost" color="gray.300"
ml="auto" _hover={{ color: "white", bg: "whiteAlpha.200" }}
size="md" onClick={() => navigate('/config')}
color="gray.300" />
_hover={{ color: "white", bg: "whiteAlpha.200" }}
/>
<MenuList bg="gray.800" borderColor="gray.700">
<MenuItem
bg="gray.800"
_hover={{ bg: "gray.700" }}
>
<StyledFileInput isMobile />
</MenuItem>
{songs.length > 0 && (
<MenuItem
bg="gray.800"
_hover={{ bg: "gray.700" }}
onClick={handleExport}
color="gray.100"
>
Export XML
</MenuItem>
)}
</MenuList>
</Menu>
)}
</Flex> </Flex>
{/* Main Content */} {/* Main Content */}
<Flex flex={1} overflow="hidden" w="full"> <Box flex={1} overflow="hidden">
{/* Sidebar - Desktop */} <Routes>
{!isMobile && ( <Route path="/config" element={<Configuration />} />
<Box <Route
position="relative" path="*"
w={`${sidebarWidth}px`} element={
minW={`${sidebarWidth}px`} <Flex flex={1} h="100%" overflow="hidden" w="full">
p={4} {/* Sidebar - Desktop */}
borderRight="1px" {!isMobile && (
borderColor="gray.700" <Box
overflowY="auto" position="relative"
bg="gray.900" w={`${sidebarWidth}px`}
> minW={`${sidebarWidth}px`}
{playlistManager} p={4}
<ResizeHandle onMouseDown={handleResizeStart} /> borderRight="1px"
</Box> borderColor="gray.700"
)} overflowY="auto"
bg="gray.900"
>
{playlistManager}
<ResizeHandle onMouseDown={handleResizeStart} />
</Box>
)}
{/* Sidebar - Mobile */} {/* Sidebar - Mobile */}
<Drawer <Drawer
isOpen={isOpen} isOpen={isOpen}
placement="left" placement="left"
onClose={onClose} onClose={onClose}
size="full" size="full"
> >
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent bg="gray.900" p={0}> <DrawerContent bg="gray.900" p={0}>
<DrawerHeader <DrawerHeader
borderBottomWidth="1px" borderBottomWidth="1px"
bg="gray.800" bg="gray.800"
display="flex" display="flex"
alignItems="center" alignItems="center"
px={2} px={2}
py={2} py={2}
> >
<Text>Playlists</Text> <Text>Playlists</Text>
<IconButton <IconButton
aria-label="Close menu" aria-label="Close menu"
icon={<ChevronLeftIcon />} icon={<ChevronLeftIcon />}
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
ml="auto" ml="auto"
color="blue.400" color="blue.400"
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }} _hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
/> />
</DrawerHeader> </DrawerHeader>
<DrawerBody p={2}> <DrawerBody p={2}>
{playlistManager} {playlistManager}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
{/* Main Content Area */} {/* Main Content Area */}
<Flex <Flex
flex={1} flex={1}
gap={4} gap={4}
p={isMobile ? 2 : 4} p={isMobile ? 2 : 4}
overflowY="hidden" h="100%"
w="full" overflow="hidden"
> >
{/* Song List */} {/* Song List */}
<Box flex={1} overflowY="auto" w="full"> <Box flex={1} overflowY="auto" minH={0}>
<SongList <SongList
songs={displayedSongs} songs={displayedSongs}
onAddToPlaylist={handleAddSongsToPlaylist} onAddToPlaylist={handleAddSongsToPlaylist}
onRemoveFromPlaylist={handleRemoveFromPlaylist} onRemoveFromPlaylist={handleRemoveFromPlaylist}
playlists={playlists} playlists={playlists}
onSongSelect={setSelectedSong} onSongSelect={setSelectedSong}
selectedSongId={selectedSong?.id || null} selectedSongId={selectedSong?.id || null}
currentPlaylist={currentPlaylist} currentPlaylist={currentPlaylist}
/> />
</Box> </Box>
{/* Details Panel */} {/* Details Panel */}
{!isMobile && ( {!isMobile && (
<Box <Box
w="350px" w="350px"
minW="350px" minW="350px"
p={4} p={4}
borderLeft="1px" borderLeft="1px"
borderColor="gray.700" borderColor="gray.700"
overflowY="auto" overflowY="auto"
bg="gray.900" bg="gray.900"
sx={{ minH={0}
'&::-webkit-scrollbar': { sx={{
width: '8px', '&::-webkit-scrollbar': {
borderRadius: '8px', width: '8px',
backgroundColor: 'gray.900', borderRadius: '8px',
}, backgroundColor: 'gray.900',
'&::-webkit-scrollbar-thumb': { },
backgroundColor: 'gray.700', '&::-webkit-scrollbar-thumb': {
borderRadius: '8px', backgroundColor: 'gray.700',
}, borderRadius: '8px',
overflowY: 'auto', },
overflowX: 'hidden', }}
}} >
> <SongDetails song={selectedSong} />
<SongDetails song={selectedSong} /> </Box>
</Box> )}
)} </Flex>
</Flex> </Flex>
</Flex> }
/>
</Routes>
</Box>
</Flex> </Flex>
</Box> </Box>
); );

View File

@ -107,144 +107,72 @@ export const SongList: React.FC<SongListProps> = ({
}, [filteredSongs]); }, [filteredSongs]);
return ( return (
<Box> <Flex direction="column" height="100%">
{/* Search Bar */} {/* Sticky Header */}
<InputGroup mb={4}> <Box
<InputLeftElement pointerEvents="none"> position="sticky"
<Search2Icon color="gray.300" /> top={0}
</InputLeftElement> bg="gray.900"
<Input zIndex={1}
placeholder="Search songs by title or artist..." pb={4}
value={searchQuery} >
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)} {/* Search Bar */}
bg="gray.800" <InputGroup mb={4}>
borderColor="gray.600" <InputLeftElement pointerEvents="none">
_hover={{ borderColor: "gray.500" }} <Search2Icon color="gray.300" />
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }} </InputLeftElement>
/> <Input
</InputGroup> placeholder="Search songs by title or artist..."
value={searchQuery}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
bg="gray.800"
borderColor="gray.600"
_hover={{ borderColor: "gray.500" }}
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
/>
</InputGroup>
{/* 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" p={2} bg="gray.800" borderRadius="md">
<HStack spacing={4}> <HStack spacing={4}>
<Checkbox
isChecked={selectedSongs.size === filteredSongs.length}
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
onChange={toggleSelectAll}
colorScheme="blue"
sx={{
'& > span:first-of-type': {
opacity: 1,
border: '2px solid',
borderColor: 'gray.500'
}
}}
>
{selectedSongs.size === 0
? "Select All"
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
</Checkbox>
<Text color="gray.400" fontSize="sm">
{filteredSongs.length} song{filteredSongs.length === 1 ? '' : 's'} {formatTotalDuration(totalDuration)}
</Text>
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
colorScheme="blue"
>
Actions
</MenuButton>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={() => {
onRemoveFromPlaylist(Array.from(selectedSongs));
setSelectedSongs(new Set());
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
)}
</Flex>
{/* Song List */}
<Flex direction="column" gap={2}>
{filteredSongs.map((song) => (
<Flex
key={song.id}
alignItems="center"
p={2}
pl={depth > 0 ? 4 + (depth * 4) : 2}
borderBottom="1px"
borderColor="gray.700"
bg={selectedSongId === song.id ? "gray.700" : "transparent"}
_hover={{ bg: "gray.800", cursor: "pointer" }}
onClick={() => onSongSelect(song)}
>
<Checkbox <Checkbox
isChecked={selectedSongs.has(song.id)} isChecked={selectedSongs.size === filteredSongs.length}
onChange={(e: ChangeEvent<HTMLInputElement>) => { isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
e.stopPropagation(); onChange={toggleSelectAll}
toggleSelection(song.id); colorScheme="blue"
sx={{
'& > span:first-of-type': {
opacity: 1,
border: '2px solid',
borderColor: 'gray.500'
}
}} }}
mr={4} >
onClick={(e) => e.stopPropagation()} {selectedSongs.size === 0
size={depth > 0 ? "sm" : "md"} ? "Select All"
/> : `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
<Box flex="1"> </Checkbox>
<Text <Text color="gray.400" fontSize="sm">
fontWeight="bold" {filteredSongs.length} song{filteredSongs.length === 1 ? '' : 's'} {formatTotalDuration(totalDuration)}
color={selectedSongId === song.id ? "white" : "gray.100"} </Text>
fontSize={depth > 0 ? "sm" : "md"} </HStack>
>
{song.title} {selectedSongs.size > 0 && (
</Text>
<Text
fontSize={depth > 0 ? "xs" : "sm"}
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
>
{song.artist} {formatDuration(song.totalTime)}
</Text>
</Box>
<Menu> <Menu>
<MenuButton <MenuButton
as={IconButton} as={Button}
aria-label="Options" rightIcon={<ChevronDownIcon />}
icon={<ChevronDownIcon />} size="sm"
variant="ghost" colorScheme="blue"
onClick={(e) => e.stopPropagation()} >
size={depth > 0 ? "xs" : "sm"} Actions
ml="auto" </MenuButton>
/>
<MenuList> <MenuList>
{allPlaylists.map((playlist) => ( {allPlaylists.map((playlist) => (
<MenuItem <MenuItem
key={playlist.id} key={playlist.id}
onClick={(e) => { onClick={() => {
e.stopPropagation(); handleBulkAddToPlaylist(playlist.name);
onAddToPlaylist([song.id], playlist.name);
}} }}
> >
Add to {playlist.name} Add to {playlist.name}
@ -255,9 +183,9 @@ export const SongList: React.FC<SongListProps> = ({
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
color="red.300" color="red.300"
onClick={(e) => { onClick={() => {
e.stopPropagation(); onRemoveFromPlaylist(Array.from(selectedSongs));
onRemoveFromPlaylist([song.id]); setSelectedSongs(new Set());
}} }}
> >
Remove from {currentPlaylist} Remove from {currentPlaylist}
@ -266,9 +194,92 @@ export const SongList: React.FC<SongListProps> = ({
)} )}
</MenuList> </MenuList>
</Menu> </Menu>
</Flex> )}
))} </Flex>
</Flex> </Box>
</Box>
{/* Scrollable Song List */}
<Box flex={1} overflowY="auto" mt={2}>
<Flex direction="column" gap={2}>
{filteredSongs.map((song) => (
<Flex
key={song.id}
alignItems="center"
p={2}
pl={depth > 0 ? 4 + (depth * 4) : 2}
borderBottom="1px"
borderColor="gray.700"
bg={selectedSongId === song.id ? "gray.700" : "transparent"}
_hover={{ bg: "gray.800", cursor: "pointer" }}
onClick={() => onSongSelect(song)}
>
<Checkbox
isChecked={selectedSongs.has(song.id)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
toggleSelection(song.id);
}}
mr={4}
onClick={(e) => e.stopPropagation()}
size={depth > 0 ? "sm" : "md"}
/>
<Box flex="1">
<Text
fontWeight="bold"
color={selectedSongId === song.id ? "white" : "gray.100"}
fontSize={depth > 0 ? "sm" : "md"}
>
{song.title}
</Text>
<Text
fontSize={depth > 0 ? "xs" : "sm"}
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
>
{song.artist} {formatDuration(song.totalTime)}
</Text>
</Box>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<ChevronDownIcon />}
variant="ghost"
onClick={(e) => e.stopPropagation()}
size={depth > 0 ? "xs" : "sm"}
ml="auto"
/>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={(e) => {
e.stopPropagation();
onAddToPlaylist([song.id], playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={(e) => {
e.stopPropagation();
onRemoveFromPlaylist([song.id]);
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
</Flex>
))}
</Flex>
</Box>
</Flex>
); );
}; };

View File

@ -0,0 +1,59 @@
import React, { useRef } from 'react';
import { Box, Button, Input } from "@chakra-ui/react";
import { useXmlParser } from "../hooks/useXmlParser";
interface StyledFileInputProps {
isMobile?: boolean;
}
export const StyledFileInput: React.FC<StyledFileInputProps> = ({ isMobile = false }) => {
const { handleFileUpload } = useXmlParser();
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
return (
<Box
position="relative"
width="auto"
maxW={isMobile ? "100%" : "full"}
>
<Input
type="file"
accept=".xml"
onChange={handleFileUpload}
height="40px"
padding="8px 12px"
opacity="0"
position="absolute"
top="0"
left="0"
width="100%"
cursor="pointer"
ref={inputRef}
style={{ display: 'none' }}
/>
<Button
width="100%"
height="40px"
bg="gray.700"
color="gray.300"
border="2px dashed"
borderColor="gray.600"
_hover={{
bg: "gray.600",
borderColor: "gray.500"
}}
_active={{
bg: "gray.500"
}}
onClick={handleClick}
size={isMobile ? "sm" : "md"}
>
Choose XML File
</Button>
</Box>
);
};

View File

@ -0,0 +1,51 @@
import { Box, Heading, VStack, Text, Button } from "@chakra-ui/react";
import { useXmlParser } from "../hooks/useXmlParser";
import { exportToXml } from "../services/xmlService";
import { StyledFileInput } from "../components/StyledFileInput";
export function Configuration() {
const { songs } = useXmlParser();
const handleExport = () => {
const xmlContent = exportToXml(songs, []);
const blob = new Blob([xmlContent], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "rekordbox_playlists.xml";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
return (
<Box h="100%" bg="gray.900" p={8}>
<VStack spacing={8} align="stretch" maxW="2xl" mx="auto">
<Heading size="lg" mb={6}>Configuration</Heading>
<Box bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<Heading size="md" mb={4}>Library Management</Heading>
<Text color="gray.400" mb={6}>
Import your Rekordbox XML library or export your current library to XML format.
</Text>
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>Import Library</Text>
<StyledFileInput />
</Box>
{songs.length > 0 && (
<Box>
<Text fontWeight="medium" mb={2}>Export Library</Text>
<Button onClick={handleExport} width="full">
Export XML
</Button>
</Box>
)}
</VStack>
</Box>
</VStack>
</Box>
);
}