Folders working!
This commit is contained in:
parent
f82cb84397
commit
3a13c24301
19
package-lock.json
generated
19
package-lock.json
generated
@ -2233,6 +2233,11 @@
|
||||
"@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": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||
@ -5751,6 +5756,18 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@ -6038,12 +6055,14 @@
|
||||
"@chakra-ui/transition": "^2.1.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"events": "^3.3.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"sax": "^1.4.1",
|
||||
"uuid": "^11.1.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder": "^15.1.1"
|
||||
},
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const playlistSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, unique: true },
|
||||
tracks: [{ type: String, ref: 'Song' }],
|
||||
const playlistNodeSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true },
|
||||
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);
|
||||
@ -1,12 +1,12 @@
|
||||
import express from 'express';
|
||||
import { Playlist } from '../models/Playlist.js';
|
||||
import { PlaylistNode } from '../models/Playlist.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all playlists
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const playlists = await Playlist.find();
|
||||
const playlists = await PlaylistNode.find();
|
||||
res.json(playlists);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching playlists', error });
|
||||
@ -18,9 +18,9 @@ router.post('/batch', async (req, res) => {
|
||||
try {
|
||||
const playlists = req.body;
|
||||
// Delete all existing playlists first
|
||||
await Playlist.deleteMany({});
|
||||
await PlaylistNode.deleteMany({});
|
||||
// Insert new playlists
|
||||
const result = await Playlist.insertMany(playlists);
|
||||
const result = await PlaylistNode.insertMany(playlists);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error creating playlists', error });
|
||||
|
||||
@ -19,12 +19,14 @@
|
||||
"@chakra-ui/transition": "^2.1.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"events": "^3.3.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"sax": "^1.4.1",
|
||||
"uuid": "^11.1.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder": "^15.1.1"
|
||||
},
|
||||
|
||||
@ -8,7 +8,8 @@ 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 type { Song, PlaylistNode } from "./types/interfaces";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import "./App.css";
|
||||
|
||||
const StyledFileInput = ({ isMobile = false }) => {
|
||||
@ -92,6 +93,27 @@ const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => v
|
||||
</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);
|
||||
@ -120,7 +142,7 @@ export default function RekordboxReader() {
|
||||
// If we've loaded the data and the playlist doesn't exist
|
||||
if (initialLoadDone.current &&
|
||||
currentPlaylist !== "All Songs" &&
|
||||
!playlists.some(p => p.name === currentPlaylist)) {
|
||||
!findPlaylistByName(playlists, currentPlaylist)) {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [currentPlaylist, playlists, navigate, loading]);
|
||||
@ -137,7 +159,13 @@ export default function RekordboxReader() {
|
||||
};
|
||||
|
||||
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 savedPlaylists = await api.savePlaylists(updatedPlaylists);
|
||||
setPlaylists(savedPlaylists);
|
||||
@ -145,11 +173,29 @@ export default function RekordboxReader() {
|
||||
};
|
||||
|
||||
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
|
||||
const updatedPlaylists = playlists.map((playlist) =>
|
||||
playlist.name === playlistName
|
||||
? { ...playlist, tracks: [...new Set([...playlist.tracks, ...songIds])] }
|
||||
: playlist
|
||||
);
|
||||
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);
|
||||
};
|
||||
@ -157,11 +203,29 @@ export default function RekordboxReader() {
|
||||
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 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);
|
||||
};
|
||||
@ -189,9 +253,17 @@ export default function RekordboxReader() {
|
||||
|
||||
const displayedSongs = currentPlaylist === "All Songs"
|
||||
? songs
|
||||
: songs.filter((song) =>
|
||||
playlists.find((p) => p.name === currentPlaylist)?.tracks.includes(song.id)
|
||||
);
|
||||
: 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();
|
||||
|
||||
@ -18,13 +18,14 @@ import {
|
||||
MenuList,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Collapse,
|
||||
} 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 { Playlist } from "../types/Playlist";
|
||||
import { PlaylistNode } from "../types/interfaces";
|
||||
|
||||
interface PlaylistManagerProps {
|
||||
playlists: Playlist[];
|
||||
playlists: PlaylistNode[];
|
||||
selectedItem: string | null;
|
||||
onPlaylistCreate: (name: string) => 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> = ({
|
||||
playlists,
|
||||
selectedItem,
|
||||
@ -77,38 +169,15 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
|
||||
>
|
||||
All Songs
|
||||
</Button>
|
||||
{playlists.map((playlist) => (
|
||||
<Flex key={playlist._id} align="center" gap={1}>
|
||||
<Button
|
||||
flex={1}
|
||||
{...getButtonStyles(selectedItem === playlist.name)}
|
||||
onClick={() => onPlaylistSelect(playlist.name)}
|
||||
>
|
||||
{playlist.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(playlist.name)}
|
||||
>
|
||||
Delete Playlist
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Flex>
|
||||
{playlists.map((node, index) => (
|
||||
<PlaylistItem
|
||||
key={node.id || index}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedItem={selectedItem}
|
||||
onPlaylistSelect={onPlaylistSelect}
|
||||
onPlaylistDelete={onPlaylistDelete}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@ -5,25 +6,25 @@ import {
|
||||
Button,
|
||||
IconButton,
|
||||
HStack,
|
||||
Menu as ChakraMenu,
|
||||
MenuButton as ChakraMenuButton,
|
||||
MenuList as ChakraMenuList,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
MenuGroup,
|
||||
Checkbox as ChakraCheckbox
|
||||
} from "@chakra-ui/react";
|
||||
Checkbox
|
||||
} from '@chakra-ui/react';
|
||||
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
|
||||
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
|
||||
import { Song } from "../types/interfaces";
|
||||
import { useState, useCallback, useMemo, forwardRef } from "react";
|
||||
import { ChangeEvent, MouseEvent } from "react";
|
||||
import type { Song, PlaylistNode } from "../types/interfaces";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { MouseEvent } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
|
||||
onRemoveFromPlaylist?: (songIds: string[]) => void;
|
||||
playlists: { name: string }[];
|
||||
playlists: PlaylistNode[];
|
||||
onSongSelect: (song: Song) => void;
|
||||
selectedSongId: string | null;
|
||||
currentPlaylist: string | null;
|
||||
@ -41,6 +42,22 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
||||
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(() => {
|
||||
if (!searchQuery) return songs;
|
||||
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();
|
||||
onSongSelect(song);
|
||||
};
|
||||
@ -102,7 +119,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>
|
||||
<ChakraCheckbox
|
||||
<Checkbox
|
||||
isChecked={selectedSongs.size === filteredSongs.length}
|
||||
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
|
||||
onChange={toggleSelectAll}
|
||||
@ -118,35 +135,30 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
{selectedSongs.size === 0
|
||||
? "Select All"
|
||||
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
|
||||
</ChakraCheckbox>
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
|
||||
{selectedSongs.size > 0 && (
|
||||
<ChakraMenu>
|
||||
<ChakraMenuButton
|
||||
<Menu>
|
||||
<MenuButton
|
||||
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>
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{allPlaylists.map((playlist) => (
|
||||
<MenuItem
|
||||
key={playlist.id}
|
||||
onClick={() => {
|
||||
handleBulkAddToPlaylist(playlist.name);
|
||||
}}
|
||||
>
|
||||
Add to {playlist.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
@ -161,76 +173,36 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ChakraMenuList>
|
||||
</ChakraMenu>
|
||||
</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>
|
||||
<ChakraCheckbox
|
||||
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) && (
|
||||
<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 key={uuidv4()} alignItems="center" p={2} borderBottom="1px" borderColor="gray.200">
|
||||
<Checkbox
|
||||
isChecked={selectedSongs.has(song.id)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(song.id);
|
||||
}}
|
||||
mr={4}
|
||||
/>
|
||||
<Box flex="1">
|
||||
<Text fontWeight="bold">{song.title}</Text>
|
||||
<Text fontSize="sm" color="gray.600">{song.artist}</Text>
|
||||
</Box>
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} aria-label="Options" icon={<span>⋮</span>} variant="ghost" />
|
||||
<MenuList>
|
||||
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Select</MenuItem>
|
||||
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Add to Playlist</MenuItem>
|
||||
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Remove from Playlist</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Song, Playlist } from "../types/interfaces";
|
||||
import type { Song, PlaylistNode } from "../types/interfaces";
|
||||
import { parseXmlFile } from "../services/xmlService";
|
||||
import { api } from "../services/api";
|
||||
|
||||
export const useXmlParser = () => {
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [playlists, setPlaylists] = useState<PlaylistNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load data from the backend on mount
|
||||
|
||||
@ -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> {
|
||||
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 = {
|
||||
class Api {
|
||||
async getSongs(): Promise<Song[]> {
|
||||
console.log('Fetching songs from API...');
|
||||
const response = await fetch(`${API_URL}/songs`);
|
||||
const data = await handleResponse<Song[]>(response);
|
||||
console.log(`Received ${data.length} songs from API`);
|
||||
return data;
|
||||
},
|
||||
const response = await fetch(`${API_BASE_URL}/songs`);
|
||||
if (!response.ok) throw new Error('Failed to fetch songs');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async saveSongs(songs: Song[]): Promise<Song[]> {
|
||||
console.log(`Saving ${songs.length} songs to API...`);
|
||||
const response = await fetch(`${API_URL}/songs/batch`, {
|
||||
const response = await fetch(`${API_BASE_URL}/songs/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(songs),
|
||||
});
|
||||
const data = await handleResponse<Song[]>(response);
|
||||
console.log(`Successfully saved ${data.length} songs`);
|
||||
return data;
|
||||
},
|
||||
if (!response.ok) throw new Error('Failed to save songs');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
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<PlaylistNode[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/playlists`);
|
||||
if (!response.ok) throw new Error('Failed to fetch playlists');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getPlaylists(): Promise<Playlist[]> {
|
||||
console.log('Fetching playlists from API...');
|
||||
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`, {
|
||||
async savePlaylists(playlists: PlaylistNode[]): Promise<PlaylistNode[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/playlists/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(playlists),
|
||||
});
|
||||
const data = await handleResponse<Playlist[]>(response);
|
||||
console.log(`Successfully saved ${data.length} playlists`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
if (!response.ok) throw new Error('Failed to save playlists');
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new Api();
|
||||
@ -1,8 +1,29 @@
|
||||
import { parseStringPromise } from "xml2js";
|
||||
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 {
|
||||
const result = await parseStringPromise(xmlText);
|
||||
|
||||
@ -40,10 +61,24 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl
|
||||
} : undefined,
|
||||
}));
|
||||
|
||||
const playlists: Playlist[] = result.DJ_PLAYLISTS.PLAYLISTS[0].NODE[0].NODE.map((playlist: any) => ({
|
||||
name: playlist.$.Name,
|
||||
tracks: playlist.TRACK ? playlist.TRACK.map((t: any) => t.$.Key) : [],
|
||||
}));
|
||||
// Parse the playlist structure
|
||||
const playlistsRoot = result.DJ_PLAYLISTS.PLAYLISTS[0];
|
||||
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 };
|
||||
} 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 doc = create("DJ_PLAYLISTS", { Version: "1.0.0" });
|
||||
const collection = doc.ele("COLLECTION", {
|
||||
Entries: songs.length.toString(),
|
||||
});
|
||||
const buildXmlNode = (node: PlaylistNode): any => {
|
||||
const xmlNode: any = {
|
||||
'@Type': node.type === 'folder' ? '0' : '1',
|
||||
'@Name': node.name,
|
||||
};
|
||||
|
||||
songs.forEach((song) => {
|
||||
const trackElement = collection.ele("TRACK", {
|
||||
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 || "",
|
||||
});
|
||||
if (node.type === 'folder') {
|
||||
xmlNode['@Count'] = (node.children || []).length;
|
||||
if (node.children && node.children.length > 0) {
|
||||
xmlNode.NODE = node.children.map(child => buildXmlNode(child));
|
||||
}
|
||||
} else {
|
||||
// For playlists, always include KeyType and Entries
|
||||
xmlNode['@KeyType'] = '0';
|
||||
// Set Entries to the actual number of tracks
|
||||
xmlNode['@Entries'] = (node.tracks || []).length;
|
||||
// Include TRACK elements if there are tracks
|
||||
if (node.tracks && node.tracks.length > 0) {
|
||||
xmlNode.TRACK = node.tracks.map(trackId => ({
|
||||
'@Key': trackId
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (song.tempo) {
|
||||
trackElement.ele("TEMPO", {
|
||||
Inizio: song.tempo.inizio || "",
|
||||
Bpm: song.tempo.bpm || "",
|
||||
Metro: song.tempo.metro || "",
|
||||
Battito: song.tempo.battito || "",
|
||||
});
|
||||
return xmlNode;
|
||||
};
|
||||
|
||||
export const exportToXml = (songs: Song[], playlists: PlaylistNode[]): string => {
|
||||
const xml = create({
|
||||
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");
|
||||
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 });
|
||||
return xml.end({ pretty: true });
|
||||
};
|
||||
12
packages/frontend/src/types/Song.ts
Normal file
12
packages/frontend/src/types/Song.ts
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
name: string;
|
||||
tracks: string[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user