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", "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",
@ -45,4 +48,4 @@
"globals": "^15.14.0", "globals": "^15.14.0",
"vite": "^7.1.0" "vite": "^7.1.0"
} }
} }

View File

@ -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",
p: 1, justifyContent: "center",
borderBottom: '1px solid', p: 1,
borderColor: 'divider', borderBottom: "1px solid",
backgroundColor: 'custom.sidebar', borderColor: "divider",
height: 80, // Фиксированная высота backgroundColor: "custom.sidebar",
position: 'relative' // Для позиционирования кнопки height: 80,
}}> position: "relative",
{/* Логотип (занимает все пространство) */} }}
<Box sx={{ >
display: 'flex', <Box
alignItems: 'center', sx={{
justifyContent: 'center', display: "flex",
width: '100%', alignItems: "center",
height: '100%', justifyContent: "center",
'& svg': { width: "100%",
width: '100%', height: "100%",
height: '100%', "& svg": {
padding: collapsed ? '8px' : '12px', width: "100%",
objectFit: 'contain' height: "100%",
} 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}
collapsed={collapsed} collisionDetection={closestCenter}
level={0} 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}
onSelectItem={onSelectItem}
/>
))}
</List>
</SortableContext>
onSelectItem={onSelectItem} <DragOverlay>
/> {activeItem ? (
)} <Box
</List> 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>
); );

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;