trust-module-frontend/src/Components/Layout/SidebarMenu.jsx

538 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;