From 933ceb2547f7a9ea5653957cfab52817a8fc729a Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 1 Sep 2025 08:16:59 -0400 Subject: [PATCH] added drag-and-drop --- package.json | 7 +- src/Components/Layout/SidebarMenu.jsx | 332 +++++++++++++----- .../SortableMenuItem.jsx | 115 ++++++ 3 files changed, 373 insertions(+), 81 deletions(-) create mode 100644 src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx diff --git a/package.json b/package.json index ca982a3..f44909a 100755 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "reactflow": "^11.11.4", "recharts": "^2.15.1", "socket.io-client": "^4.8.1", - "vite-plugin-svgr": "^4.3.0" + "vite-plugin-svgr": "^4.3.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/core": "^6.3.1" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -45,4 +48,4 @@ "globals": "^15.14.0", "vite": "^7.1.0" } -} +} \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index 3c07130..87b61e2 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -1,5 +1,4 @@ -// SidebarMenu.jsx -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Drawer, List, @@ -8,13 +7,27 @@ import { Tooltip, Box, } from "@mui/material"; -import MenuItem from "./SidebarMenuComponents/MenuItem"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import useSidebarResize from "../hooks/useSidebarResize"; -import ChevronLeft from '@mui/icons-material/ChevronLeft'; -import ChevronRight from '@mui/icons-material/ChevronRight'; -import LogoFull from '../../assets/images/logo.svg?react'; -import LogoSmall from '../../assets/images/system_monitor_icon.svg?react'; +import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import ChevronRight from "@mui/icons-material/ChevronRight"; +import LogoFull from "../../assets/images/logo.svg?react"; +import LogoSmall from "../../assets/images/system_monitor_icon.svg?react"; + +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + DragOverlay +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; + +import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem"; const SidebarMenu = ({ data, @@ -22,25 +35,161 @@ const SidebarMenu = ({ setIsDarkMode, onSelectItem, forceRefreshMenu, - user + user, }) => { const [collapsed, setCollapsed] = useState(false); const { sidebarWidth, startResizing } = useSidebarResize(290); - const [hovered, setHovered] = useState(false); + const [menuItems, setMenuItems] = useState(data.items || []); + const [activeItem, setActiveItem] = useState(null); - const handleToggleCollapse = () => { - setCollapsed(!collapsed); + const sensors = useSensors(useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + })); + + useEffect(() => { + const cached = localStorage.getItem("menuTree"); + if (cached) { + try { + setMenuItems(JSON.parse(cached)); + } catch { + setMenuItems(data.items || []); + } + } else { + setMenuItems(data.items || []); + } + }, [data]); + + const handleToggleCollapse = () => setCollapsed(!collapsed); + + // Функция для поиска элемента по ID во всем дереве + const findItemInTree = (items, id) => { + for (const item of items) { + if (item.id === id) return item; + if (item.items) { + const found = findItemInTree(item.items, id); + if (found) return found; + } + } + return null; }; - const SidebarResizer = styled('div')(({ theme }) => ({ - width: '4px', - cursor: 'ew-resize', - backgroundColor: 'transparent', - '&:hover': { + // Функция для удаления элемента из дерева + const removeItemFromTree = (items, id) => { + return items.filter(item => { + if (item.id === id) return false; + if (item.items) { + item.items = removeItemFromTree(item.items, id); + } + return true; + }); + }; + + // Функция для добавления элемента в конкретную папку + const addItemToFolder = (items, folderId, newItem) => { + return items.map(item => { + if (item.id === folderId) { + return { + ...item, + items: [...(item.items || []), newItem] + }; + } + if (item.items) { + return { + ...item, + items: addItemToFolder(item.items, folderId, newItem) + }; + } + return item; + }); + }; + + // Функция для поиска родителя элемента + const findParent = (items, childId, parent = null) => { + for (const item of items) { + if (item.id === childId) return parent; + if (item.items) { + const found = findParent(item.items, childId, item); + if (found) return found; + } + } + return null; + }; + + // Функция для добавления элемента на тот же уровень + const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => { + return items.map(item => { + if (item.id === parentId) { + const children = item.items || []; + const insertIndex = afterId ? children.findIndex(i => i.id === afterId) + 1 : children.length; + + const newChildren = [ + ...children.slice(0, insertIndex), + newItem, + ...children.slice(insertIndex) + ]; + + return { ...item, items: newChildren }; + } + if (item.items) { + return { ...item, items: addItemAtSameLevel(item.items, parentId, newItem, afterId) }; + } + return item; + }); + }; + + const handleDragStart = (event) => { + const { active } = event; + const item = findItemInTree(menuItems, active.id); + setActiveItem(item); + }; + + const handleDragEnd = (event) => { + const { active, over } = event; + setActiveItem(null); + + if (!over) return; + if (active.id === over.id) return; + + const draggedItem = findItemInTree(menuItems, active.id); + if (!draggedItem) return; + + // Определяем тип целевого элемента (папка или элемент) + const overItem = findItemInTree(menuItems, over.id); + const isOverFolder = overItem && Array.isArray(overItem.items); + + let newTree; + + if (isOverFolder) { + // Перемещаем в папку + newTree = removeItemFromTree([...menuItems], active.id); + newTree = addItemToFolder(newTree, over.id, draggedItem); + } else { + // Перемещаем на тот же уровень + const overParent = findParent(menuItems, over.id); + if (!overParent) return; + + // Удаляем из старого места + newTree = removeItemFromTree([...menuItems], active.id); + + // Добавляем на новый уровень + newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, over.id); + } + + setMenuItems(newTree); + localStorage.setItem("menuTree", JSON.stringify(newTree)); + }; + + const SidebarResizer = styled("div")(({ theme }) => ({ + width: "4px", + cursor: "ew-resize", + backgroundColor: "transparent", + "&:hover": { backgroundColor: theme.palette.action.hover, }, - height: '100%', - position: 'absolute', + height: "100%", + position: "absolute", top: 0, right: 0, zIndex: 1000, @@ -48,12 +197,10 @@ const SidebarMenu = ({ return ( setHovered(true)} - onMouseLeave={() => setHovered(false)} sx={{ - position: 'relative', + position: "relative", width: collapsed ? 64 : sidebarWidth, - transition: 'width 0.3s ease', + transition: "width 0.3s ease", }} > {/* Заголовок с логотипом */} - - {/* Логотип (занимает все пространство) */} - + + {collapsed ? ( - + ) : ( - + )} - {/* Кнопка сворачивания (абсолютное позиционирование) */} {collapsed ? : } @@ -131,18 +276,48 @@ const SidebarMenu = ({ {/* Основное содержимое меню */} - - - {data && ( - + + i.id)} strategy={verticalListSortingStrategy}> + + {menuItems.map((item) => ( + + ))} + + - onSelectItem={onSelectItem} - /> - )} - + + {activeItem ? ( + + {activeItem.title} + + ) : null} + + - {!collapsed && ( - - )} + + {!collapsed && } ); diff --git a/src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx new file mode 100644 index 0000000..20ae851 --- /dev/null +++ b/src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { + ListItem, + ListItemIcon, + ListItemText, + Collapse, + List, + IconButton, + Box +} from "@mui/material"; +import { Folder, FolderOpen, ExpandLess, ExpandMore, DragIndicator } from "@mui/icons-material"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => { + const [isOpen, setIsOpen] = useState(false); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ + id: item.id, + data: { + type: 'menu-item', + item + } + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const hasChildren = Array.isArray(item.items) && item.items.length > 0; + + const handleClick = (e) => { + e.stopPropagation(); + if (hasChildren) { + setIsOpen(!isOpen); + } else { + onSelectItem?.(item); + } + }; + + return ( + + + {!collapsed && ( + + + + )} + + + {hasChildren ? (isOpen ? : ) : } + + + {!collapsed && ( + <> + + {hasChildren && (isOpen ? : )} + + )} + + + {hasChildren && !collapsed && ( + + + {item.items.map((child) => ( + + ))} + + + )} + + ); +}; + +export default SortableMenuItem; \ No newline at end of file