538 lines
20 KiB
JavaScript
538 lines
20 KiB
JavaScript
import { useState, useEffect } from "react";
|
||
import {
|
||
Drawer,
|
||
List,
|
||
styled,
|
||
IconButton,
|
||
Tooltip,
|
||
Box,
|
||
alpha
|
||
} from "@mui/material";
|
||
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 {
|
||
DndContext,
|
||
closestCenter,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
DragOverlay,
|
||
MeasuringStrategy
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
SortableContext,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
|
||
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
|
||
|
||
const SidebarMenu = ({
|
||
data,
|
||
isDarkMode,
|
||
setIsDarkMode,
|
||
onSelectItem,
|
||
forceRefreshMenu,
|
||
user,
|
||
}) => {
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину
|
||
const [menuItems, setMenuItems] = useState(data.items || []);
|
||
const [activeItem, setActiveItem] = useState(null);
|
||
const [hoveredItem, setHoveredItem] = useState(null);
|
||
const [dropIndicator, setDropIndicator] = useState({ show: false, position: null, targetId: null });
|
||
|
||
const sensors = useSensors(useSensor(PointerSensor, {
|
||
activationConstraint: {
|
||
distance: 4,
|
||
},
|
||
}));
|
||
|
||
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);
|
||
setHoveredItem(null);
|
||
};
|
||
|
||
// Функции для работы с деревом (остаются без изменений)
|
||
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 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);
|
||
setDropIndicator({ show: false, position: null, targetId: null });
|
||
};
|
||
const handleDragEnd = (event) => {
|
||
const { active, over } = event;
|
||
setActiveItem(null);
|
||
setHoveredItem(null);
|
||
setDropIndicator({ show: false, position: null, targetId: 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);
|
||
|
||
// Проверяем, не пытаемся ли переместить элемент в его же потомка
|
||
if (isDescendant(draggedItem, overItem)) {
|
||
return;
|
||
}
|
||
|
||
let newTree;
|
||
|
||
if (dropIndicator.position === 'inside' && overItem && Array.isArray(overItem.items)) {
|
||
// Вставка внутрь папки
|
||
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);
|
||
|
||
// Определяем позицию для вставки
|
||
let insertAfterId = null;
|
||
if (dropIndicator.position === 'below') {
|
||
insertAfterId = over.id;
|
||
} else if (dropIndicator.position === 'above') {
|
||
const siblings = overParent.items || [];
|
||
const overIndex = siblings.findIndex(item => item.id === over.id);
|
||
if (overIndex > 0) {
|
||
insertAfterId = siblings[overIndex - 1].id;
|
||
}
|
||
}
|
||
|
||
newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, insertAfterId);
|
||
}
|
||
|
||
setMenuItems(newTree);
|
||
localStorage.setItem("menuTree", JSON.stringify(newTree));
|
||
};
|
||
|
||
const handleDragOver = (event) => {
|
||
const { active, over } = event;
|
||
|
||
if (!over) {
|
||
setDropIndicator({ show: false, position: null, targetId: null });
|
||
return;
|
||
}
|
||
|
||
const overItem = findItemInTree(menuItems, over.id);
|
||
const activeItem = findItemInTree(menuItems, active.id);
|
||
|
||
if (!overItem || !activeItem || active.id === over.id) {
|
||
setDropIndicator({ show: false, position: null, targetId: null });
|
||
return;
|
||
}
|
||
|
||
// Проверяем, можно ли перемещать элемент
|
||
if (isDescendant(activeItem, overItem)) {
|
||
setDropIndicator({ show: false, position: null, targetId: null });
|
||
return;
|
||
}
|
||
|
||
const overRect = over.rect.current;
|
||
if (!overRect) return;
|
||
|
||
const relativeY = event.delta.y;
|
||
const isOverFolder = overItem && Array.isArray(overItem.items);
|
||
const isTopHalf = relativeY < overRect.height * 0.4;
|
||
const isBottomHalf = relativeY > overRect.height * 0.6;
|
||
|
||
if (isOverFolder && !isTopHalf && !isBottomHalf) {
|
||
// Показываем индикатор для вставки в папку
|
||
setDropIndicator({
|
||
show: true,
|
||
position: 'inside',
|
||
targetId: over.id
|
||
});
|
||
setHoveredItem(over.id);
|
||
} else if (isTopHalf) {
|
||
// Показываем индикатор для вставки выше
|
||
setDropIndicator({
|
||
show: true,
|
||
position: 'above',
|
||
targetId: over.id
|
||
});
|
||
setHoveredItem(null);
|
||
} else if (isBottomHalf) {
|
||
// Показываем индикатор для вставки ниже
|
||
setDropIndicator({
|
||
show: true,
|
||
position: 'below',
|
||
targetId: over.id
|
||
});
|
||
setHoveredItem(null);
|
||
} else {
|
||
setDropIndicator({ show: false, position: null, targetId: null });
|
||
setHoveredItem(null);
|
||
}
|
||
};
|
||
|
||
const isDescendant = (parent, child) => {
|
||
if (!parent || !child || !parent.items) return false;
|
||
|
||
const checkChildren = (items, targetId) => {
|
||
for (const item of items) {
|
||
if (item.id === targetId) return true;
|
||
if (item.items && checkChildren(item.items, targetId)) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
return checkChildren(parent.items, child.id);
|
||
};
|
||
|
||
const SidebarResizer = styled("div")(({ theme }) => ({
|
||
width: "3px",
|
||
cursor: "col-resize",
|
||
backgroundColor: alpha(theme.palette.primary.main, 0.3),
|
||
"&:hover": {
|
||
backgroundColor: theme.palette.primary.main,
|
||
},
|
||
height: "100%",
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
zIndex: 1000,
|
||
transition: "background-color 0.2s ease",
|
||
}));
|
||
|
||
const DropIndicator = ({ position, targetId }) => {
|
||
if (!targetId) return null;
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
height: '2px',
|
||
backgroundColor: 'primary.main',
|
||
zIndex: 1001,
|
||
...(position === 'above' && { top: 0 }),
|
||
...(position === 'below' && { bottom: 0 }),
|
||
'&::before': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
top: '-3px',
|
||
left: '10%',
|
||
width: '80%',
|
||
height: '8px',
|
||
backgroundColor: 'primary.main',
|
||
borderRadius: '2px',
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
position: "relative",
|
||
width: collapsed ? 72 : sidebarWidth,
|
||
transition: "width 0.2s ease",
|
||
height: "100vh",
|
||
}}
|
||
>
|
||
<Drawer
|
||
variant="permanent"
|
||
sx={{
|
||
width: collapsed ? 72 : sidebarWidth,
|
||
flexShrink: 0,
|
||
"& .MuiDrawer-paper": {
|
||
width: collapsed ? 72 : sidebarWidth,
|
||
boxSizing: "border-box",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
backgroundColor: "background.paper",
|
||
color: "text.primary",
|
||
transition: "width 0.2s ease, background-color 0.2s ease",
|
||
overflowX: "hidden",
|
||
borderRight: "1px solid",
|
||
borderColor: "divider",
|
||
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
|
||
},
|
||
}}
|
||
>
|
||
{/* Заголовок с логотипом */}
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
p: 2,
|
||
borderBottom: "1px solid",
|
||
borderColor: "divider",
|
||
backgroundColor: "background.paper",
|
||
height: 80,
|
||
position: "relative",
|
||
transition: "all 0.2s ease",
|
||
minHeight: 80,
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: "100%",
|
||
height: "100%",
|
||
transition: "all 0.2s ease",
|
||
"& svg": {
|
||
width: "auto",
|
||
height: "40px", // Фиксированная высота для лого
|
||
objectFit: "contain",
|
||
transition: "all 0.2s ease",
|
||
},
|
||
}}
|
||
>
|
||
{collapsed ? (
|
||
<LogoSmall style={{
|
||
color: "inherit",
|
||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||
width: "32px",
|
||
height: "32px"
|
||
}} />
|
||
) : (
|
||
<LogoFull style={{
|
||
color: "inherit",
|
||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||
maxWidth: "180px",
|
||
height: "40px"
|
||
}} />
|
||
)}
|
||
</Box>
|
||
|
||
<Tooltip
|
||
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||
placement="right"
|
||
>
|
||
<IconButton
|
||
onClick={handleToggleCollapse}
|
||
size="small"
|
||
sx={{
|
||
color: "text.secondary",
|
||
"&:hover": {
|
||
backgroundColor: "action.hover",
|
||
color: "text.primary"
|
||
},
|
||
position: "absolute",
|
||
right: 12,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
transition: "all 0.2s ease",
|
||
width: 32,
|
||
height: 32,
|
||
}}
|
||
>
|
||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
|
||
{/* Основное содержимое меню */}
|
||
<Box
|
||
sx={{
|
||
flexGrow: 1,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragStart={handleDragStart}
|
||
onDragEnd={handleDragEnd}
|
||
onDragOver={handleDragOver}
|
||
measuring={{
|
||
droppable: {
|
||
strategy: MeasuringStrategy.Always
|
||
}
|
||
}}
|
||
>
|
||
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||
<List
|
||
sx={{
|
||
overflowY: "auto",
|
||
flex: "1 1 auto",
|
||
py: 1,
|
||
px: 1,
|
||
position: 'relative',
|
||
'&::-webkit-scrollbar': {
|
||
width: '6px',
|
||
},
|
||
'&::-webkit-scrollbar-track': {
|
||
background: 'transparent',
|
||
},
|
||
'&::-webkit-scrollbar-thumb': {
|
||
background: 'text.disabled',
|
||
borderRadius: '3px',
|
||
},
|
||
'&::-webkit-scrollbar-thumb:hover': {
|
||
background: 'text.secondary',
|
||
},
|
||
}}
|
||
>
|
||
{menuItems.map((item) => (
|
||
<Box key={item.id} position="relative">
|
||
{dropIndicator.show && dropIndicator.targetId === item.id &&
|
||
dropIndicator.position !== 'inside' && (
|
||
<DropIndicator
|
||
position={dropIndicator.position}
|
||
targetId={dropIndicator.targetId}
|
||
/>
|
||
)}
|
||
<SortableMenuItem
|
||
item={item}
|
||
collapsed={collapsed}
|
||
onSelectItem={onSelectItem}
|
||
isHovered={hoveredItem === item.id}
|
||
showDropIndicator={dropIndicator.show && dropIndicator.targetId === item.id && dropIndicator.position === 'inside'}
|
||
sidebarWidth={sidebarWidth}
|
||
/>
|
||
</Box>
|
||
))}
|
||
</List>
|
||
</SortableContext>
|
||
|
||
<DragOverlay>
|
||
{activeItem ? (
|
||
<Box
|
||
sx={{
|
||
backgroundColor: 'primary.main',
|
||
color: 'white',
|
||
padding: '8px 12px',
|
||
borderRadius: '8px',
|
||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||
maxWidth: 250,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
fontSize: '0.875rem',
|
||
fontWeight: 500,
|
||
backdropFilter: 'blur(10px)',
|
||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||
transform: 'rotate(5deg)',
|
||
}}
|
||
>
|
||
{activeItem.title}
|
||
</Box>
|
||
) : null}
|
||
</DragOverlay>
|
||
</DndContext>
|
||
|
||
<SidebarFooter
|
||
collapsed={collapsed}
|
||
isDarkMode={isDarkMode}
|
||
setIsDarkMode={setIsDarkMode}
|
||
forceRefreshMenu={forceRefreshMenu}
|
||
user={user}
|
||
/>
|
||
</Box>
|
||
|
||
{!collapsed && (
|
||
<Tooltip title="Изменить ширину" placement="top">
|
||
<SidebarResizer onMouseDown={startResizing} />
|
||
</Tooltip>
|
||
)}
|
||
</Drawer>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default SidebarMenu; |