sidebar redesign

pull/59/head
DmitriyA 2025-09-01 09:20:23 -04:00
parent 933ceb2547
commit 06249fce3a
4 changed files with 589 additions and 253 deletions

View File

@ -6,6 +6,7 @@ import {
IconButton,
Tooltip,
Box,
alpha
} from "@mui/material";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import useSidebarResize from "../hooks/useSidebarResize";
@ -20,7 +21,8 @@ import {
PointerSensor,
useSensor,
useSensors,
DragOverlay
DragOverlay,
MeasuringStrategy
} from "@dnd-kit/core";
import {
SortableContext,
@ -38,13 +40,15 @@ const SidebarMenu = ({
user,
}) => {
const [collapsed, setCollapsed] = useState(false);
const { sidebarWidth, startResizing } = useSidebarResize(290);
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: 8,
distance: 4,
},
}));
@ -61,9 +65,12 @@ const SidebarMenu = ({
}
}, [data]);
const handleToggleCollapse = () => setCollapsed(!collapsed);
const handleToggleCollapse = () => {
setCollapsed(!collapsed);
setHoveredItem(null);
};
// Функция для поиска элемента по ID во всем дереве
// Функции для работы с деревом (остаются без изменений)
const findItemInTree = (items, id) => {
for (const item of items) {
if (item.id === id) return item;
@ -75,7 +82,6 @@ const SidebarMenu = ({
return null;
};
// Функция для удаления элемента из дерева
const removeItemFromTree = (items, id) => {
return items.filter(item => {
if (item.id === id) return false;
@ -86,7 +92,6 @@ const SidebarMenu = ({
});
};
// Функция для добавления элемента в конкретную папку
const addItemToFolder = (items, folderId, newItem) => {
return items.map(item => {
if (item.id === folderId) {
@ -105,7 +110,6 @@ const SidebarMenu = ({
});
};
// Функция для поиска родителя элемента
const findParent = (items, childId, parent = null) => {
for (const item of items) {
if (item.id === childId) return parent;
@ -117,7 +121,6 @@ const SidebarMenu = ({
return null;
};
// Функция для добавления элемента на тот же уровень
const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => {
return items.map(item => {
if (item.id === parentId) {
@ -143,11 +146,13 @@ const SidebarMenu = ({
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;
@ -155,69 +160,190 @@ const SidebarMenu = ({
const draggedItem = findItemInTree(menuItems, active.id);
if (!draggedItem) return;
// Определяем тип целевого элемента (папка или элемент)
const overItem = findItemInTree(menuItems, over.id);
const isOverFolder = overItem && Array.isArray(overItem.items);
// Проверяем, не пытаемся ли переместить элемент в его же потомка
if (isDescendant(draggedItem, overItem)) {
return;
}
let newTree;
if (isOverFolder) {
// Перемещаем в папку
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);
// Добавляем на новый уровень
newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, over.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: "4px",
cursor: "ew-resize",
backgroundColor: "transparent",
width: "3px",
cursor: "col-resize",
backgroundColor: alpha(theme.palette.primary.main, 0.3),
"&:hover": {
backgroundColor: theme.palette.action.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 ? 64 : sidebarWidth,
transition: "width 0.3s ease",
width: collapsed ? 72 : sidebarWidth,
transition: "width 0.2s ease",
height: "100vh",
}}
>
<Drawer
variant="permanent"
sx={{
width: collapsed ? 64 : sidebarWidth,
width: collapsed ? 72 : sidebarWidth,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: collapsed ? 64 : sidebarWidth,
width: collapsed ? 72 : sidebarWidth,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
backgroundColor: "custom.sidebar",
color: "custom.sidebarText",
transition: "width 0.3s ease",
backgroundColor: "background.paper",
color: "text.primary",
transition: "width 0.2s ease, background-color 0.2s ease",
overflowX: "hidden",
borderRight: "none",
borderRight: "1px solid",
borderColor: "divider",
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
},
}}
>
@ -227,12 +353,14 @@ const SidebarMenu = ({
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 1,
p: 2,
borderBottom: "1px solid",
borderColor: "divider",
backgroundColor: "custom.sidebar",
backgroundColor: "background.paper",
height: 80,
position: "relative",
transition: "all 0.2s ease",
minHeight: 80,
}}
>
<Box
@ -242,32 +370,52 @@ const SidebarMenu = ({
justifyContent: "center",
width: "100%",
height: "100%",
transition: "all 0.2s ease",
"& svg": {
width: "100%",
height: "100%",
padding: collapsed ? "8px" : "12px",
width: "auto",
height: "40px", // Фиксированная высота для лого
objectFit: "contain",
transition: "all 0.2s ease",
},
}}
>
{collapsed ? (
<LogoSmall style={{ color: "inherit" }} />
<LogoSmall style={{
color: "inherit",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
width: "32px",
height: "32px"
}} />
) : (
<LogoFull style={{ color: "inherit" }} />
<LogoFull style={{
color: "inherit",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
maxWidth: "180px",
height: "40px"
}} />
)}
</Box>
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
<Tooltip
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
placement="right"
>
<IconButton
onClick={handleToggleCollapse}
size="small"
sx={{
color: "custom.sidebarText",
"&:hover": { backgroundColor: "custom.sidebarHover" },
color: "text.secondary",
"&:hover": {
backgroundColor: "action.hover",
color: "text.primary"
},
position: "absolute",
right: 8,
right: 12,
top: "50%",
transform: "translateY(-50%)",
transition: "all 0.2s ease",
width: 32,
height: 32,
}}
>
{collapsed ? <ChevronRight /> : <ChevronLeft />}
@ -277,23 +425,67 @@ const SidebarMenu = ({
{/* Основное содержимое меню */}
<Box
sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
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" }}>
<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) => (
<SortableMenuItem
key={item.id}
item={item}
collapsed={collapsed}
onSelectItem={onSelectItem}
/>
<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>
@ -304,13 +496,18 @@ const SidebarMenu = ({
sx={{
backgroundColor: 'primary.main',
color: 'white',
padding: 1,
borderRadius: 1,
boxShadow: 3,
maxWidth: 200,
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}
@ -328,7 +525,11 @@ const SidebarMenu = ({
/>
</Box>
{!collapsed && <SidebarResizer onMouseDown={startResizing} />}
{!collapsed && (
<Tooltip title="Изменить ширину" placement="top">
<SidebarResizer onMouseDown={startResizing} />
</Tooltip>
)}
</Drawer>
</Box>
);

View File

@ -1,121 +1,121 @@
// MenuItem.jsx
import React, { useState } from "react";
import {
ListItem,
ListItemIcon,
ListItemText,
Collapse,
List,
styled,
Menu,
MenuItem as MuiMenuItem
} from "@mui/material";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
import StatusIndicator from "./StatusIndicator";
// // MenuItem.jsx
// import React, { useState } from "react";
// import {
// ListItem,
// ListItemIcon,
// ListItemText,
// Collapse,
// List,
// styled,
// Menu,
// MenuItem as MuiMenuItem
// } from "@mui/material";
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
// import StatusIndicator from "./StatusIndicator";
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer",
paddingLeft: theme.spacing(2 + level * 2),
position: 'relative',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
'&.Mui-selected': {
backgroundColor: theme.palette.custom.sidebarHover,
},
}));
// const StyledListItem = styled(ListItem)(({ theme, level }) => ({
// cursor: "pointer",
// paddingLeft: theme.spacing(2 + level * 2),
// position: 'relative',
// '&:hover': {
// backgroundColor: theme.palette.action.hover,
// },
// '&.Mui-selected': {
// backgroundColor: theme.palette.custom.sidebarHover,
// },
// }));
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
const [isOpen, setIsOpen] = useState(false);
const [contextMenu, setContextMenu] = useState(null);
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
// const [isOpen, setIsOpen] = useState(false);
// const [contextMenu, setContextMenu] = useState(null);
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const handleContextMenu = (e) => {
e.preventDefault();
setContextMenu(
contextMenu === null
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
: null
);
};
// const handleContextMenu = (e) => {
// e.preventDefault();
// setContextMenu(
// contextMenu === null
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
// : null
// );
// };
const handleCloseContextMenu = () => {
setContextMenu(null);
};
// const handleCloseContextMenu = () => {
// setContextMenu(null);
// };
const handleToggle = (e) => {
e.stopPropagation();
setIsOpen(!isOpen);
};
// const handleToggle = (e) => {
// e.stopPropagation();
// setIsOpen(!isOpen);
// };
const handleClick = () => {
if (onSelectItem) {
onSelectItem(item);
}
};
// const handleClick = () => {
// if (onSelectItem) {
// onSelectItem(item);
// }
// };
return (
<>
<StyledListItem
component="div"
onClick={hasChildren ? handleToggle : handleClick}
onContextMenu={handleContextMenu}
level={level}
sx={{
pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{!collapsed && <StatusIndicator status={item.status} />}
// return (
// <>
// <StyledListItem
// component="div"
// onClick={hasChildren ? handleToggle : handleClick}
// onContextMenu={handleContextMenu}
// level={level}
// sx={{
// pl: collapsed ? 2 : 2 + level * 2,
// justifyContent: collapsed ? 'center' : 'flex-start',
// }}
// >
// {!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</ListItemIcon>
// <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
// </ListItemIcon>
{!collapsed && (
<>
<ListItemText
primary={item.title}
primaryTypographyProps={{
color: 'custom.sidebarText'
}}
/>
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
</>
)}
</StyledListItem>
// {!collapsed && (
// <>
// <ListItemText
// primary={item.title}
// primaryTypographyProps={{
// color: 'custom.sidebarText'
// }}
// />
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
// </>
// )}
// </StyledListItem>
<Menu
open={contextMenu !== null}
onClose={handleCloseContextMenu}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
// <Menu
// open={contextMenu !== null}
// onClose={handleCloseContextMenu}
// anchorReference="anchorPosition"
// anchorPosition={
// contextMenu !== null
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
// : undefined
// }
// >
</Menu>
// </Menu>
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child, index) => (
<MenuItem
key={child.id ?? index}
item={child}
onSelectItem={onSelectItem}
onEdit={onEdit}
level={level + 1}
collapsed={collapsed}
/>
))}
</List>
</Collapse>
)}
</>
);
};
// {hasChildren && !collapsed && (
// <Collapse in={isOpen} timeout="auto" unmountOnExit>
// <List component="div" disablePadding>
// {item.items.map((child, index) => (
// <MenuItem
// key={child.id ?? index}
// item={child}
// onSelectItem={onSelectItem}
// onEdit={onEdit}
// level={level + 1}
// collapsed={collapsed}
// />
// ))}
// </List>
// </Collapse>
// )}
// </>
// );
// };
export default MenuItem;
// export default MenuItem;

View File

@ -1,20 +1,24 @@
import React, { useState } from "react";
import { Brightness4, Brightness7 } from "@mui/icons-material";
import { IconButton, Tooltip } from "@mui/material";
import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
import {
IconButton,
Tooltip,
Box,
Button,
alpha
} from "@mui/material";
import {
List,
ListItem,
ListItemText,
styled,
Switch,
Box,
Button
} from "@mui/material";
import SettingsModal from "../SettingsModal";
import { RoleBasedRender } from "../../UI/RoleBasedRender";
const FooterList = styled(List)(({ theme }) => ({
backgroundColor: theme.palette.custom.sidebar,
backgroundColor: 'background.paper',
padding: theme.spacing(1, 0),
borderTop: `1px solid ${theme.palette.divider}`,
marginTop: 'auto'
@ -22,12 +26,15 @@ const FooterList = styled(List)(({ theme }) => ({
const FooterListItem = styled(ListItem)(({ theme }) => ({
'&:hover': {
backgroundColor: theme.palette.custom.sidebarHover,
backgroundColor: alpha(theme.palette.action.hover, 0.4),
},
padding: theme.spacing(1, 2),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
borderRadius: '8px',
margin: '0 8px 4px',
transition: 'all 0.2s ease',
}));
const SidebarFooter = ({
@ -46,72 +53,93 @@ const SidebarFooter = ({
const handleSettingsClose = () => {
setSettingsOpen(false);
};
/*console.log('SidebarFooter user with role:', {
...user,
hasRole: 'role' in user,
roleValue: user?.role
}); */
return (
<>
<FooterList>
{!collapsed && (
<FooterListItem button>
<ListItemText
primary="Помощь"
primaryTypographyProps={{
color: 'custom.sidebarText',
variant: 'body2'
}}
/>
</FooterListItem>
)}
<FooterListItem>
{/* кнопка настроек */}
<RoleBasedRender user={user} allowedRoles={['admin']}>
{!collapsed && (
{!collapsed ? (
<>
<FooterListItem>
<Button
onClick={handleSettingsOpen}
startIcon={<Settings />}
sx={{
color: 'custom.sidebarText',
color: 'text.secondary',
textTransform: 'none',
minWidth: 0,
padding: 0,
marginRight: 'auto'
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
}
}}
>
<ListItemText
primary="Настройки"
primaryTypographyProps={{
color: 'custom.sidebarText',
variant: 'body2'
}}
/>
Настройки
</Button>
)}
</RoleBasedRender>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="Переключить тему">
<IconButton
size="small"
onClick={() => setIsDarkMode(!isDarkMode)}
sx={{ color: 'custom.sidebarText' }}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="Переключить тему">
<IconButton
size="small"
onClick={() => setIsDarkMode(!isDarkMode)}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: alpha('#000000', 0.1)
}
}}
>
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Tooltip>
<Switch
checked={isDarkMode}
onChange={() => setIsDarkMode(!isDarkMode)}
size="small"
color="primary"
/>
</Box>
</FooterListItem>
<FooterListItem button>
<Button
startIcon={<Help />}
sx={{
color: 'text.secondary',
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
}
}}
>
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
Помощь
</Button>
</FooterListItem>
</>
) : (
<FooterListItem sx={{ justifyContent: 'center' }}>
<Tooltip title="Настройки" placement="right">
<IconButton
onClick={handleSettingsOpen}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: alpha('#000000', 0.1)
}
}}
>
<Settings />
</IconButton>
</Tooltip>
{!collapsed && (
<Switch
checked={isDarkMode}
onChange={() => setIsDarkMode(!isDarkMode)}
size="small"
/>
)}
</Box>
</FooterListItem>
</FooterListItem>
)}
</FooterList>
{/* Используем RoleBasedRender для модального окна */}
<RoleBasedRender user={user} allowedRoles={['admin']}>
<SettingsModal
open={settingsOpen}

View File

@ -6,14 +6,26 @@ import {
Collapse,
List,
IconButton,
Box
Box,
alpha,
Typography,
Tooltip
} from "@mui/material";
import { Folder, FolderOpen, ExpandLess, ExpandMore, DragIndicator } from "@mui/icons-material";
import { ChevronRight, DragIndicator, Folder, FolderOpen } from "@mui/icons-material";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => {
const SortableMenuItem = ({
item,
collapsed,
onSelectItem,
level = 0,
isHovered = false,
showDropIndicator = false,
sidebarWidth = 300
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLocalHovered, setIsLocalHovered] = useState(false);
const {
attributes,
@ -21,22 +33,34 @@ const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => {
setNodeRef,
transform,
transition,
isDragging
isDragging,
isOver
} = useSortable({
id: item.id,
data: {
type: 'menu-item',
item
item,
level
}
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
transition: transition || 'all 0.2s ease',
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 1000 : 1,
};
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const isFolder = hasChildren;
const isHighlighted = isHovered || isOver;
// Рассчитываем максимальную ширину текста в зависимости от уровня вложенности
const calculateMaxTextWidth = () => {
const baseWidth = sidebarWidth - 40; // Отступы и иконки
const levelOffset = level * 24; // Отступ для каждого уровня
return baseWidth - levelOffset - 60; // Оставляем место для иконок и отступов
};
const handleClick = (e) => {
e.stopPropagation();
@ -47,63 +71,146 @@ const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => {
}
};
const handleMouseEnter = () => {
setIsLocalHovered(true);
};
const handleMouseLeave = () => {
setIsLocalHovered(false);
};
const getBackgroundColor = (theme) => {
if (isDragging) return alpha(theme.palette.primary.main, 0.1);
if (isHighlighted) return alpha(theme.palette.primary.main, 0.08);
if (isLocalHovered) return alpha(theme.palette.action.hover, 0.4);
return 'transparent';
};
return (
<Box ref={setNodeRef} style={style}>
<Box
ref={setNodeRef}
style={style}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
sx={{
position: 'relative',
'&::before': isHighlighted ? {
content: '""',
position: 'absolute',
left: 0,
top: 4,
bottom: 4,
width: 3,
backgroundColor: 'primary.main',
borderRadius: '0 2px 2px 0',
} : {},
...(showDropIndicator && {
backgroundColor: (theme) => alpha(theme.palette.primary.main, 0.1),
border: (theme) => `2px dashed ${theme.palette.primary.main}`,
borderRadius: '8px',
})
}}
>
<ListItem
button
sx={{
pl: collapsed ? 2 : 2 + level * 2,
pl: collapsed ? 1 : Math.max(0.1, 0.1 + level * 0.1),
pr: 0.5,
py: 0.25,
minHeight: 32,
justifyContent: collapsed ? "center" : "flex-start",
backgroundColor: isDragging ? 'action.hover' : 'transparent',
position: 'relative',
backgroundColor: (theme) => getBackgroundColor(theme),
borderRadius: '6px',
margin: '1px 4px',
transition: 'all 0.2s ease',
}}
onClick={handleClick}
>
{!collapsed && (
<IconButton
{...attributes}
{...listeners}
size="small"
sx={{
cursor: "grab",
cursor: isDragging ? "grabbing" : "grab",
mr: 1,
'&:active': { cursor: 'grabbing' }
opacity: isLocalHovered || isDragging ? 1 : 0.4,
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
},
flexShrink: 0
}}
>
<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 />)}
<Tooltip title={item.title} placement="right" enterDelay={400} arrow>
<ListItemText
primary={
<Typography
variant="body2"
sx={{
fontWeight: isFolder ? 600 : 400,
color: isFolder ? 'text.primary' : 'text.secondary',
maxWidth: calculateMaxTextWidth(),
display: "-webkit-box",
WebkitLineClamp: 2, // максимум 2 строки
WebkitBoxOrient: "vertical",
overflow: "hidden",
lineHeight: 1.2,
fontSize: "0.85rem", // компактнее текст
}}
>
{item.title}
</Typography>
}
sx={{ mr: 0.5, flex: '1 1 auto', minWidth: 0 }}
/>
</Tooltip>
{hasChildren && (
<ChevronRight
sx={{
fontSize: 18,
color: 'text.disabled',
transform: isOpen ? 'rotate(90deg)' : 'none',
transition: 'transform 0.2s ease',
flexShrink: 0,
}}
/>
)}
</>
)}
</ListItem>
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List disablePadding>
<List
disablePadding
sx={{
pl: 1.5,
borderLeft: (theme) => `1px solid ${alpha(theme.palette.divider, 0.1)}`,
marginLeft: 2,
position: 'relative',
}}
>
{item.items.map((child) => (
<SortableMenuItem
key={child.id}
item={child}
collapsed={collapsed}
onSelectItem={onSelectItem}
level={level + 1}
/>
<Box key={child.id} position="relative">
<SortableMenuItem
item={child}
collapsed={collapsed}
onSelectItem={onSelectItem}
level={level + 1}
isHovered={isHovered}
showDropIndicator={showDropIndicator}
sidebarWidth={sidebarWidth}
/>
</Box>
))}
</List>
</Collapse>