496 lines
13 KiB
JavaScript
496 lines
13 KiB
JavaScript
"use client";
|
|
|
|
import {
|
|
Box,
|
|
Drawer,
|
|
DrawerBody,
|
|
DrawerCloseButton,
|
|
DrawerContent,
|
|
DrawerHeader,
|
|
DrawerOverlay,
|
|
HStack,
|
|
Icon,
|
|
IconButton,
|
|
Link,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
Tooltip,
|
|
VStack,
|
|
useDisclosure,
|
|
} from "@chakra-ui/react";
|
|
import { Children, forwardRef, isValidElement, useRef } from "react";
|
|
import { FiMoreHorizontal } from "react-icons/fi/index.js";
|
|
import { IoExit } from "react-icons/io5/index.js";
|
|
import { logoutKeycloak } from "../core/keycloak.jsx";
|
|
import { usePermission } from "../core/permission.jsx";
|
|
|
|
const normalizeSidebarPath = (value = "") => {
|
|
const path = String(value).split(/[?#]/)[0] || "";
|
|
|
|
if (!path || path === "/") return path || "/";
|
|
|
|
return path.replace(/\/+$/, "");
|
|
};
|
|
|
|
export const isSidebarPathActive = (currentPath, item) => {
|
|
const normalizedPath = normalizeSidebarPath(currentPath);
|
|
const candidatePaths = [item?.path, ...(item?.aliases || [])]
|
|
.filter(Boolean)
|
|
.map(normalizeSidebarPath);
|
|
|
|
return candidatePaths.some(
|
|
(path) => normalizedPath === path || normalizedPath.startsWith(`${path}/`),
|
|
);
|
|
};
|
|
|
|
export const getActiveSidebarItem = (items = [], currentPath) =>
|
|
items.find((item) => isSidebarPathActive(currentPath, item));
|
|
|
|
const SidebarLink = forwardRef(
|
|
({ linkComponent, href, children, ...props }, ref) => {
|
|
const linkProps = linkComponent ? { as: linkComponent } : {};
|
|
|
|
return (
|
|
<Link ref={ref} href={href} {...linkProps} {...props}>
|
|
{children}
|
|
</Link>
|
|
);
|
|
},
|
|
);
|
|
|
|
SidebarLink.displayName = "SidebarLink";
|
|
|
|
const DesktopNavItem = ({
|
|
item,
|
|
isActive,
|
|
isDisabled,
|
|
linkComponent,
|
|
onNavigate,
|
|
}) => {
|
|
const commonProps = {
|
|
variant: "ghost",
|
|
position: "relative",
|
|
bg: "transparent",
|
|
|
|
_before: isActive
|
|
? {
|
|
content: '""',
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
insetInlineStart: 0,
|
|
insetInlineEnd: 0,
|
|
bg: "gray.200",
|
|
borderRadius: "md",
|
|
zIndex: 0,
|
|
}
|
|
: undefined,
|
|
|
|
sx: {
|
|
"> svg": {
|
|
position: "relative",
|
|
zIndex: 1,
|
|
},
|
|
},
|
|
|
|
icon: <Icon as={item.icon} />,
|
|
fontSize: "xl",
|
|
size: { base: "sm", lg: "md" },
|
|
"aria-label": item.title,
|
|
"aria-current": isActive ? "page" : undefined,
|
|
};
|
|
|
|
let content;
|
|
|
|
if (isDisabled || isActive) {
|
|
content = (
|
|
<IconButton
|
|
{...commonProps}
|
|
isDisabled={isDisabled}
|
|
cursor={isActive && !isDisabled ? "default" : undefined}
|
|
/>
|
|
);
|
|
} else {
|
|
const linkProps = linkComponent
|
|
? { as: linkComponent, href: item.path }
|
|
: { as: "a", href: item.path };
|
|
|
|
content = (
|
|
<IconButton {...commonProps} {...linkProps} onClick={onNavigate} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Tooltip
|
|
label={isDisabled ? "عدم دسترسی" : item.title}
|
|
placement="left-start"
|
|
hasArrow
|
|
rounded="md"
|
|
py={2}
|
|
px={4}
|
|
fontWeight="bold"
|
|
closeDelay={150}
|
|
>
|
|
<Box as="span">{content}</Box>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const MobileNavigationButton = ({
|
|
item,
|
|
isActive,
|
|
isDisabled,
|
|
linkComponent,
|
|
onNavigate,
|
|
}) => {
|
|
const content = (
|
|
<VStack
|
|
as="span"
|
|
spacing={0.5}
|
|
w="full"
|
|
minW={0}
|
|
py={1}
|
|
px={1}
|
|
borderRadius="xl"
|
|
color={isActive ? "blue.600" : "gray.600"}
|
|
bg={isActive ? "blue.50" : "transparent"}
|
|
opacity={isDisabled ? 0.4 : 1}
|
|
transition="background-color 150ms ease, color 150ms ease"
|
|
>
|
|
<Icon as={item.icon} boxSize={5} />
|
|
<Text
|
|
as="span"
|
|
w="full"
|
|
fontSize="10px"
|
|
fontWeight={isActive ? "bold" : "medium"}
|
|
lineHeight="short"
|
|
textAlign="center"
|
|
noOfLines={1}
|
|
>
|
|
{item.mobileTitle || item.title}
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
|
|
if (isDisabled || isActive) {
|
|
return (
|
|
<Box
|
|
flex="1"
|
|
minW={0}
|
|
display="flex"
|
|
alignItems="center"
|
|
aria-disabled={isDisabled ? "true" : undefined}
|
|
aria-current={isActive ? "page" : undefined}
|
|
>
|
|
{content}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SidebarLink
|
|
linkComponent={linkComponent}
|
|
href={item.path}
|
|
flex="1"
|
|
minW={0}
|
|
display="flex"
|
|
alignItems="center"
|
|
_hover={{ textDecoration: "none" }}
|
|
onClick={onNavigate}
|
|
aria-current={isActive ? "page" : undefined}
|
|
>
|
|
{content}
|
|
</SidebarLink>
|
|
);
|
|
};
|
|
|
|
const SidebarExit = () => null;
|
|
|
|
const SidebarRoot = ({
|
|
items = [],
|
|
currentPath,
|
|
linkComponent,
|
|
onNavigate,
|
|
children,
|
|
primaryMobileCount = 4,
|
|
moreTitle = "بیشتر",
|
|
moreDrawerTitle = "سایر بخشهای سامانه",
|
|
desktopProps,
|
|
mobileProps,
|
|
}) => {
|
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
const moreButtonRef = useRef();
|
|
const { cannot } = usePermission();
|
|
|
|
const exitElement = Children.toArray(children).find(
|
|
(child) => isValidElement(child) && child.type === SidebarExit,
|
|
);
|
|
const hasExit = Boolean(exitElement);
|
|
const logoutTitle = exitElement?.props?.title || "خروج";
|
|
const logoutIcon = exitElement?.props?.icon || IoExit;
|
|
|
|
const resolvedCurrentPath =
|
|
currentPath ??
|
|
(typeof window !== "undefined"
|
|
? `${window.location.pathname}${window.location.search}`
|
|
: "");
|
|
|
|
const primaryItems = items.slice(0, primaryMobileCount);
|
|
const secondaryItems = items.slice(primaryMobileCount);
|
|
|
|
const disabled = (item) => {
|
|
const permissions = Array.isArray(item?.perm) ? item.perm : [item?.perm];
|
|
|
|
return permissions.filter(Boolean).some((permission) => cannot(permission));
|
|
};
|
|
const active = (item) => isSidebarPathActive(resolvedCurrentPath, item);
|
|
|
|
const isSecondaryItemActive = secondaryItems.some(active);
|
|
const hasMoreContent = secondaryItems.length > 0 || hasExit;
|
|
|
|
const handleNavigate = (item) => {
|
|
onNavigate?.(item);
|
|
onClose();
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
onClose();
|
|
logoutKeycloak();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Stack
|
|
py={6}
|
|
as="nav"
|
|
flexShrink={0}
|
|
justify="space-between"
|
|
h="full"
|
|
borderLeft="1px"
|
|
borderColor="gray.200"
|
|
overflowY="scroll"
|
|
sx={{
|
|
"::-webkit-scrollbar": { display: "none" },
|
|
scrollbarWidth: "none",
|
|
msOverflowStyle: "none",
|
|
}}
|
|
display={{ base: "none", lg: "flex" }}
|
|
{...desktopProps}
|
|
>
|
|
<VStack spacing={2}>
|
|
{items.map((item) => (
|
|
<DesktopNavItem
|
|
key={item.path}
|
|
item={item}
|
|
isDisabled={disabled(item)}
|
|
isActive={active(item)}
|
|
linkComponent={linkComponent}
|
|
onNavigate={() => onNavigate?.(item)}
|
|
/>
|
|
))}
|
|
|
|
{hasExit && (
|
|
<Tooltip
|
|
label={logoutTitle}
|
|
placement="left-start"
|
|
hasArrow
|
|
rounded="md"
|
|
py={2}
|
|
px={4}
|
|
fontWeight="bold"
|
|
closeDelay={150}
|
|
>
|
|
<IconButton
|
|
variant="ghost"
|
|
icon={<Icon as={logoutIcon} />}
|
|
fontSize="xl"
|
|
size={{ base: "sm", lg: "lg" }}
|
|
aria-label={logoutTitle}
|
|
onClick={handleLogout}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
</VStack>
|
|
</Stack>
|
|
|
|
<Box
|
|
as="nav"
|
|
aria-label="ناوبری اصلی موبایل"
|
|
display={{ base: "block", lg: "none" }}
|
|
position="fixed"
|
|
insetInline={0}
|
|
bottom={0}
|
|
zIndex={1200}
|
|
bg="white"
|
|
borderTopWidth="1px"
|
|
borderColor="gray.200"
|
|
boxShadow="0 -6px 18px rgba(0, 0, 0, 0.08)"
|
|
pb="env(safe-area-inset-bottom)"
|
|
{...mobileProps}
|
|
>
|
|
<HStack h="56px" px={1.5} spacing={1} align="stretch">
|
|
{primaryItems.map((item) => (
|
|
<MobileNavigationButton
|
|
key={item.path}
|
|
item={item}
|
|
isDisabled={disabled(item)}
|
|
isActive={active(item)}
|
|
linkComponent={linkComponent}
|
|
onNavigate={() => handleNavigate(item)}
|
|
/>
|
|
))}
|
|
|
|
{hasMoreContent && (
|
|
<Box
|
|
as="button"
|
|
ref={moreButtonRef}
|
|
type="button"
|
|
flex="1"
|
|
minW={0}
|
|
h="full"
|
|
p={0}
|
|
border={0}
|
|
bg="transparent"
|
|
font="inherit"
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
cursor="pointer"
|
|
onClick={onOpen}
|
|
aria-label="نمایش سایر بخشها"
|
|
>
|
|
<VStack
|
|
spacing={0.5}
|
|
w="full"
|
|
py={1}
|
|
px={1}
|
|
borderRadius="xl"
|
|
color={isSecondaryItemActive ? "blue.600" : "gray.600"}
|
|
bg={isSecondaryItemActive ? "blue.50" : "transparent"}
|
|
>
|
|
<Icon as={FiMoreHorizontal} boxSize={5} />
|
|
<Text
|
|
fontSize="10px"
|
|
fontWeight={isSecondaryItemActive ? "bold" : "medium"}
|
|
lineHeight="short"
|
|
>
|
|
{moreTitle}
|
|
</Text>
|
|
</VStack>
|
|
</Box>
|
|
)}
|
|
</HStack>
|
|
</Box>
|
|
|
|
{hasMoreContent && (
|
|
<Drawer
|
|
isOpen={isOpen}
|
|
placement="bottom"
|
|
onClose={onClose}
|
|
finalFocusRef={moreButtonRef}
|
|
>
|
|
<DrawerOverlay />
|
|
<DrawerContent
|
|
borderTopRadius="2xl"
|
|
maxH="min(78dvh, 620px)"
|
|
pb="env(safe-area-inset-bottom)"
|
|
>
|
|
<DrawerCloseButton insetInlineEnd={3} insetInlineStart="auto" />
|
|
<DrawerHeader borderBottomWidth="1px" fontSize="md">
|
|
{moreDrawerTitle}
|
|
</DrawerHeader>
|
|
|
|
<DrawerBody py={4} overflowY="auto">
|
|
<SimpleGrid columns={4} spacing={2}>
|
|
{secondaryItems.map((item) => {
|
|
const isDisabled = disabled(item);
|
|
const isActive = active(item);
|
|
|
|
const content = (
|
|
<VStack
|
|
h="70px"
|
|
minW={0}
|
|
justify="center"
|
|
spacing={1}
|
|
px={1}
|
|
borderWidth="1px"
|
|
borderColor={isActive ? "blue.300" : "gray.200"}
|
|
borderRadius="lg"
|
|
bg={isActive ? "blue.50" : "white"}
|
|
color={isActive ? "blue.700" : "gray.700"}
|
|
opacity={isDisabled ? 0.4 : 1}
|
|
>
|
|
<Icon as={item.icon} boxSize={5} flexShrink={0} />
|
|
<Text
|
|
w="full"
|
|
fontSize="10px"
|
|
lineHeight="shorter"
|
|
fontWeight={isActive ? "bold" : "medium"}
|
|
textAlign="center"
|
|
noOfLines={2}
|
|
>
|
|
{item.mobileTitle || item.title}
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
|
|
if (isDisabled || isActive) {
|
|
return (
|
|
<Box
|
|
key={item.path}
|
|
aria-disabled={isDisabled ? "true" : undefined}
|
|
aria-current={isActive ? "page" : undefined}
|
|
>
|
|
{content}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SidebarLink
|
|
key={item.path}
|
|
linkComponent={linkComponent}
|
|
href={item.path}
|
|
_hover={{ textDecoration: "none" }}
|
|
onClick={() => handleNavigate(item)}
|
|
aria-current={isActive ? "page" : undefined}
|
|
>
|
|
{content}
|
|
</SidebarLink>
|
|
);
|
|
})}
|
|
</SimpleGrid>
|
|
|
|
{hasExit && (
|
|
<Box mt={4} pt={4} borderTopWidth="1px" borderColor="gray.200">
|
|
<HStack
|
|
as="button"
|
|
type="button"
|
|
w="full"
|
|
justify="center"
|
|
py={3}
|
|
borderRadius="xl"
|
|
border={0}
|
|
color="red.600"
|
|
bg="red.50"
|
|
cursor="pointer"
|
|
onClick={handleLogout}
|
|
>
|
|
<Icon as={logoutIcon} boxSize={5} />
|
|
<Text fontWeight="bold">{logoutTitle}</Text>
|
|
</HStack>
|
|
</Box>
|
|
)}
|
|
</DrawerBody>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const Sidebar = Object.assign(SidebarRoot, {
|
|
Exit: SidebarExit,
|
|
});
|