With amazing details screen on the sidebar now!

This commit is contained in:
Geert Rademakes 2025-04-24 19:55:10 +02:00
parent ff371aa855
commit 35da4f83ce
8 changed files with 1527 additions and 1037 deletions

2003
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,5 +14,13 @@
},
"devDependencies": {
"concurrently": "^8.2.2"
},
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/input": "^2.1.2",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"framer-motion": "^12.9.1"
}
}
}

View File

@ -5,6 +5,14 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script>
// Insert this script in your index.html right after the <body> tag.
// This will help to prevent a flash if dark mode is the default.
(function() {
document.documentElement.style.backgroundColor = '#171923';
document.body.style.backgroundColor = '#171923';
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@ -10,8 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/checkbox": "^2.3.2",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/input": "^2.1.2",
"@chakra-ui/menu": "^2.2.1",
"@chakra-ui/modal": "^2.3.1",
"@chakra-ui/react": "^3.12.0",
"@chakra-ui/react": "^2.8.2",
"@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",

View File

@ -1,15 +1,69 @@
import { Box, Button, Flex, Heading, Input, Spinner, Text } from "@chakra-ui/react";
import { useState } from "react";
import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig } from "@chakra-ui/react";
import { useState, useRef } from "react";
import { SongList } from "./components/SongList";
import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails";
import { useXmlParser } from "./hooks/useXmlParser";
import { exportToXml } from "./services/xmlService";
import { api } from "./services/api";
import { Song } from "./types/interfaces";
import "./App.css";
const StyledFileInput = () => {
const { handleFileUpload } = useXmlParser();
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
return (
<Box
position="relative"
width="auto"
maxW="300px"
>
<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}
>
Choose XML File
</Button>
</Box>
);
};
export default function RekordboxReader() {
const { songs, playlists, setPlaylists, handleFileUpload, loading } = useXmlParser();
const { songs, playlists, setPlaylists, loading } = useXmlParser();
const [selectedItem, setSelectedItem] = useState<string | null>("All Songs");
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const handleCreatePlaylist = async (name: string) => {
const newPlaylist = { name, tracks: [] };
@ -18,10 +72,10 @@ export default function RekordboxReader() {
setPlaylists(savedPlaylists);
};
const handleAddSongToPlaylist = async (songId: string, playlistName: string) => {
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
const updatedPlaylists = playlists.map((playlist) =>
playlist.name === playlistName
? { ...playlist, tracks: [...playlist.tracks, songId] }
? { ...playlist, tracks: [...new Set([...playlist.tracks, ...songIds])] }
: playlist
);
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
@ -40,6 +94,10 @@ export default function RekordboxReader() {
document.body.removeChild(a);
};
const handleSongSelect = (song: Song) => {
setSelectedSong(song);
};
const displayedSongs = selectedItem === "All Songs"
? songs
: songs.filter((song) =>
@ -57,20 +115,16 @@ export default function RekordboxReader() {
return (
<Box>
<Flex direction="column" gap={2} mb={4}>
<Heading size="md">Rekordbox Reader</Heading>
<Input
type="file"
accept=".xml"
onChange={handleFileUpload}
size="sm"
width="auto"
/>
{songs.length > 0 && (
<Button onClick={handleExport} size="sm" colorScheme="blue" width="auto">
Export XML
</Button>
)}
<Flex direction="column" gap={4} mb={6}>
<Heading size="md" mb={2}>Rekordbox Reader</Heading>
<Flex gap={4} align="center">
<StyledFileInput />
{songs.length > 0 && (
<Button onClick={handleExport} size="sm" width="auto">
Export XML
</Button>
)}
</Flex>
</Flex>
<Flex gap={4} alignItems="start">
<Box w="200px">
@ -84,10 +138,13 @@ export default function RekordboxReader() {
<Box flex={1}>
<SongList
songs={displayedSongs}
onAddToPlaylist={handleAddSongToPlaylist}
onAddToPlaylist={handleAddSongsToPlaylist}
playlists={playlists}
onSongSelect={handleSongSelect}
selectedSongId={selectedSong?.id || null}
/>
</Box>
<SongDetails song={selectedSong} />
</Flex>
</Box>
);

View File

@ -0,0 +1,119 @@
import { Box, VStack, Text, Divider } from "@chakra-ui/react";
import { Song } from "../types/interfaces";
interface SongDetailsProps {
song: Song | null;
}
export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
if (!song) {
return (
<Box
w="300px"
position="sticky"
top={4}
h="calc(100vh - 2rem)"
bg="gray.800"
p={4}
borderRadius="md"
borderLeft="1px"
borderColor="gray.700"
>
<Text color="gray.500">Select a song to view details</Text>
</Box>
);
}
const details = [
{ label: "Title", value: song.title },
{ label: "Artist", value: song.artist },
{ label: "Album", value: song.album },
{ label: "Genre", value: song.genre },
{ label: "BPM", value: song.averageBpm },
{ label: "Key", value: song.tonality },
{ label: "Year", value: song.year },
{ label: "Label", value: song.label },
{ label: "Mix", value: song.mix },
{ label: "Rating", value: song.rating },
{ label: "Comments", value: song.comments },
].filter(detail => detail.value); // Only show fields that have values
return (
<Box
w="300px"
position="sticky"
top={4}
h="calc(100vh - 2rem)"
bg="gray.800"
borderRadius="md"
borderLeft="1px"
borderColor="gray.700"
display="flex"
flexDirection="column"
>
<Box p={4} flex="1" overflowY="auto" css={{
'&::-webkit-scrollbar': {
width: '4px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: 'var(--chakra-colors-gray-600)',
borderRadius: '2px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: 'var(--chakra-colors-gray-500)',
},
}}>
<VStack align="stretch" spacing={4}>
<Box>
<Text fontSize="lg" fontWeight="bold" color="white">
{song.title}
</Text>
<Text fontSize="md" color="gray.400">
{song.artist}
</Text>
</Box>
<Divider borderColor="gray.700" />
<VStack align="stretch" spacing={3}>
{details.map(({ label, value }) => (
<Box key={label}>
<Text fontSize="xs" color="gray.500" mb={1}>
{label}
</Text>
<Text fontSize="sm" color="gray.300">
{value}
</Text>
</Box>
))}
</VStack>
{song.tempo && (
<>
<Divider borderColor="gray.700" />
<Box>
<Text fontSize="xs" color="gray.500" mb={2}>
Tempo Details
</Text>
<VStack align="stretch" spacing={2}>
<Box>
<Text fontSize="xs" color="gray.500">BPM</Text>
<Text fontSize="sm" color="gray.300">{song.tempo.bpm}</Text>
</Box>
<Box>
<Text fontSize="xs" color="gray.500">Beat</Text>
<Text fontSize="sm" color="gray.300">{song.tempo.battito}</Text>
</Box>
<Box>
<Text fontSize="xs" color="gray.500">Time Signature</Text>
<Text fontSize="sm" color="gray.300">{song.tempo.metro}</Text>
</Box>
</VStack>
</Box>
</>
)}
</VStack>
</Box>
</Box>
);
};

View File

@ -1,43 +1,204 @@
import { Box, Flex, Text } from "@chakra-ui/react";
import {
Box,
Flex,
Text,
Button,
IconButton,
HStack,
} 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 { Song } from "../types/interfaces";
import { ChangeEvent } from "react";
import { useState, useCallback, useMemo, forwardRef } from "react";
import { ChangeEvent, MouseEvent } from "react";
interface SongListProps {
songs: Song[];
onAddToPlaylist: (songId: string, playlistName: string) => void;
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
playlists: { name: string }[];
onSongSelect: (song: Song) => void;
selectedSongId: string | null;
}
export const SongList: React.FC<SongListProps> = ({ songs, onAddToPlaylist, playlists }) => {
export const SongList: React.FC<SongListProps> = ({
songs,
onAddToPlaylist,
playlists,
onSongSelect,
selectedSongId
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const filteredSongs = useMemo(() => {
if (!searchQuery) return songs;
const query = searchQuery.toLowerCase();
return songs.filter(
song =>
song.title.toLowerCase().includes(query) ||
song.artist.toLowerCase().includes(query)
);
}, [songs, searchQuery]);
const toggleSelection = useCallback((songId: string) => {
setSelectedSongs(prev => {
const newSelection = new Set(prev);
if (newSelection.has(songId)) {
newSelection.delete(songId);
} else {
newSelection.add(songId);
}
return newSelection;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedSongs(prev =>
prev.size === songs.length ? new Set() : new Set(songs.map(s => s.id))
);
}, [songs]);
const handleBulkAddToPlaylist = (playlistName: string) => {
if (selectedSongs.size > 0) {
onAddToPlaylist(Array.from(selectedSongs), playlistName);
setSelectedSongs(new Set()); // Clear selection after action
}
};
const handleSongClick = (e: MouseEvent, song: Song) => {
e.stopPropagation();
onSongSelect(song);
};
return (
<Flex direction="column" gap={2}>
{songs.map((song) => (
<Box key={song.id} borderWidth="1px" borderRadius="md" p={2} shadow="sm">
<Flex justify="space-between" align="center">
<Box>
<Text fontWeight="bold" fontSize="sm">{song.title}</Text>
<Text color="gray.500" fontSize="xs">{song.artist}</Text>
</Box>
<select
onChange={(e: ChangeEvent<HTMLSelectElement>) => onAddToPlaylist(song.id, e.target.value)}
value=""
style={{
padding: '0.25rem',
fontSize: '0.875rem',
borderRadius: '0.375rem',
border: '1px solid #E2E8F0'
}}
>
<option value="" disabled>Add to playlist</option>
<Box>
{/* Search Bar */}
<InputGroup mb={4}>
<InputLeftElement pointerEvents="none">
<Search2Icon color="gray.300" />
</InputLeftElement>
<Input
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 */}
<Flex justify="space-between" align="center" mb={4} p={2} bg="gray.800" borderRadius="md">
<HStack>
<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>
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton as={Button} colorScheme="blue" size="sm">
Add to Playlist
</MenuButton>
<MenuList>
{playlists.map((playlist) => (
<option key={playlist.name} value={playlist.name}>
<MenuItem
key={playlist.name}
value={playlist.name}
onClick={() => handleBulkAddToPlaylist(playlist.name)}
>
{playlist.name}
</option>
</MenuItem>
))}
</select>
</Flex>
</Box>
))}
</Flex>
</MenuList>
</Menu>
)}
</Flex>
{/* Song List */}
<Flex direction="column" gap={2}>
{filteredSongs.map((song) => (
<Box
key={song.id}
borderWidth="1px"
borderRadius="md"
p={2}
shadow="sm"
bg={selectedSongId === song.id ? "blue.800" : selectedSongs.has(song.id) ? "gray.700" : "transparent"}
_hover={{ bg: selectedSongId === song.id ? "blue.700" : selectedSongs.has(song.id) ? "gray.700" : "gray.900" }}
transition="background-color 0.2s"
cursor="pointer"
onClick={(e: MouseEvent) => handleSongClick(e, song)}
>
<Flex justify="space-between" align="center">
<HStack>
<Checkbox
isChecked={selectedSongs.has(song.id)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
toggleSelection(song.id);
}}
colorScheme="blue"
onClick={(e: MouseEvent) => e.stopPropagation()}
sx={{
'& > span:first-of-type': {
opacity: 1,
border: '2px solid',
borderColor: 'gray.500'
}
}}
/>
<Box>
<Text fontWeight="bold" fontSize="sm">{song.title}</Text>
<Text color="gray.500" fontSize="xs">{song.artist}</Text>
</Box>
</HStack>
{!selectedSongs.has(song.id) && (
<Menu>
<MenuButton
as={IconButton}
aria-label="Add to playlist"
size="sm"
variant="ghost"
onClick={(e: MouseEvent) => e.stopPropagation()}
>
</MenuButton>
<MenuList onClick={(e: MouseEvent) => e.stopPropagation()}>
{playlists.map((playlist) => (
<MenuItem
key={playlist.name}
value={playlist.name}
onClick={() => onAddToPlaylist([song.id], playlist.name)}
>
{playlist.name}
</MenuItem>
))}
</MenuList>
</Menu>
)}
</Flex>
</Box>
))}
</Flex>
</Box>
);
};

View File

@ -1,12 +1,108 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import './index.css';
import App from './App.tsx';
const theme = extendTheme({
config: {
initialColorMode: 'dark',
useSystemColorMode: false,
},
styles: {
global: {
body: {
bg: 'gray.900',
color: 'white'
}
}
},
colors: {
gray: {
700: '#2D3748',
800: '#1A202C',
900: '#171923',
},
},
components: {
Button: {
defaultProps: {
colorScheme: 'gray',
variant: 'outline',
},
variants: {
solid: {
bg: 'gray.700',
color: 'white',
_hover: {
bg: 'gray.600',
},
_active: {
bg: 'gray.500',
},
},
outline: {
borderColor: 'gray.600',
color: 'gray.300',
_hover: {
bg: 'gray.700',
},
_active: {
bg: 'gray.600',
},
},
},
},
IconButton: {
defaultProps: {
colorScheme: 'gray',
variant: 'outline',
},
variants: {
ghost: {
color: 'gray.400',
_hover: {
bg: 'gray.700',
},
_active: {
bg: 'gray.600',
},
},
outline: {
borderColor: 'gray.600',
color: 'gray.300',
_hover: {
bg: 'gray.700',
},
_active: {
bg: 'gray.600',
},
},
},
},
Menu: {
baseStyle: {
list: {
bg: 'gray.800',
borderColor: 'gray.600',
},
item: {
bg: 'gray.800',
_hover: {
bg: 'gray.700',
},
_focus: {
bg: 'gray.700',
},
},
},
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ChakraProvider value={defaultSystem}>
<ChakraProvider theme={theme}>
<App />
</ChakraProvider>
</StrictMode>,