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": "*"
|
"@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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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 });
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
.filter(p => p.name !== currentPlaylist)
|
|
||||||
.map((playlist) => (
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={playlist.name}
|
key={playlist.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleBulkAddToPlaylist(playlist.name);
|
handleBulkAddToPlaylist(playlist.name);
|
||||||
setSelectedSongs(new Set());
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{playlist.name}
|
Add to {playlist.name}
|
||||||
</MenuItem>
|
</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"
|
|
||||||
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)}
|
isChecked={selectedSongs.has(song.id)}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleSelection(song.id);
|
toggleSelection(song.id);
|
||||||
}}
|
}}
|
||||||
colorScheme="blue"
|
mr={4}
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
||||||
sx={{
|
|
||||||
'& > span:first-of-type': {
|
|
||||||
opacity: 1,
|
|
||||||
border: '2px solid',
|
|
||||||
borderColor: 'gray.500'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box flex="1">
|
||||||
<Text fontWeight="bold" fontSize="sm">{song.title}</Text>
|
<Text fontWeight="bold">{song.title}</Text>
|
||||||
<Text color="gray.500" fontSize="xs">{song.artist}</Text>
|
<Text fontSize="sm" color="gray.600">{song.artist}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</HStack>
|
<Menu>
|
||||||
|
<MenuButton as={IconButton} aria-label="Options" icon={<span>⋮</span>} variant="ghost" />
|
||||||
{!selectedSongs.has(song.id) && (
|
<MenuList>
|
||||||
<ChakraMenu>
|
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Select</MenuItem>
|
||||||
<ChakraMenuButton
|
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Add to Playlist</MenuItem>
|
||||||
as={IconButton}
|
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Remove from Playlist</MenuItem>
|
||||||
aria-label="Add to playlist"
|
</MenuList>
|
||||||
size="sm"
|
</Menu>
|
||||||
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>
|
</Flex>
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
async getSongs(): Promise<Song[]> {
|
||||||
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
|
const response = await fetch(`${API_BASE_URL}/songs`);
|
||||||
throw new Error(error.message || 'An error occurred');
|
if (!response.ok) throw new Error('Failed to fetch songs');
|
||||||
}
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const 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;
|
|
||||||
},
|
|
||||||
|
|
||||||
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();
|
||||||
@ -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 });
|
|
||||||
};
|
};
|
||||||
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 {
|
export interface Playlist {
|
||||||
name: string;
|
name: string;
|
||||||
tracks: string[];
|
tracks: string[];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user