435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
import {
|
|
Box,
|
|
Button,
|
|
Flex,
|
|
Input,
|
|
Modal,
|
|
ModalBody,
|
|
ModalCloseButton,
|
|
ModalContent,
|
|
ModalFooter,
|
|
ModalHeader,
|
|
ModalOverlay,
|
|
useDisclosure,
|
|
VStack,
|
|
Menu,
|
|
MenuButton,
|
|
MenuList,
|
|
MenuItem,
|
|
IconButton,
|
|
Collapse,
|
|
MenuDivider,
|
|
MenuGroup,
|
|
Icon
|
|
} from "@chakra-ui/react";
|
|
import { ChevronDownIcon, DeleteIcon, ChevronRightIcon, AddIcon, ViewIcon } from "@chakra-ui/icons";
|
|
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;
|
|
}
|
|
|
|
const getButtonStyles = (isSelected: boolean) => ({
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
bg: isSelected ? "blue.800" : "transparent",
|
|
color: isSelected ? "white" : "gray.100",
|
|
fontWeight: isSelected ? "600" : "normal",
|
|
borderRadius: "md",
|
|
px: 4,
|
|
py: 2,
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
_hover: {
|
|
bg: isSelected ? "blue.600" : "whiteAlpha.200",
|
|
transform: "translateX(2px)",
|
|
},
|
|
_active: {
|
|
bg: isSelected ? "blue.700" : "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 }[];
|
|
}
|
|
|
|
const PlaylistItem: React.FC<PlaylistItemProps> = ({
|
|
node,
|
|
level,
|
|
selectedItem,
|
|
onPlaylistSelect,
|
|
onPlaylistDelete,
|
|
onPlaylistMove,
|
|
allFolders,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(true);
|
|
const indent = level * 10;
|
|
|
|
if (node.type === 'folder') {
|
|
return (
|
|
<Box>
|
|
<Flex align="center" gap={1}>
|
|
<Button
|
|
flex={1}
|
|
{...getButtonStyles(false)}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
ml={indent}
|
|
justifyContent="flex-start"
|
|
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>
|
|
}
|
|
>
|
|
<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}
|
|
/>
|
|
))}
|
|
</VStack>
|
|
</Collapse>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Flex align="center" gap={0}>
|
|
<Button
|
|
flex="1 1 auto"
|
|
{...getButtonStyles(selectedItem === node.name)}
|
|
onClick={() => onPlaylistSelect(node.name)}
|
|
ml={indent}
|
|
borderRightRadius={0}
|
|
borderRight="1px solid"
|
|
borderRightColor="whiteAlpha.200"
|
|
>
|
|
{node.name}
|
|
</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}
|
|
>
|
|
<ChevronDownIcon
|
|
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>
|
|
);
|
|
};
|
|
|
|
export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
|
|
playlists,
|
|
selectedItem,
|
|
onPlaylistCreate,
|
|
onPlaylistSelect,
|
|
onPlaylistDelete,
|
|
onFolderCreate,
|
|
onPlaylistMove,
|
|
}) => {
|
|
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
|
|
{...getButtonStyles(selectedItem === null)}
|
|
onClick={() => onPlaylistSelect(null)}
|
|
>
|
|
All Songs
|
|
</Button>
|
|
{playlists.map((node, index) => (
|
|
<PlaylistItem
|
|
key={node.id || index}
|
|
node={node}
|
|
level={0}
|
|
selectedItem={selectedItem}
|
|
onPlaylistSelect={onPlaylistSelect}
|
|
onPlaylistDelete={onPlaylistDelete}
|
|
onPlaylistMove={onPlaylistMove}
|
|
allFolders={allFolders}
|
|
/>
|
|
))}
|
|
</VStack>
|
|
|
|
<Flex gap={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>
|
|
|
|
{/* 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>
|
|
);
|
|
};
|