added drag-and-drop

pull/59/head
DmitriyA 2025-09-01 08:16:59 -04:00
parent 34f2010cae
commit 933ceb2547
3 changed files with 373 additions and 81 deletions

View File

@ -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",

View File

@ -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>
);

View File

@ -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;