567 lines
17 KiB
TypeScript
567 lines
17 KiB
TypeScript
import {
|
|
Box,
|
|
Button,
|
|
Flex,
|
|
Input,
|
|
Modal,
|
|
ModalBody,
|
|
ModalCloseButton,
|
|
ModalContent,
|
|
ModalFooter,
|
|
ModalHeader,
|
|
ModalOverlay,
|
|
useDisclosure,
|
|
VStack,
|
|
Menu,
|
|
MenuButton,
|
|
MenuList,
|
|
MenuItem,
|
|
Text,
|
|
// HStack,
|
|
Collapse,
|
|
MenuDivider,
|
|
MenuGroup,
|
|
Icon,
|
|
Badge
|
|
} from "@chakra-ui/react";
|
|
import { ChevronRightIcon, AddIcon, ViewIcon, DeleteIcon } from "@chakra-ui/icons";
|
|
import { FiMoreHorizontal } from 'react-icons/fi';
|
|
import React, { useState, useCallback } from "react";
|
|
import { PlaylistNode } from "../types/interfaces";
|
|
|
|
interface PlaylistManagerProps {
|
|
playlists: PlaylistNode[];
|
|
selectedItem: string | null;
|
|
onPlaylistCreate: (name: string) => void;
|
|
onPlaylistSelect: (name: string | null) => void;
|
|
onPlaylistDelete: (name: string) => void;
|
|
onFolderCreate: (name: string) => void;
|
|
onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void;
|
|
onDropSongs?: (playlistName: string, songIds: string[]) => void;
|
|
}
|
|
|
|
// Memoized button styles to prevent unnecessary re-renders
|
|
const selectedButtonStyles = {
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
bg: "blue.800",
|
|
color: "white",
|
|
fontWeight: "600",
|
|
borderRadius: "md",
|
|
px: 4,
|
|
py: 2,
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
_hover: {
|
|
bg: "blue.600",
|
|
transform: "translateX(2px)",
|
|
},
|
|
_active: {
|
|
bg: "blue.700",
|
|
},
|
|
};
|
|
|
|
const unselectedButtonStyles = {
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
bg: "transparent",
|
|
color: "gray.100",
|
|
fontWeight: "normal",
|
|
borderRadius: "md",
|
|
px: 4,
|
|
py: 2,
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
_hover: {
|
|
bg: "whiteAlpha.200",
|
|
transform: "translateX(2px)",
|
|
},
|
|
_active: {
|
|
bg: "whiteAlpha.300",
|
|
},
|
|
};
|
|
|
|
interface PlaylistItemProps {
|
|
node: PlaylistNode;
|
|
level: number;
|
|
selectedItem: string | null;
|
|
onPlaylistSelect: (name: string | null) => void;
|
|
onPlaylistDelete: (name: string) => void;
|
|
onPlaylistMove: (playlistName: string, targetFolderName: string | null) => void;
|
|
allFolders: { name: string }[];
|
|
onDropSongs?: (playlistName: string, songIds: string[]) => void;
|
|
}
|
|
|
|
const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
|
|
node,
|
|
level,
|
|
selectedItem,
|
|
onPlaylistSelect,
|
|
onPlaylistDelete,
|
|
onPlaylistMove,
|
|
allFolders,
|
|
onDropSongs,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
|
|
// Memoize click handlers to prevent recreation
|
|
const handlePlaylistClick = useCallback(() => {
|
|
onPlaylistSelect(node.name);
|
|
}, [onPlaylistSelect, node.name]);
|
|
|
|
const handleFolderToggle = useCallback(() => {
|
|
setIsOpen(prev => !prev);
|
|
}, []);
|
|
const indent = level * 10; // Reverted back to 10px per level
|
|
|
|
if (node.type === 'folder') {
|
|
return (
|
|
<Box>
|
|
<Flex align="center" gap={1}>
|
|
<Button
|
|
flex={1}
|
|
{...unselectedButtonStyles}
|
|
onClick={handleFolderToggle}
|
|
ml={indent}
|
|
pl={level > 0 ? 6 : 4} // Add extra padding for nested items
|
|
justifyContent="flex-start"
|
|
position="relative"
|
|
leftIcon={
|
|
<Box position="relative" display="flex" alignItems="center">
|
|
<ChevronRightIcon
|
|
position="absolute"
|
|
right="-6px"
|
|
transform={isOpen ? 'rotate(90deg)' : 'none'}
|
|
transition="transform 0.2s"
|
|
color="gray.400"
|
|
/>
|
|
</Box>
|
|
}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const types = Array.from((e.dataTransfer.types || []) as any);
|
|
const hasOurType = types.includes('application/json') || types.includes('text/plain');
|
|
if (hasOurType) {
|
|
try { e.dataTransfer.dropEffect = 'copy'; } catch {}
|
|
setIsDragOver(true);
|
|
}
|
|
}}
|
|
onDragLeave={() => setIsDragOver(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
const json = e.dataTransfer.getData('application/json') || e.dataTransfer.getData('text/plain');
|
|
const parsed = json ? JSON.parse(json) : null;
|
|
if (parsed?.type === 'songs' && Array.isArray(parsed.songIds) && onDropSongs) {
|
|
onDropSongs(node.name, parsed.songIds);
|
|
}
|
|
} catch {}
|
|
setIsDragOver(false);
|
|
}}
|
|
boxShadow={isDragOver ? 'inset 0 0 0 1px var(--chakra-colors-blue-400)' : 'none'}
|
|
>
|
|
{level > 0 && (
|
|
<Box
|
|
position="absolute"
|
|
left="8px"
|
|
top="50%"
|
|
transform="translateY(-50%)"
|
|
width="2px"
|
|
height="12px"
|
|
bg="gray.500"
|
|
borderRadius="1px"
|
|
/>
|
|
)}
|
|
<Icon
|
|
viewBox="0 0 24 24"
|
|
color="blue.300"
|
|
ml={1}
|
|
mr={2}
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"
|
|
/>
|
|
</Icon>
|
|
{node.name}
|
|
</Button>
|
|
</Flex>
|
|
<Collapse in={isOpen}>
|
|
<VStack spacing={1} mt={2} align="stretch">
|
|
{node.children?.map((child, index) => (
|
|
<PlaylistItem
|
|
key={child.id || index}
|
|
node={child}
|
|
level={level + 1}
|
|
selectedItem={selectedItem}
|
|
onPlaylistSelect={onPlaylistSelect}
|
|
onPlaylistDelete={onPlaylistDelete}
|
|
onPlaylistMove={onPlaylistMove}
|
|
allFolders={allFolders}
|
|
onDropSongs={onDropSongs}
|
|
/>
|
|
))}
|
|
</VStack>
|
|
</Collapse>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Flex align="center" gap={0}>
|
|
<Button
|
|
flex="1 1 auto"
|
|
{...(selectedItem === node.name ? selectedButtonStyles : unselectedButtonStyles)}
|
|
onClick={handlePlaylistClick}
|
|
ml={indent}
|
|
pl={level > 0 ? 6 : 4} // Add extra padding for nested items
|
|
borderRightRadius={0}
|
|
borderRight="1px solid"
|
|
borderRightColor="whiteAlpha.200"
|
|
position="relative"
|
|
border={selectedItem === node.name ? '1px solid' : undefined}
|
|
borderColor={selectedItem === node.name ? 'blue.800' : undefined}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const types = Array.from((e.dataTransfer.types || []) as any);
|
|
const hasOurType = types.includes('application/json') || types.includes('text/plain');
|
|
if (hasOurType) {
|
|
try { e.dataTransfer.dropEffect = 'copy'; } catch {}
|
|
setIsDragOver(true);
|
|
}
|
|
}}
|
|
onDragLeave={() => setIsDragOver(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
let json = e.dataTransfer.getData('application/json');
|
|
if (!json) json = e.dataTransfer.getData('text/plain');
|
|
let parsed: any = null;
|
|
if (json && json.trim().length > 0) {
|
|
parsed = JSON.parse(json);
|
|
}
|
|
if (parsed?.type === 'songs' && Array.isArray(parsed.songIds) && onDropSongs) {
|
|
onDropSongs(node.name, parsed.songIds);
|
|
}
|
|
} catch {}
|
|
setIsDragOver(false);
|
|
}}
|
|
boxShadow={isDragOver ? 'inset 0 0 0 1px var(--chakra-colors-blue-400)' : 'none'}
|
|
>
|
|
{level > 0 && (
|
|
<Box
|
|
position="absolute"
|
|
left="8px"
|
|
top="50%"
|
|
transform="translateY(-50%)"
|
|
width="2px"
|
|
height="12px"
|
|
bg="gray.500"
|
|
borderRadius="1px"
|
|
/>
|
|
)}
|
|
<Flex align="center" justify="space-between" w="100%">
|
|
<Text>{node.name}</Text>
|
|
{((node.trackCount || 0) + (node.tracks?.length || 0)) > 0 && (
|
|
<Badge size="sm" colorScheme="blue" variant="subtle" ml={2}>
|
|
{node.trackCount || node.tracks?.length || 0}
|
|
</Badge>
|
|
)}
|
|
</Flex>
|
|
</Button>
|
|
<Menu>
|
|
<MenuButton
|
|
as={Button}
|
|
width="32px"
|
|
height="40px"
|
|
borderLeftRadius={0}
|
|
bg={selectedItem === node.name ? "blue.800" : "transparent"}
|
|
color={selectedItem === node.name ? "white" : "gray.100"}
|
|
fontWeight={selectedItem === node.name ? "600" : "normal"}
|
|
transition="all 0.2s"
|
|
_hover={{
|
|
bg: selectedItem === node.name ? "blue.600" : "whiteAlpha.200",
|
|
transform: "none"
|
|
}}
|
|
_active={{
|
|
bg: selectedItem === node.name ? "blue.700" : "whiteAlpha.300",
|
|
}}
|
|
aria-label="Playlist options"
|
|
p={0}
|
|
>
|
|
<Icon as={FiMoreHorizontal} color={selectedItem === node.name ? "white" : "gray.400"} _hover={{ color: "white" }} />
|
|
</MenuButton>
|
|
<MenuList bg="gray.800" borderColor="gray.700">
|
|
<MenuGroup title="Move to folder">
|
|
<MenuItem
|
|
bg="gray.800"
|
|
_hover={{ bg: "gray.700" }}
|
|
onClick={() => onPlaylistMove(node.name, null)}
|
|
>
|
|
Root level
|
|
</MenuItem>
|
|
<MenuDivider />
|
|
{allFolders.map((folder) => (
|
|
<MenuItem
|
|
key={folder.name}
|
|
bg="gray.800"
|
|
_hover={{ bg: "gray.700" }}
|
|
onClick={() => onPlaylistMove(node.name, folder.name)}
|
|
>
|
|
{folder.name}
|
|
</MenuItem>
|
|
))}
|
|
</MenuGroup>
|
|
<MenuDivider />
|
|
<MenuItem
|
|
bg="gray.800"
|
|
color="red.300"
|
|
_hover={{ bg: "gray.700" }}
|
|
icon={<DeleteIcon />}
|
|
onClick={() => onPlaylistDelete(node.name)}
|
|
>
|
|
Delete Playlist
|
|
</MenuItem>
|
|
</MenuList>
|
|
</Menu>
|
|
</Flex>
|
|
);
|
|
});
|
|
|
|
const PlaylistManagerComponent: React.FC<PlaylistManagerProps> = ({
|
|
playlists,
|
|
selectedItem,
|
|
onPlaylistCreate,
|
|
onPlaylistSelect,
|
|
onPlaylistDelete,
|
|
onFolderCreate,
|
|
onPlaylistMove,
|
|
onDropSongs,
|
|
}) => {
|
|
const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure();
|
|
const { isOpen: isFolderModalOpen, onOpen: onFolderModalOpen, onClose: onFolderModalClose } = useDisclosure();
|
|
const [newPlaylistName, setNewPlaylistName] = useState("");
|
|
const [newFolderName, setNewFolderName] = useState("");
|
|
|
|
// Helper function to get all folders from the playlist tree
|
|
const getAllFolders = useCallback((nodes: PlaylistNode[]): { name: string }[] => {
|
|
let result: { name: string }[] = [];
|
|
for (const node of nodes) {
|
|
if (node.type === 'folder') {
|
|
result.push({ name: node.name });
|
|
if (node.children) {
|
|
result = result.concat(getAllFolders(node.children));
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}, []);
|
|
|
|
const allFolders = getAllFolders(playlists);
|
|
|
|
const handleCreatePlaylist = () => {
|
|
if (newPlaylistName.trim()) {
|
|
onPlaylistCreate(newPlaylistName);
|
|
setNewPlaylistName("");
|
|
onPlaylistModalClose();
|
|
}
|
|
};
|
|
|
|
const handleCreateFolder = () => {
|
|
if (newFolderName.trim()) {
|
|
onFolderCreate(newFolderName);
|
|
setNewFolderName("");
|
|
onFolderModalClose();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
<VStack spacing={2} align="stretch" mb={4}>
|
|
<Button
|
|
{...(selectedItem === null ? selectedButtonStyles : unselectedButtonStyles)}
|
|
onClick={() => onPlaylistSelect(null)}
|
|
>
|
|
All Songs
|
|
</Button>
|
|
|
|
{/* Add Playlist and Folder buttons at the top */}
|
|
<Flex gap={2} mb={2}>
|
|
<Button
|
|
onClick={onPlaylistModalOpen}
|
|
colorScheme="blue"
|
|
size="sm"
|
|
flex={1}
|
|
leftIcon={<AddIcon />}
|
|
borderRadius="md"
|
|
_hover={{
|
|
transform: "translateY(-1px)",
|
|
boxShadow: "sm",
|
|
}}
|
|
>
|
|
New Playlist
|
|
</Button>
|
|
<Button
|
|
onClick={onFolderModalOpen}
|
|
colorScheme="teal"
|
|
size="sm"
|
|
flex={1}
|
|
leftIcon={<ViewIcon />}
|
|
borderRadius="md"
|
|
_hover={{
|
|
transform: "translateY(-1px)",
|
|
boxShadow: "sm",
|
|
}}
|
|
>
|
|
New Folder
|
|
</Button>
|
|
</Flex>
|
|
|
|
{playlists.map((node, index) => (
|
|
<PlaylistItem
|
|
key={node.id || index}
|
|
node={node}
|
|
level={0}
|
|
selectedItem={selectedItem}
|
|
onPlaylistSelect={onPlaylistSelect}
|
|
onPlaylistDelete={onPlaylistDelete}
|
|
onPlaylistMove={onPlaylistMove}
|
|
allFolders={allFolders}
|
|
onDropSongs={onDropSongs}
|
|
/>
|
|
))}
|
|
</VStack>
|
|
|
|
{/* New Playlist Modal */}
|
|
<Modal
|
|
isOpen={isPlaylistModalOpen}
|
|
onClose={onPlaylistModalClose}
|
|
isCentered
|
|
motionPreset="slideInBottom"
|
|
>
|
|
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(5px)" />
|
|
<ModalContent
|
|
bg="gray.800"
|
|
borderRadius="xl"
|
|
boxShadow="xl"
|
|
border="1px"
|
|
borderColor="gray.700"
|
|
>
|
|
<ModalHeader color="white">Create New Playlist</ModalHeader>
|
|
<ModalCloseButton color="white" />
|
|
<ModalBody pb={6}>
|
|
<Input
|
|
value={newPlaylistName}
|
|
onChange={(e) => setNewPlaylistName(e.target.value)}
|
|
placeholder="Enter playlist name"
|
|
bg="gray.700"
|
|
border="none"
|
|
color="white"
|
|
_placeholder={{ color: "gray.400" }}
|
|
_focus={{
|
|
boxShadow: "0 0 0 1px blue.500",
|
|
borderColor: "blue.500",
|
|
}}
|
|
/>
|
|
</ModalBody>
|
|
|
|
<ModalFooter>
|
|
<Button
|
|
colorScheme="blue"
|
|
mr={3}
|
|
onClick={handleCreatePlaylist}
|
|
isDisabled={!newPlaylistName.trim()}
|
|
_hover={{
|
|
transform: "translateY(-1px)",
|
|
boxShadow: "sm",
|
|
}}
|
|
>
|
|
Create
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onPlaylistModalClose}
|
|
color="gray.300"
|
|
_hover={{
|
|
bg: "whiteAlpha.200",
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
{/* New Folder Modal */}
|
|
<Modal
|
|
isOpen={isFolderModalOpen}
|
|
onClose={onFolderModalClose}
|
|
isCentered
|
|
motionPreset="slideInBottom"
|
|
>
|
|
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(5px)" />
|
|
<ModalContent
|
|
bg="gray.800"
|
|
borderRadius="xl"
|
|
boxShadow="xl"
|
|
border="1px"
|
|
borderColor="gray.700"
|
|
>
|
|
<ModalHeader color="white">Create New Folder</ModalHeader>
|
|
<ModalCloseButton color="white" />
|
|
<ModalBody pb={6}>
|
|
<Input
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
placeholder="Enter folder name"
|
|
bg="gray.700"
|
|
border="none"
|
|
color="white"
|
|
_placeholder={{ color: "gray.400" }}
|
|
_focus={{
|
|
boxShadow: "0 0 0 1px teal.500",
|
|
borderColor: "teal.500",
|
|
}}
|
|
/>
|
|
</ModalBody>
|
|
|
|
<ModalFooter>
|
|
<Button
|
|
colorScheme="teal"
|
|
mr={3}
|
|
onClick={handleCreateFolder}
|
|
isDisabled={!newFolderName.trim()}
|
|
_hover={{
|
|
transform: "translateY(-1px)",
|
|
boxShadow: "sm",
|
|
}}
|
|
>
|
|
Create
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onFolderModalClose}
|
|
color="gray.300"
|
|
_hover={{
|
|
bg: "whiteAlpha.200",
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export const PlaylistManager = React.memo(PlaylistManagerComponent); |