added drag-and-drop
parent
34f2010cae
commit
933ceb2547
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
position: "relative",
|
||||
width: collapsed ? 64 : sidebarWidth,
|
||||
transition: 'width 0.3s ease',
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
|
|
@ -61,68 +208,66 @@ const SidebarMenu = ({
|
|||
sx={{
|
||||
width: collapsed ? 64 : sidebarWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
"& .MuiDrawer-paper": {
|
||||
width: collapsed ? 64 : sidebarWidth,
|
||||
boxSizing: "border-box",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: 'custom.sidebar',
|
||||
color: 'custom.sidebarText',
|
||||
transition: 'width 0.3s ease',
|
||||
overflowX: 'hidden',
|
||||
borderRight: 'none'
|
||||
backgroundColor: "custom.sidebar",
|
||||
color: "custom.sidebarText",
|
||||
transition: "width 0.3s ease",
|
||||
overflowX: "hidden",
|
||||
borderRight: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Заголовок с логотипом */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center', // Центрируем содержимое
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
p: 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
backgroundColor: 'custom.sidebar',
|
||||
height: 80, // Фиксированная высота
|
||||
position: 'relative' // Для позиционирования кнопки
|
||||
}}>
|
||||
{/* Логотип (занимает все пространство) */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: collapsed ? '8px' : '12px',
|
||||
objectFit: 'contain'
|
||||
}
|
||||
}}>
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
backgroundColor: "custom.sidebar",
|
||||
height: 80,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"& svg": {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: collapsed ? "8px" : "12px",
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<LogoSmall style={{
|
||||
color: 'inherit' // Наследует цвет темы
|
||||
}} />
|
||||
<LogoSmall style={{ color: "inherit" }} />
|
||||
) : (
|
||||
<LogoFull style={{
|
||||
color: 'inherit' // Наследует цвет темы
|
||||
}} />
|
||||
<LogoFull style={{ color: "inherit" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Кнопка сворачивания (абсолютное позиционирование) */}
|
||||
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
||||
<IconButton
|
||||
onClick={handleToggleCollapse}
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'custom.sidebarText',
|
||||
'&:hover': { backgroundColor: 'custom.sidebarHover' },
|
||||
position: 'absolute',
|
||||
color: "custom.sidebarText",
|
||||
"&:hover": { backgroundColor: "custom.sidebarHover" },
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)'
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||
|
|
@ -131,18 +276,48 @@ const SidebarMenu = ({
|
|||
</Box>
|
||||
|
||||
{/* Основное содержимое меню */}
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
|
||||
{data && (
|
||||
<MenuItem
|
||||
item={data}
|
||||
<Box
|
||||
sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
|
||||
>
|
||||
<DndContext
|
||||
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}
|
||||
level={0}
|
||||
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</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
|
||||
collapsed={collapsed}
|
||||
|
|
@ -152,9 +327,8 @@ const SidebarMenu = ({
|
|||
user={user}
|
||||
/>
|
||||
</Box>
|
||||
{!collapsed && (
|
||||
<SidebarResizer onMouseDown={startResizing} />
|
||||
)}
|
||||
|
||||
{!collapsed && <SidebarResizer onMouseDown={startResizing} />}
|
||||
</Drawer>
|
||||
</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