added drag-and-drop
parent
34f2010cae
commit
933ceb2547
|
|
@ -31,7 +31,10 @@
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"socket.io-client": "^4.8.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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// SidebarMenu.jsx
|
import { useState, useEffect } from "react";
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
|
|
@ -8,13 +7,27 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box,
|
Box,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||||
import useSidebarResize from "../hooks/useSidebarResize";
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft';
|
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRight from '@mui/icons-material/ChevronRight';
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
import LogoFull from '../../assets/images/logo.svg?react';
|
import LogoFull from "../../assets/images/logo.svg?react";
|
||||||
import LogoSmall from '../../assets/images/system_monitor_icon.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 = ({
|
const SidebarMenu = ({
|
||||||
data,
|
data,
|
||||||
|
|
@ -22,25 +35,161 @@ const SidebarMenu = ({
|
||||||
setIsDarkMode,
|
setIsDarkMode,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
forceRefreshMenu,
|
forceRefreshMenu,
|
||||||
user
|
user,
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [menuItems, setMenuItems] = useState(data.items || []);
|
||||||
|
const [activeItem, setActiveItem] = useState(null);
|
||||||
|
|
||||||
const handleToggleCollapse = () => {
|
const sensors = useSensors(useSensor(PointerSensor, {
|
||||||
setCollapsed(!collapsed);
|
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',
|
const removeItemFromTree = (items, id) => {
|
||||||
cursor: 'ew-resize',
|
return items.filter(item => {
|
||||||
backgroundColor: 'transparent',
|
if (item.id === id) return false;
|
||||||
'&:hover': {
|
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,
|
backgroundColor: theme.palette.action.hover,
|
||||||
},
|
},
|
||||||
height: '100%',
|
height: "100%",
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
|
@ -48,12 +197,10 @@ const SidebarMenu = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: collapsed ? 64 : sidebarWidth,
|
||||||
transition: 'width 0.3s ease',
|
transition: "width 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -61,68 +208,66 @@ const SidebarMenu = ({
|
||||||
sx={{
|
sx={{
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: collapsed ? 64 : sidebarWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'& .MuiDrawer-paper': {
|
"& .MuiDrawer-paper": {
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: collapsed ? 64 : sidebarWidth,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: 'custom.sidebar',
|
backgroundColor: "custom.sidebar",
|
||||||
color: 'custom.sidebarText',
|
color: "custom.sidebarText",
|
||||||
transition: 'width 0.3s ease',
|
transition: "width 0.3s ease",
|
||||||
overflowX: 'hidden',
|
overflowX: "hidden",
|
||||||
borderRight: 'none'
|
borderRight: "none",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Заголовок с логотипом */}
|
{/* Заголовок с логотипом */}
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
justifyContent: 'center', // Центрируем содержимое
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
p: 1,
|
p: 1,
|
||||||
borderBottom: '1px solid',
|
borderBottom: "1px solid",
|
||||||
borderColor: 'divider',
|
borderColor: "divider",
|
||||||
backgroundColor: 'custom.sidebar',
|
backgroundColor: "custom.sidebar",
|
||||||
height: 80, // Фиксированная высота
|
height: 80,
|
||||||
position: 'relative' // Для позиционирования кнопки
|
position: "relative",
|
||||||
}}>
|
}}
|
||||||
{/* Логотип (занимает все пространство) */}
|
>
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
alignItems: "center",
|
||||||
width: '100%',
|
justifyContent: "center",
|
||||||
height: '100%',
|
width: "100%",
|
||||||
'& svg': {
|
height: "100%",
|
||||||
width: '100%',
|
"& svg": {
|
||||||
height: '100%',
|
width: "100%",
|
||||||
padding: collapsed ? '8px' : '12px',
|
height: "100%",
|
||||||
objectFit: 'contain'
|
padding: collapsed ? "8px" : "12px",
|
||||||
}
|
objectFit: "contain",
|
||||||
}}>
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<LogoSmall style={{
|
<LogoSmall style={{ color: "inherit" }} />
|
||||||
color: 'inherit' // Наследует цвет темы
|
|
||||||
}} />
|
|
||||||
) : (
|
) : (
|
||||||
<LogoFull style={{
|
<LogoFull style={{ color: "inherit" }} />
|
||||||
color: 'inherit' // Наследует цвет темы
|
|
||||||
}} />
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Кнопка сворачивания (абсолютное позиционирование) */}
|
|
||||||
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleToggleCollapse}
|
onClick={handleToggleCollapse}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
color: 'custom.sidebarText',
|
color: "custom.sidebarText",
|
||||||
'&:hover': { backgroundColor: 'custom.sidebarHover' },
|
"&:hover": { backgroundColor: "custom.sidebarHover" },
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
right: 8,
|
right: 8,
|
||||||
top: '50%',
|
top: "50%",
|
||||||
transform: 'translateY(-50%)'
|
transform: "translateY(-50%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||||
|
|
@ -131,18 +276,48 @@ const SidebarMenu = ({
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Основное содержимое меню */}
|
{/* Основное содержимое меню */}
|
||||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<Box
|
||||||
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
|
sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
|
||||||
{data && (
|
>
|
||||||
<MenuItem
|
<DndContext
|
||||||
item={data}
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<List sx={{ overflowY: "auto", flex: "1 1 auto" }}>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<SortableMenuItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
level={0}
|
|
||||||
|
|
||||||
onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
padding: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
boxShadow: 3,
|
||||||
|
maxWidth: 200,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.title}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
|
|
@ -152,9 +327,8 @@ const SidebarMenu = ({
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{!collapsed && (
|
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
{!collapsed && <SidebarResizer onMouseDown={startResizing} />}
|
||||||
)}
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Box ref={setNodeRef} style={style}>
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
sx={{
|
||||||
|
pl: collapsed ? 2 : 2 + level * 2,
|
||||||
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
|
backgroundColor: isDragging ? 'action.hover' : 'transparent',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{!collapsed && (
|
||||||
|
<IconButton
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
cursor: "grab",
|
||||||
|
mr: 1,
|
||||||
|
'&:active': { cursor: 'grabbing' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragIndicator fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListItemIcon sx={{ minWidth: collapsed ? "auto" : 40 }}>
|
||||||
|
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.title}
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{hasChildren && !collapsed && (
|
||||||
|
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||||
|
<List disablePadding>
|
||||||
|
{item.items.map((child) => (
|
||||||
|
<SortableMenuItem
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onSelectItem={onSelectItem}
|
||||||
|
level={level + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableMenuItem;
|
||||||
Loading…
Reference in New Issue