2025-04-25 10:29:24 +02:00

572 lines
18 KiB
TypeScript

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 { useState, useRef, useEffect } from "react";
import { useNavigate, useLocation, Routes, Route } from "react-router-dom";
import { SongList } from "./components/SongList";
import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails";
import { Configuration } from "./pages/Configuration";
import { useXmlParser } from "./hooks/useXmlParser";
import { exportToXml } from "./services/xmlService";
import { api } from "./services/api";
import type { Song, PlaylistNode } from "./types/interfaces";
import { v4 as uuidv4 } from "uuid";
import "./App.css";
const StyledFileInput = ({ isMobile = false }) => {
const { handleFileUpload } = useXmlParser();
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
return (
<Box
position="relative"
width="auto"
maxW={isMobile ? "100%" : "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}
size={isMobile ? "sm" : "md"}
>
Choose XML File
</Button>
</Box>
);
};
const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => void }) => (
<Box
position="absolute"
right="-4px"
top={0}
bottom={0}
width="8px"
cursor="col-resize"
zIndex={1}
_hover={{
'&::after': {
bg: 'blue.500',
}
}}
onMouseDown={onMouseDown}
>
<Box
position="absolute"
left="3px"
top={0}
bottom={0}
width="2px"
bg="gray.600"
transition="background-color 0.2s"
_hover={{ bg: 'blue.500' }}
/>
</Box>
);
const findPlaylistByName = (playlists: PlaylistNode[], name: string): PlaylistNode | null => {
for (const playlist of playlists) {
if (playlist.name === name) return playlist;
if (playlist.type === 'folder' && playlist.children) {
const found = findPlaylistByName(playlist.children, name);
if (found) return found;
}
}
return null;
};
const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
if (!node.type || node.type === 'playlist') { // Handle both old and new playlist formats
return node.tracks || [];
}
if (node.type === 'folder' && node.children) {
return node.children.flatMap(child => getAllPlaylistTracks(child));
}
return [];
};
export default function RekordboxReader() {
const { songs, playlists, setPlaylists, loading } = useXmlParser();
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const navigate = useNavigate();
const location = useLocation();
const initialLoadDone = useRef(false);
const mobileFileInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const isMobile = useBreakpointValue({ base: true, md: false });
const [sidebarWidth, setSidebarWidth] = useState(400);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
// Get the current playlist from URL or default to "All Songs"
const currentPlaylist = location.pathname.startsWith("/playlists/")
? decodeURIComponent(location.pathname.slice("/playlists/".length))
: "All Songs";
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" &&
!findPlaylistByName(playlists, 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(`/playlists/${encodedName}`);
}
};
const handleCreatePlaylist = async (name: string) => {
const newPlaylist: PlaylistNode = {
id: uuidv4(),
name,
type: 'playlist',
tracks: [],
children: undefined
};
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) => {
const updatedPlaylists = playlists.map(node => {
if (node.name === playlistName && node.type === 'playlist') {
return {
...node,
tracks: [...new Set([...(node.tracks || []), ...songIds])]
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: node.children.map(child => {
if (child.name === playlistName && child.type === 'playlist') {
return {
...child,
tracks: [...new Set([...(child.tracks || []), ...songIds])]
};
}
return child;
})
};
}
return node;
});
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
const handleRemoveFromPlaylist = async (songIds: string[]) => {
if (currentPlaylist === "All Songs") return;
const updatedPlaylists = playlists.map(node => {
if (node.name === currentPlaylist && node.type === 'playlist') {
return {
...node,
tracks: (node.tracks || []).filter(id => !songIds.includes(id))
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: node.children.map(child => {
if (child.name === currentPlaylist && child.type === 'playlist') {
return {
...child,
tracks: (child.tracks || []).filter(id => !songIds.includes(id))
};
}
return child;
})
};
}
return node;
});
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
const handleExport = () => {
const xmlContent = exportToXml(songs, playlists);
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);
};
const handlePlaylistDelete = async (name: string) => {
const updatedPlaylists = playlists.filter(p => p.name !== name);
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
if (currentPlaylist === name) {
navigate("/"); // Navigate back to All Songs if the current playlist is deleted
}
};
const handleCreateFolder = async (name: string) => {
const newFolder: PlaylistNode = {
id: uuidv4(),
name,
type: 'folder',
children: [],
};
const updatedPlaylists = [...playlists, newFolder];
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
const handleMovePlaylist = async (playlistName: string, targetFolderName: string | null) => {
let updatedPlaylists = [...playlists];
let playlistToMove: PlaylistNode | null = null;
// Helper function to remove playlist from its current location
const removePlaylist = (nodes: PlaylistNode[]): PlaylistNode[] => {
return nodes.reduce((acc: PlaylistNode[], node) => {
if (node.name === playlistName) {
playlistToMove = node;
return acc;
}
if (node.type === 'folder' && node.children) {
return [...acc, {
...node,
children: removePlaylist(node.children)
}];
}
return [...acc, node];
}, []);
};
// First, remove the playlist from its current location
updatedPlaylists = removePlaylist(updatedPlaylists);
if (!playlistToMove) return; // Playlist not found
if (targetFolderName === null) {
// Move to root level
updatedPlaylists.push(playlistToMove);
} else {
// Move to target folder
const addToFolder = (nodes: PlaylistNode[]): PlaylistNode[] => {
return nodes.map(node => {
if (node.name === targetFolderName && node.type === 'folder') {
return {
...node,
children: [...(node.children || []), playlistToMove!]
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: addToFolder(node.children)
};
}
return node;
});
};
updatedPlaylists = addToFolder(updatedPlaylists);
}
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
const displayedSongs = currentPlaylist === "All Songs"
? songs
: songs.filter((song: Song) => {
// Try to find the playlist either in nested structure or at root level
const playlist = findPlaylistByName(playlists, currentPlaylist) ||
playlists.find(p => p.name === currentPlaylist);
if (!playlist) return false;
// Use getAllPlaylistTracks for both nested and root playlists
const playlistTracks = getAllPlaylistTracks(playlist);
return playlistTracks.includes(song.id);
});
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
resizeRef.current = {
startX: e.pageX,
startWidth: sidebarWidth,
};
};
const handleResizeMove = (e: MouseEvent) => {
if (!isResizing || !resizeRef.current) return;
const delta = e.pageX - resizeRef.current.startX;
const newWidth = Math.max(300, Math.min(800, resizeRef.current.startWidth + delta));
setSidebarWidth(newWidth);
};
const handleResizeEnd = () => {
setIsResizing(false);
resizeRef.current = null;
};
useEffect(() => {
if (isResizing) {
window.addEventListener('mousemove', handleResizeMove);
window.addEventListener('mouseup', handleResizeEnd);
}
return () => {
window.removeEventListener('mousemove', handleResizeMove);
window.removeEventListener('mouseup', handleResizeEnd);
};
}, [isResizing]);
if (loading) {
return (
<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>
);
}
const playlistManager = (
<PlaylistManager
playlists={playlists}
selectedItem={currentPlaylist}
onPlaylistCreate={handleCreatePlaylist}
onPlaylistSelect={(name) => {
handlePlaylistSelect(name || "All Songs");
if (isMobile) onClose();
}}
onPlaylistDelete={handlePlaylistDelete}
onFolderCreate={handleCreateFolder}
onPlaylistMove={handleMovePlaylist}
/>
);
return (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
overflow="hidden"
margin={0}
padding={0}
userSelect={isResizing ? 'none' : 'auto'}
>
<Flex direction="column" h="100%" w="100%">
{/* Header */}
<Flex
px={isMobile ? 2 : 4}
py={2}
bg="gray.800"
borderBottom="1px"
borderColor="gray.700"
align="center"
gap={2}
w="full"
>
{isMobile && (
<IconButton
aria-label="Open menu"
icon={<ChevronRightIcon />}
onClick={onOpen}
variant="solid"
colorScheme="blue"
size="md"
fontSize="20px"
/>
)}
<Heading
size={isMobile ? "sm" : "md"}
cursor="pointer"
onClick={() => navigate('/')}
_hover={{ color: 'blue.300' }}
transition="color 0.2s"
>
Rekordbox Reader
</Heading>
{/* Configuration Button */}
<IconButton
icon={<SettingsIcon />}
aria-label="Configuration"
variant="ghost"
ml="auto"
color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
onClick={() => navigate('/config')}
/>
</Flex>
{/* Main Content */}
<Box flex={1} overflow="hidden">
<Routes>
<Route path="/config" element={<Configuration />} />
<Route
path="*"
element={
<Flex flex={1} h="100%" overflow="hidden" w="full">
{/* Sidebar - Desktop */}
{!isMobile && (
<Box
position="relative"
w={`${sidebarWidth}px`}
minW={`${sidebarWidth}px`}
p={4}
borderRight="1px"
borderColor="gray.700"
overflowY="auto"
bg="gray.900"
>
{playlistManager}
<ResizeHandle onMouseDown={handleResizeStart} />
</Box>
)}
{/* Sidebar - Mobile */}
<Drawer
isOpen={isOpen}
placement="left"
onClose={onClose}
size="full"
>
<DrawerOverlay />
<DrawerContent bg="gray.900" p={0}>
<DrawerHeader
borderBottomWidth="1px"
bg="gray.800"
display="flex"
alignItems="center"
px={2}
py={2}
>
<Text>Playlists</Text>
<IconButton
aria-label="Close menu"
icon={<ChevronLeftIcon />}
onClick={onClose}
variant="ghost"
ml="auto"
color="blue.400"
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
/>
</DrawerHeader>
<DrawerBody p={2}>
{playlistManager}
</DrawerBody>
</DrawerContent>
</Drawer>
{/* Main Content Area */}
<Flex
flex={1}
gap={4}
p={isMobile ? 2 : 4}
h="100%"
overflow="hidden"
>
{/* Song List */}
<Box flex={1} overflowY="auto" minH={0}>
<SongList
songs={displayedSongs}
onAddToPlaylist={handleAddSongsToPlaylist}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
playlists={playlists}
onSongSelect={setSelectedSong}
selectedSongId={selectedSong?.id || null}
currentPlaylist={currentPlaylist}
/>
</Box>
{/* Details Panel */}
{!isMobile && (
<Box
w="350px"
minW="350px"
p={4}
borderLeft="1px"
borderColor="gray.700"
overflowY="auto"
bg="gray.900"
minH={0}
sx={{
'&::-webkit-scrollbar': {
width: '8px',
borderRadius: '8px',
backgroundColor: 'gray.900',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'gray.700',
borderRadius: '8px',
},
}}
>
<SongDetails song={selectedSong} />
</Box>
)}
</Flex>
</Flex>
}
/>
</Routes>
</Box>
</Flex>
</Box>
);
}