Folders working!

This commit is contained in:
Geert Rademakes 2025-04-24 23:45:14 +02:00
parent f82cb84397
commit 3a13c24301
12 changed files with 467 additions and 274 deletions

19
package-lock.json generated
View File

@ -2233,6 +2233,11 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="
},
"node_modules/@types/webidl-conversions": { "node_modules/@types/webidl-conversions": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@ -5751,6 +5756,18 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -6038,12 +6055,14 @@
"@chakra-ui/transition": "^2.1.0", "@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@types/uuid": "^10.0.0",
"events": "^3.3.0", "events": "^3.3.0",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.5.2", "react-router-dom": "^7.5.2",
"sax": "^1.4.1", "sax": "^1.4.1",
"uuid": "^11.1.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1" "xmlbuilder": "^15.1.1"
}, },

View File

@ -1,8 +1,22 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
const playlistSchema = new mongoose.Schema({ const playlistNodeSchema = new mongoose.Schema({
name: { type: String, required: true, unique: true }, name: { type: String, required: true },
tracks: [{ type: String, ref: 'Song' }], type: { type: String, enum: ['folder', 'playlist'], default: 'playlist' },
tracks: [{ type: String, ref: 'Song', default: [] }],
children: [{ type: mongoose.Schema.Types.Mixed }], // This allows recursive structure
}, {
_id: true,
id: true,
timestamps: true,
toJSON: {
transform: function(doc, ret) {
ret.id = ret._id.toString();
delete ret._id;
delete ret.__v;
return ret;
}
}
}); });
export const Playlist = mongoose.model('Playlist', playlistSchema); export const PlaylistNode = mongoose.model('PlaylistNode', playlistNodeSchema);

View File

@ -1,12 +1,12 @@
import express from 'express'; import express from 'express';
import { Playlist } from '../models/Playlist.js'; import { PlaylistNode } from '../models/Playlist.js';
const router = express.Router(); const router = express.Router();
// Get all playlists // Get all playlists
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const playlists = await Playlist.find(); const playlists = await PlaylistNode.find();
res.json(playlists); res.json(playlists);
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Error fetching playlists', error }); res.status(500).json({ message: 'Error fetching playlists', error });
@ -18,9 +18,9 @@ router.post('/batch', async (req, res) => {
try { try {
const playlists = req.body; const playlists = req.body;
// Delete all existing playlists first // Delete all existing playlists first
await Playlist.deleteMany({}); await PlaylistNode.deleteMany({});
// Insert new playlists // Insert new playlists
const result = await Playlist.insertMany(playlists); const result = await PlaylistNode.insertMany(playlists);
res.status(201).json(result); res.status(201).json(result);
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Error creating playlists', error }); res.status(500).json({ message: 'Error creating playlists', error });

View File

@ -19,12 +19,14 @@
"@chakra-ui/transition": "^2.1.0", "@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@types/uuid": "^10.0.0",
"events": "^3.3.0", "events": "^3.3.0",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.5.2", "react-router-dom": "^7.5.2",
"sax": "^1.4.1", "sax": "^1.4.1",
"uuid": "^11.1.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1" "xmlbuilder": "^15.1.1"
}, },

View File

@ -8,7 +8,8 @@ import { SongDetails } from "./components/SongDetails";
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";
import { Song } from "./types/interfaces"; import type { Song, PlaylistNode } from "./types/interfaces";
import { v4 as uuidv4 } from "uuid";
import "./App.css"; import "./App.css";
const StyledFileInput = ({ isMobile = false }) => { const StyledFileInput = ({ isMobile = false }) => {
@ -92,6 +93,27 @@ const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => v
</Box> </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() { export default function RekordboxReader() {
const { songs, playlists, setPlaylists, loading } = useXmlParser(); const { songs, playlists, setPlaylists, loading } = useXmlParser();
const [selectedSong, setSelectedSong] = useState<Song | null>(null); const [selectedSong, setSelectedSong] = useState<Song | null>(null);
@ -120,7 +142,7 @@ export default function RekordboxReader() {
// If we've loaded the data and the playlist doesn't exist // If we've loaded the data and the playlist doesn't exist
if (initialLoadDone.current && if (initialLoadDone.current &&
currentPlaylist !== "All Songs" && currentPlaylist !== "All Songs" &&
!playlists.some(p => p.name === currentPlaylist)) { !findPlaylistByName(playlists, currentPlaylist)) {
navigate("/", { replace: true }); navigate("/", { replace: true });
} }
}, [currentPlaylist, playlists, navigate, loading]); }, [currentPlaylist, playlists, navigate, loading]);
@ -137,7 +159,13 @@ export default function RekordboxReader() {
}; };
const handleCreatePlaylist = async (name: string) => { const handleCreatePlaylist = async (name: string) => {
const newPlaylist = { name, tracks: [] }; const newPlaylist: PlaylistNode = {
id: uuidv4(),
name,
type: 'playlist',
tracks: [],
children: undefined
};
const updatedPlaylists = [...playlists, newPlaylist]; const updatedPlaylists = [...playlists, newPlaylist];
const savedPlaylists = await api.savePlaylists(updatedPlaylists); const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists); setPlaylists(savedPlaylists);
@ -145,11 +173,29 @@ export default function RekordboxReader() {
}; };
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => { const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
const updatedPlaylists = playlists.map((playlist) => const updatedPlaylists = playlists.map(node => {
playlist.name === playlistName if (node.name === playlistName && node.type === 'playlist') {
? { ...playlist, tracks: [...new Set([...playlist.tracks, ...songIds])] } return {
: playlist ...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); const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists); setPlaylists(savedPlaylists);
}; };
@ -157,11 +203,29 @@ export default function RekordboxReader() {
const handleRemoveFromPlaylist = async (songIds: string[]) => { const handleRemoveFromPlaylist = async (songIds: string[]) => {
if (currentPlaylist === "All Songs") return; if (currentPlaylist === "All Songs") return;
const updatedPlaylists = playlists.map((playlist) => const updatedPlaylists = playlists.map(node => {
playlist.name === currentPlaylist if (node.name === currentPlaylist && node.type === 'playlist') {
? { ...playlist, tracks: playlist.tracks.filter(id => !songIds.includes(id)) } return {
: playlist ...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); const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists); setPlaylists(savedPlaylists);
}; };
@ -189,9 +253,17 @@ export default function RekordboxReader() {
const displayedSongs = currentPlaylist === "All Songs" const displayedSongs = currentPlaylist === "All Songs"
? songs ? songs
: songs.filter((song) => : songs.filter((song: Song) => {
playlists.find((p) => p.name === currentPlaylist)?.tracks.includes(song.id) // 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) => { const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -18,13 +18,14 @@ import {
MenuList, MenuList,
MenuItem, MenuItem,
IconButton, IconButton,
Collapse,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ChevronDownIcon, DeleteIcon } from "@chakra-ui/icons"; import { ChevronDownIcon, DeleteIcon, ChevronRightIcon } from "@chakra-ui/icons";
import React, { useState } from "react"; import React, { useState } from "react";
import { Playlist } from "../types/Playlist"; import { PlaylistNode } from "../types/interfaces";
interface PlaylistManagerProps { interface PlaylistManagerProps {
playlists: Playlist[]; playlists: PlaylistNode[];
selectedItem: string | null; selectedItem: string | null;
onPlaylistCreate: (name: string) => void; onPlaylistCreate: (name: string) => void;
onPlaylistSelect: (name: string | null) => void; onPlaylistSelect: (name: string | null) => void;
@ -50,6 +51,97 @@ const getButtonStyles = (isSelected: boolean) => ({
}, },
}); });
interface PlaylistItemProps {
node: PlaylistNode;
level: number;
selectedItem: string | null;
onPlaylistSelect: (name: string | null) => void;
onPlaylistDelete: (name: string) => void;
}
const PlaylistItem: React.FC<PlaylistItemProps> = ({
node,
level,
selectedItem,
onPlaylistSelect,
onPlaylistDelete,
}) => {
const [isOpen, setIsOpen] = useState(true);
const indent = level * 16; // 16px indent per level
if (node.type === 'folder') {
return (
<Box>
<Flex align="center" gap={1}>
<Button
flex={1}
{...getButtonStyles(false)}
onClick={() => setIsOpen(!isOpen)}
pl={indent}
leftIcon={
<ChevronRightIcon
transform={isOpen ? 'rotate(90deg)' : 'none'}
transition="transform 0.2s"
/>
}
>
{node.name}
</Button>
</Flex>
<Collapse in={isOpen}>
<VStack spacing={1} align="stretch">
{node.children?.map((child, index) => (
<PlaylistItem
key={child.id || index}
node={child}
level={level + 1}
selectedItem={selectedItem}
onPlaylistSelect={onPlaylistSelect}
onPlaylistDelete={onPlaylistDelete}
/>
))}
</VStack>
</Collapse>
</Box>
);
}
return (
<Flex align="center" gap={1}>
<Button
flex={1}
{...getButtonStyles(selectedItem === node.name)}
onClick={() => onPlaylistSelect(node.name)}
pl={indent + 24} // Extra indent for playlists inside folders
>
{node.name}
</Button>
<Menu>
<MenuButton
as={IconButton}
aria-label="Playlist options"
icon={<ChevronDownIcon />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
/>
<MenuList bg="gray.800" borderColor="gray.700">
<MenuItem
bg="gray.800"
color="red.300"
_hover={{ bg: "gray.700" }}
icon={<DeleteIcon />}
onClick={() => onPlaylistDelete(node.name)}
>
Delete Playlist
</MenuItem>
</MenuList>
</Menu>
</Flex>
);
};
export const PlaylistManager: React.FC<PlaylistManagerProps> = ({ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
playlists, playlists,
selectedItem, selectedItem,
@ -77,38 +169,15 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
> >
All Songs All Songs
</Button> </Button>
{playlists.map((playlist) => ( {playlists.map((node, index) => (
<Flex key={playlist._id} align="center" gap={1}> <PlaylistItem
<Button key={node.id || index}
flex={1} node={node}
{...getButtonStyles(selectedItem === playlist.name)} level={0}
onClick={() => onPlaylistSelect(playlist.name)} selectedItem={selectedItem}
> onPlaylistSelect={onPlaylistSelect}
{playlist.name} onPlaylistDelete={onPlaylistDelete}
</Button> />
<Menu>
<MenuButton
as={IconButton}
aria-label="Playlist options"
icon={<ChevronDownIcon />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
/>
<MenuList bg="gray.800" borderColor="gray.700">
<MenuItem
bg="gray.800"
color="red.300"
_hover={{ bg: "gray.700" }}
icon={<DeleteIcon />}
onClick={() => onPlaylistDelete(playlist.name)}
>
Delete Playlist
</MenuItem>
</MenuList>
</Menu>
</Flex>
))} ))}
</VStack> </VStack>

View File

@ -1,3 +1,4 @@
import React, { ChangeEvent } from 'react';
import { import {
Box, Box,
Flex, Flex,
@ -5,25 +6,25 @@ import {
Button, Button,
IconButton, IconButton,
HStack, HStack,
Menu as ChakraMenu, Menu,
MenuButton as ChakraMenuButton, MenuButton,
MenuList as ChakraMenuList, MenuList,
MenuItem, MenuItem,
MenuDivider, MenuDivider,
MenuGroup, Checkbox
Checkbox as ChakraCheckbox } from '@chakra-ui/react';
} from "@chakra-ui/react";
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input"; import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons"; import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
import { Song } from "../types/interfaces"; import type { Song, PlaylistNode } from "../types/interfaces";
import { useState, useCallback, useMemo, forwardRef } from "react"; import { useState, useCallback, useMemo } from "react";
import { ChangeEvent, MouseEvent } from "react"; import type { MouseEvent } from 'react';
import { v4 as uuidv4 } from 'uuid';
interface SongListProps { interface SongListProps {
songs: Song[]; songs: Song[];
onAddToPlaylist: (songIds: string[], playlistName: string) => void; onAddToPlaylist: (songIds: string[], playlistName: string) => void;
onRemoveFromPlaylist?: (songIds: string[]) => void; onRemoveFromPlaylist?: (songIds: string[]) => void;
playlists: { name: string }[]; playlists: PlaylistNode[];
onSongSelect: (song: Song) => void; onSongSelect: (song: Song) => void;
selectedSongId: string | null; selectedSongId: string | null;
currentPlaylist: string | null; currentPlaylist: string | null;
@ -41,6 +42,22 @@ export const SongList: React.FC<SongListProps> = ({
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set()); const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Helper function to get all playlists (excluding folders) from the playlist tree
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
let result: PlaylistNode[] = [];
for (const node of nodes) {
if (node.type === 'playlist') {
result.push(node);
} else if (node.type === 'folder' && node.children) {
result = result.concat(getAllPlaylists(node.children));
}
}
return result;
}, []);
// Get flattened list of all playlists
const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
const filteredSongs = useMemo(() => { const filteredSongs = useMemo(() => {
if (!searchQuery) return songs; if (!searchQuery) return songs;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
@ -76,7 +93,7 @@ export const SongList: React.FC<SongListProps> = ({
} }
}; };
const handleSongClick = (e: MouseEvent, song: Song) => { const handleSongClick = (e: MouseEvent<HTMLButtonElement>, song: Song) => {
e.stopPropagation(); e.stopPropagation();
onSongSelect(song); onSongSelect(song);
}; };
@ -102,7 +119,7 @@ export const SongList: React.FC<SongListProps> = ({
{/* 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" mb={4} p={2} bg="gray.800" borderRadius="md">
<HStack> <HStack>
<ChakraCheckbox <Checkbox
isChecked={selectedSongs.size === filteredSongs.length} isChecked={selectedSongs.size === filteredSongs.length}
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length} isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
onChange={toggleSelectAll} onChange={toggleSelectAll}
@ -118,35 +135,30 @@ export const SongList: React.FC<SongListProps> = ({
{selectedSongs.size === 0 {selectedSongs.size === 0
? "Select All" ? "Select All"
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`} : `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
</ChakraCheckbox> </Checkbox>
</HStack> </HStack>
{selectedSongs.size > 0 && ( {selectedSongs.size > 0 && (
<ChakraMenu> <Menu>
<ChakraMenuButton <MenuButton
as={Button} as={Button}
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon />}
size="sm" size="sm"
colorScheme="blue" colorScheme="blue"
> >
Actions Actions
</ChakraMenuButton> </MenuButton>
<ChakraMenuList> <MenuList>
<MenuGroup title="Add to Playlist"> {allPlaylists.map((playlist) => (
{playlists <MenuItem
.filter(p => p.name !== currentPlaylist) key={playlist.id}
.map((playlist) => ( onClick={() => {
<MenuItem handleBulkAddToPlaylist(playlist.name);
key={playlist.name} }}
onClick={() => { >
handleBulkAddToPlaylist(playlist.name); Add to {playlist.name}
setSelectedSongs(new Set()); </MenuItem>
}} ))}
>
{playlist.name}
</MenuItem>
))}
</MenuGroup>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && ( {currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<> <>
<MenuDivider /> <MenuDivider />
@ -161,76 +173,36 @@ export const SongList: React.FC<SongListProps> = ({
</MenuItem> </MenuItem>
</> </>
)} )}
</ChakraMenuList> </MenuList>
</ChakraMenu> </Menu>
)} )}
</Flex> </Flex>
{/* Song List */} {/* Song List */}
<Flex direction="column" gap={2}> <Flex direction="column" gap={2}>
{filteredSongs.map((song) => ( {filteredSongs.map((song) => (
<Box <Flex key={uuidv4()} alignItems="center" p={2} borderBottom="1px" borderColor="gray.200">
key={song.id} <Checkbox
borderWidth="1px" isChecked={selectedSongs.has(song.id)}
borderRadius="md" onChange={(e: ChangeEvent<HTMLInputElement>) => {
p={2} e.stopPropagation();
shadow="sm" toggleSelection(song.id);
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" }} mr={4}
transition="background-color 0.2s" />
cursor="pointer" <Box flex="1">
onClick={(e: MouseEvent) => handleSongClick(e, song)} <Text fontWeight="bold">{song.title}</Text>
> <Text fontSize="sm" color="gray.600">{song.artist}</Text>
<Flex justify="space-between" align="center"> </Box>
<HStack> <Menu>
<ChakraCheckbox <MenuButton as={IconButton} aria-label="Options" icon={<span></span>} variant="ghost" />
isChecked={selectedSongs.has(song.id)} <MenuList>
onChange={(e: ChangeEvent<HTMLInputElement>) => { <MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Select</MenuItem>
e.stopPropagation(); <MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Add to Playlist</MenuItem>
toggleSelection(song.id); <MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Remove from Playlist</MenuItem>
}} </MenuList>
colorScheme="blue" </Menu>
onClick={(e: MouseEvent) => e.stopPropagation()} </Flex>
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) && (
<ChakraMenu>
<ChakraMenuButton
as={IconButton}
aria-label="Add to playlist"
size="sm"
variant="ghost"
onClick={(e: MouseEvent) => e.stopPropagation()}
>
</ChakraMenuButton>
<ChakraMenuList onClick={(e: MouseEvent) => e.stopPropagation()}>
{playlists.map((playlist) => (
<MenuItem
key={playlist.name}
value={playlist.name}
onClick={() => onAddToPlaylist([song.id], playlist.name)}
>
{playlist.name}
</MenuItem>
))}
</ChakraMenuList>
</ChakraMenu>
)}
</Flex>
</Box>
))} ))}
</Flex> </Flex>
</Box> </Box>

View File

@ -1,11 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Song, Playlist } from "../types/interfaces"; import type { Song, PlaylistNode } from "../types/interfaces";
import { parseXmlFile } from "../services/xmlService"; import { parseXmlFile } from "../services/xmlService";
import { api } from "../services/api"; import { api } from "../services/api";
export const useXmlParser = () => { export const useXmlParser = () => {
const [songs, setSongs] = useState<Song[]>([]); const [songs, setSongs] = useState<Song[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]); const [playlists, setPlaylists] = useState<PlaylistNode[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Load data from the backend on mount // Load data from the backend on mount

View File

@ -1,70 +1,43 @@
import { Song, Playlist } from '../types/interfaces'; import type { Song, PlaylistNode } from '../types/interfaces';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
async function handleResponse<T>(response: Response): Promise<T> { class Api {
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || 'An error occurred');
}
return response.json();
}
export const api = {
async getSongs(): Promise<Song[]> { async getSongs(): Promise<Song[]> {
console.log('Fetching songs from API...'); const response = await fetch(`${API_BASE_URL}/songs`);
const response = await fetch(`${API_URL}/songs`); if (!response.ok) throw new Error('Failed to fetch songs');
const data = await handleResponse<Song[]>(response); return response.json();
console.log(`Received ${data.length} songs from API`); }
return data;
},
async saveSongs(songs: Song[]): Promise<Song[]> { async saveSongs(songs: Song[]): Promise<Song[]> {
console.log(`Saving ${songs.length} songs to API...`); const response = await fetch(`${API_BASE_URL}/songs/batch`, {
const response = await fetch(`${API_URL}/songs/batch`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(songs), body: JSON.stringify(songs),
}); });
const data = await handleResponse<Song[]>(response); if (!response.ok) throw new Error('Failed to save songs');
console.log(`Successfully saved ${data.length} songs`); return response.json();
return data; }
},
async deleteSongs(songIds: string[]): Promise<void> { async getPlaylists(): Promise<PlaylistNode[]> {
console.log(`Deleting ${songIds.length} songs from API...`); const response = await fetch(`${API_BASE_URL}/playlists`);
const response = await fetch(`${API_URL}/songs/batch`, { if (!response.ok) throw new Error('Failed to fetch playlists');
method: 'DELETE', return response.json();
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[]> { async savePlaylists(playlists: PlaylistNode[]): Promise<PlaylistNode[]> {
console.log('Fetching playlists from API...'); const response = await fetch(`${API_BASE_URL}/playlists/batch`, {
const response = await fetch(`${API_URL}/playlists`);
const data = await handleResponse<Playlist[]>(response);
console.log(`Received ${data.length} playlists from API`);
return data;
},
async savePlaylists(playlists: Playlist[]): Promise<Playlist[]> {
console.log(`Saving ${playlists.length} playlists to API...`);
const response = await fetch(`${API_URL}/playlists/batch`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(playlists), body: JSON.stringify(playlists),
}); });
const data = await handleResponse<Playlist[]>(response); if (!response.ok) throw new Error('Failed to save playlists');
console.log(`Successfully saved ${data.length} playlists`); return response.json();
return data; }
}, }
};
export const api = new Api();

View File

@ -1,8 +1,29 @@
import { parseStringPromise } from "xml2js"; import { parseStringPromise } from "xml2js";
import { create } from "xmlbuilder"; import { create } from "xmlbuilder";
import { Song, Playlist } from "../types/interfaces"; import { Song, PlaylistNode } from "../types/interfaces";
import { v4 as uuidv4 } from 'uuid';
export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: Playlist[] }> => { const parseNode = (node: any): PlaylistNode => {
const type = node.$.Type === "0" ? "folder" : "playlist";
const result: PlaylistNode = {
id: uuidv4(),
name: node.$.Name,
type,
children: [],
tracks: [],
};
if (type === "folder" && node.NODE) {
result.children = node.NODE.map((childNode: any) => parseNode(childNode));
} else if (type === "playlist") {
// Always initialize tracks array, even if TRACK is not present
result.tracks = node.TRACK ? node.TRACK.map((track: any) => track.$.Key) : [];
}
return result;
};
export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: PlaylistNode[] }> => {
try { try {
const result = await parseStringPromise(xmlText); const result = await parseStringPromise(xmlText);
@ -40,10 +61,24 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl
} : undefined, } : undefined,
})); }));
const playlists: Playlist[] = result.DJ_PLAYLISTS.PLAYLISTS[0].NODE[0].NODE.map((playlist: any) => ({ // Parse the playlist structure
name: playlist.$.Name, const playlistsRoot = result.DJ_PLAYLISTS.PLAYLISTS[0];
tracks: playlist.TRACK ? playlist.TRACK.map((t: any) => t.$.Key) : [], let playlists: PlaylistNode[] = [];
}));
// Handle root level playlists (directly under PLAYLISTS)
if (playlistsRoot.NODE) {
for (const node of playlistsRoot.NODE) {
// If this is a root playlist (not the ROOT folder)
if (node.$.Name !== 'ROOT') {
playlists.push(parseNode(node));
} else {
// This is the ROOT folder, parse its children
if (node.NODE) {
playlists.push(...node.NODE.map((childNode: any) => parseNode(childNode)));
}
}
}
}
return { songs: tracks, playlists }; return { songs: tracks, playlists };
} catch (err) { } catch (err) {
@ -52,69 +87,85 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl
} }
}; };
export const exportToXml = (songs: Song[], playlists: Playlist[]): string => { const buildXmlNode = (node: PlaylistNode): any => {
const doc = create("DJ_PLAYLISTS", { Version: "1.0.0" }); const xmlNode: any = {
const collection = doc.ele("COLLECTION", { '@Type': node.type === 'folder' ? '0' : '1',
Entries: songs.length.toString(), '@Name': node.name,
}); };
songs.forEach((song) => { if (node.type === 'folder') {
const trackElement = collection.ele("TRACK", { xmlNode['@Count'] = (node.children || []).length;
TrackID: song.id, if (node.children && node.children.length > 0) {
Name: song.title, xmlNode.NODE = node.children.map(child => buildXmlNode(child));
Artist: song.artist, }
Composer: song.composer || "", } else {
Album: song.album || "", // For playlists, always include KeyType and Entries
Grouping: song.grouping || "", xmlNode['@KeyType'] = '0';
Genre: song.genre || "", // Set Entries to the actual number of tracks
Kind: song.kind || "", xmlNode['@Entries'] = (node.tracks || []).length;
Size: song.size || "", // Include TRACK elements if there are tracks
TotalTime: song.totalTime || "", if (node.tracks && node.tracks.length > 0) {
DiscNumber: song.discNumber || "", xmlNode.TRACK = node.tracks.map(trackId => ({
TrackNumber: song.trackNumber || "", '@Key': trackId
Year: song.year || "", }));
AverageBpm: song.averageBpm || "", }
DateAdded: song.dateAdded || "", }
BitRate: song.bitRate || "",
SampleRate: song.sampleRate || "",
Comments: song.comments || "",
PlayCount: song.playCount || "",
Rating: song.rating || "",
Location: song.location || "",
Remixer: song.remixer || "",
Tonality: song.tonality || "",
Label: song.label || "",
Mix: song.mix || "",
});
if (song.tempo) { return xmlNode;
trackElement.ele("TEMPO", { };
Inizio: song.tempo.inizio || "",
Bpm: song.tempo.bpm || "", export const exportToXml = (songs: Song[], playlists: PlaylistNode[]): string => {
Metro: song.tempo.metro || "", const xml = create({
Battito: song.tempo.battito || "", DJ_PLAYLISTS: {
}); '@Version': '1.0.0',
COLLECTION: {
'@Entries': songs.length,
TRACK: songs.map(song => ({
'@TrackID': song.id,
'@Name': song.title,
'@Artist': song.artist,
'@Composer': song.composer,
'@Album': song.album,
'@Grouping': song.grouping,
'@Genre': song.genre,
'@Kind': song.kind,
'@Size': song.size,
'@TotalTime': song.totalTime,
'@DiscNumber': song.discNumber,
'@TrackNumber': song.trackNumber,
'@Year': song.year,
'@AverageBpm': song.averageBpm,
'@DateAdded': song.dateAdded,
'@BitRate': song.bitRate,
'@SampleRate': song.sampleRate,
'@Comments': song.comments,
'@PlayCount': song.playCount,
'@Rating': song.rating,
'@Location': song.location,
'@Remixer': song.remixer,
'@Tonality': song.tonality,
'@Label': song.label,
'@Mix': song.mix,
...(song.tempo ? {
TEMPO: {
'@Inizio': song.tempo.inizio,
'@Bpm': song.tempo.bpm,
'@Metro': song.tempo.metro,
'@Battito': song.tempo.battito
}
} : {})
}))
},
PLAYLISTS: {
NODE: {
'@Type': '0',
'@Name': 'ROOT',
'@Count': playlists.length,
NODE: playlists.map(playlist => buildXmlNode(playlist))
}
}
} }
}); });
const playlistsNode = doc.ele("PLAYLISTS"); return xml.end({ pretty: true });
const playlistsFolder = playlistsNode.ele("NODE", {
Name: "ROOT",
Type: "0",
Count: playlists.length.toString(),
});
playlists.forEach((playlist) => {
const playlistNode = playlistsFolder.ele("NODE", {
Name: playlist.name,
KeyType: "0",
Type: "1",
Entries: playlist.tracks.length.toString(),
});
playlist.tracks.forEach((trackId) => {
playlistNode.ele("TRACK", { Key: trackId });
});
});
return doc.end({ pretty: true });
}; };

View File

@ -0,0 +1,12 @@
export interface Song {
_id: string;
title: string;
artist: string;
genre: string;
bpm: number;
key: string;
rating: number;
comments: string;
createdAt: string;
updatedAt: string;
}

View File

@ -32,6 +32,15 @@ export interface Song {
}; };
} }
export interface PlaylistNode {
id: string;
name: string;
type: 'folder' | 'playlist';
children?: PlaylistNode[];
tracks?: string[];
}
// Keep the old Playlist interface for backward compatibility during transition
export interface Playlist { export interface Playlist {
name: string; name: string;
tracks: string[]; tracks: string[];