add sidebar
This commit is contained in:
parent
fbd2ca137e
commit
c3d9a696f6
69
README.md
69
README.md
@ -38,7 +38,7 @@ yarn upgrade uikit
|
|||||||
| `uikit` | utility functions | - |
|
| `uikit` | utility functions | - |
|
||||||
| `uikit/utils` | utility functions | - |
|
| `uikit/utils` | utility functions | - |
|
||||||
| `uikit/pagination` | pagination | Chakra + React + react-icons |
|
| `uikit/pagination` | pagination | Chakra + React + react-icons |
|
||||||
| `uikit/layout` | Header | Chakra + React |
|
| `uikit/layout` | Header, Sidebar | Chakra + React + React Icons |
|
||||||
| `uikit/core` | API، BiProvider، Keycloak، Permission | Chakra + React Query + Axios + Keycloak + Query Params |
|
| `uikit/core` | API، BiProvider، Keycloak، Permission | Chakra + React Query + Axios + Keycloak + Query Params |
|
||||||
| `uikit/table` | DataTable | Chakra + TanStack Table + Pagination |
|
| `uikit/table` | DataTable | Chakra + TanStack Table + Pagination |
|
||||||
| `uikit/remote/ui` | Remote UI components مثل `GBadge` | Chakra + React + remote static file |
|
| `uikit/remote/ui` | Remote UI components مثل `GBadge` | Chakra + React + remote static file |
|
||||||
@ -130,6 +130,73 @@ function AppHeader() {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Sidebar
|
||||||
|
|
||||||
|
required dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion react-icons
|
||||||
|
yarn add @tanstack/react-query axios keycloak-js
|
||||||
|
yarn add next-query-params@^5.1.0 use-query-params@^2.2.2
|
||||||
|
```
|
||||||
|
|
||||||
|
`Sidebar` همان UI ناوبری سامانه را برای دسکتاپ و موبایل ارائه میدهد. در دسکتاپ به صورت نوار آیکون کناری و در موبایل به صورت bottom navigation + drawer «بیشتر» نمایش داده میشود.
|
||||||
|
|
||||||
|
آیتمها به صورت config به کامپوننت داده میشوند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { SlUserFollowing } from "react-icons/sl";
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
title: "آنالیز شبکه متعارف - فالوور/فالووینگ",
|
||||||
|
mobileTitle: "ارتباط کاربران",
|
||||||
|
icon: SlUserFollowing,
|
||||||
|
path: "/user-connections/analyze",
|
||||||
|
perm: ["analyze"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
نمونه استفاده در Next.js Pages Router:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import NextLink from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Sidebar } from "uikit/layout";
|
||||||
|
|
||||||
|
function AppSidebar() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
items={navigationItems}
|
||||||
|
currentPath={router.asPath}
|
||||||
|
linkComponent={NextLink}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Sidebar` باید داخل `BiProvider` استفاده شود. Permission هر آیتم از `perm` توسط خود UI Kit بررسی میشود، خروج با Keycloak توسط خود UI Kit انجام میشود و دسترسی `panel_access` نیز داخل Sidebar کنترل میشود. سامانه مقصد نیازی به `usePermission` یا `logoutKeycloak` برای Sidebar ندارد.
|
||||||
|
|
||||||
|
`aliases` نیز پشتیبانی میشود و برای تشخیص آیتم active استفاده میشود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{
|
||||||
|
title: "آنالیز شبکه های اجتماعی خارجی",
|
||||||
|
mobileTitle: "شبکه خارجی",
|
||||||
|
icon: TbAnalyze,
|
||||||
|
path: "/posts/final_analyze",
|
||||||
|
aliases: ["/posts/analyze"],
|
||||||
|
perm: ["analyze"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر `perm` آرایه باشد، همه permissionهای داخل آرایه برای فعال بودن آیتم لازم هستند. آیتم بدون `perm` محدودیت permission ندارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Core
|
# Core
|
||||||
|
|
||||||
required dependencies:
|
required dependencies:
|
||||||
|
|||||||
184
dist/chunks/permission-DL7ciWwc.js
vendored
Normal file
184
dist/chunks/permission-DL7ciWwc.js
vendored
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { jsx as y } from "react/jsx-runtime";
|
||||||
|
import { createContext as L, useState as K, useRef as O, useCallback as h, useEffect as k, useMemo as T, useContext as R } from "react";
|
||||||
|
import { useQuery as b } from "@tanstack/react-query";
|
||||||
|
import F from "keycloak-js";
|
||||||
|
let x = null, r = null, E = null, C = !1;
|
||||||
|
const G = L(null), _ = ({ url: e, realm: s, clientId: t }) => (r || (r = new F({
|
||||||
|
url: e,
|
||||||
|
realm: s,
|
||||||
|
clientId: t
|
||||||
|
})), r), j = (e) => (x = e, x), ee = () => C, J = () => !!(r != null && r.authenticated), Q = () => (r == null ? void 0 : r.token) ?? null, te = async (e = 30) => r ? r.updateToken(e) : !1, U = (e) => r == null ? void 0 : r.logout(e), re = (e) => r == null ? void 0 : r.login(e), W = ({ config: e, initOptions: s = {} } = {}) => {
|
||||||
|
const t = e ?? x;
|
||||||
|
if (!(t != null && t.url) || !(t != null && t.realm) || !(t != null && t.clientId))
|
||||||
|
throw new Error("Keycloak config is required.");
|
||||||
|
j(t);
|
||||||
|
const n = _(t);
|
||||||
|
return E || (E = n.init({
|
||||||
|
onLoad: "login-required",
|
||||||
|
checkLoginIframe: !1,
|
||||||
|
flow: "implicit",
|
||||||
|
...s
|
||||||
|
}).then((o) => (C = !0, o)).catch((o) => {
|
||||||
|
throw C = !0, o;
|
||||||
|
})), E;
|
||||||
|
}, se = ({
|
||||||
|
children: e,
|
||||||
|
config: s,
|
||||||
|
initOptions: t,
|
||||||
|
loading: n = null
|
||||||
|
}) => {
|
||||||
|
const [o, m] = K(!1), [p, w] = K(!1), [u, c] = K(null), l = O(!1), i = h(() => {
|
||||||
|
r && (w(!!r.authenticated), c(r.token ?? null));
|
||||||
|
}, []);
|
||||||
|
k(() => (l.current = !0, W({ config: s, initOptions: t }).then((a) => {
|
||||||
|
l.current && (w(!!a), c((r == null ? void 0 : r.token) ?? null), m(!0));
|
||||||
|
}).catch(() => {
|
||||||
|
l.current && (w(!1), c(null), m(!0));
|
||||||
|
}), () => {
|
||||||
|
l.current = !1;
|
||||||
|
}), [s, t]);
|
||||||
|
const P = h(() => r == null ? void 0 : r.login(), []), d = h((a) => r == null ? void 0 : r.logout(a), []), f = h(
|
||||||
|
async (a = 30) => {
|
||||||
|
if (!r) return !1;
|
||||||
|
const z = await r.updateToken(a);
|
||||||
|
return i(), z;
|
||||||
|
},
|
||||||
|
[i]
|
||||||
|
), S = T(
|
||||||
|
() => ({
|
||||||
|
initialized: o,
|
||||||
|
authenticated: p,
|
||||||
|
token: u,
|
||||||
|
login: P,
|
||||||
|
logout: d,
|
||||||
|
updateToken: f
|
||||||
|
}),
|
||||||
|
[o, p, u, P, d, f]
|
||||||
|
);
|
||||||
|
return o ? /* @__PURE__ */ y(G.Provider, { value: S, children: e }) : n;
|
||||||
|
}, B = L(null), v = "permissions", Y = (e) => (e == null ? void 0 : e.storageKey) || v, ne = ({
|
||||||
|
user: e = null,
|
||||||
|
children: s,
|
||||||
|
config: t,
|
||||||
|
loading: n = /* @__PURE__ */ y("div", { children: "Loading permissions..." }),
|
||||||
|
errorFallback: o = /* @__PURE__ */ y("div", { children: "Error loading permissions" })
|
||||||
|
}) => {
|
||||||
|
const m = Y(t), [p, w] = K(
|
||||||
|
() => $(m)
|
||||||
|
), { data: u, isLoading: c, error: l } = V(t), i = u ?? p;
|
||||||
|
k(() => {
|
||||||
|
u && (D(m, u), w(u));
|
||||||
|
}, [u, m]);
|
||||||
|
const P = T(
|
||||||
|
() => I(i),
|
||||||
|
[i]
|
||||||
|
), d = h(
|
||||||
|
(a = "") => M(P, a),
|
||||||
|
[P]
|
||||||
|
), f = h((a = "") => !d(a), [d]), S = T(
|
||||||
|
() => ({
|
||||||
|
user: e,
|
||||||
|
can: d,
|
||||||
|
cannot: f,
|
||||||
|
permissions: i,
|
||||||
|
isLoading: c,
|
||||||
|
error: l
|
||||||
|
}),
|
||||||
|
[e, d, f, i, c, l]
|
||||||
|
);
|
||||||
|
return l && !i ? o : c && !i ? n : /* @__PURE__ */ y(B.Provider, { value: S, children: s });
|
||||||
|
}, $ = (e = v) => {
|
||||||
|
if (!(typeof window > "u"))
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem(e);
|
||||||
|
return s ? JSON.parse(s) : void 0;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, D = (e = v, s) => {
|
||||||
|
if (!(typeof window > "u"))
|
||||||
|
try {
|
||||||
|
localStorage.setItem(e, JSON.stringify(s));
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}, oe = (e = v) => {
|
||||||
|
if (!(typeof window > "u"))
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(e);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}, I = (e) => e ? new Set(e.map((s) => s.name)) : /* @__PURE__ */ new Set(), M = (e, s = "") => {
|
||||||
|
if (!s) return !1;
|
||||||
|
if (e.has(s) || [...e].some((n) => n.startsWith(s))) return !0;
|
||||||
|
let t = s;
|
||||||
|
for (; t.includes(":"); )
|
||||||
|
if (t = t.slice(0, t.lastIndexOf(":")), e.has(`${t}:*`)) return !0;
|
||||||
|
return !1;
|
||||||
|
}, q = {
|
||||||
|
all: ["permissions"],
|
||||||
|
list: (e) => [...q.all, e]
|
||||||
|
}, N = async (e) => {
|
||||||
|
if (!(e != null && e.url) || !(e != null && e.clientId))
|
||||||
|
throw new Error("Permission config requires url and clientId.");
|
||||||
|
const s = Q(), t = new URL(e.url);
|
||||||
|
t.searchParams.set("client_id", e.clientId);
|
||||||
|
const n = await fetch(t.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
...s ? { Authorization: `Bearer ${s}` } : {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!n.ok)
|
||||||
|
throw new Error("Failed to load permissions");
|
||||||
|
return n.json();
|
||||||
|
}, A = () => {
|
||||||
|
const e = R(B);
|
||||||
|
if (!e)
|
||||||
|
throw new Error("usePermission must be used within PermissionProvider");
|
||||||
|
return e;
|
||||||
|
}, V = (e) => b({
|
||||||
|
queryKey: q.list(e == null ? void 0 : e.clientId),
|
||||||
|
queryFn: () => N(e),
|
||||||
|
enabled: !!(e != null && e.url) && !!(e != null && e.clientId) && J(),
|
||||||
|
staleTime: 1 / 0,
|
||||||
|
refetchOnWindowFocus: !1,
|
||||||
|
refetchOnReconnect: !1
|
||||||
|
}), ie = ({
|
||||||
|
perm: e,
|
||||||
|
children: s,
|
||||||
|
fallback: t = /* @__PURE__ */ y("div", { children: "You are not allowed to do this" })
|
||||||
|
}) => {
|
||||||
|
const { can: n } = A();
|
||||||
|
return n(e) ? s : t;
|
||||||
|
}, ae = ({ children: e }) => {
|
||||||
|
const { can: s, permissions: t } = A();
|
||||||
|
return t && s("admin") ? e : null;
|
||||||
|
}, ue = ({ children: e, forceNoAccess: s = !1 }) => {
|
||||||
|
const { can: t, isLoading: n } = A();
|
||||||
|
return k(() => {
|
||||||
|
n || (s || !t("panel_access")) && U();
|
||||||
|
}, [n, t, s]), e;
|
||||||
|
};
|
||||||
|
export {
|
||||||
|
ae as A,
|
||||||
|
ie as C,
|
||||||
|
se as K,
|
||||||
|
ne as P,
|
||||||
|
te as a,
|
||||||
|
J as b,
|
||||||
|
W as c,
|
||||||
|
ue as d,
|
||||||
|
M as e,
|
||||||
|
oe as f,
|
||||||
|
Q as g,
|
||||||
|
I as h,
|
||||||
|
ee as i,
|
||||||
|
$ as j,
|
||||||
|
N as k,
|
||||||
|
U as l,
|
||||||
|
re as m,
|
||||||
|
V as n,
|
||||||
|
q as p,
|
||||||
|
D as s,
|
||||||
|
A as u
|
||||||
|
};
|
||||||
671
dist/core.js
vendored
671
dist/core.js
vendored
File diff suppressed because one or more lines are too long
35
dist/layout.d.ts
vendored
Normal file
35
dist/layout.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export type SidebarItem = {
|
||||||
|
title: string;
|
||||||
|
mobileTitle?: string;
|
||||||
|
icon: any;
|
||||||
|
path: string;
|
||||||
|
aliases?: string[];
|
||||||
|
perm?: string | string[];
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SidebarProps = {
|
||||||
|
items?: SidebarItem[];
|
||||||
|
currentPath?: string;
|
||||||
|
linkComponent?: any;
|
||||||
|
onNavigate?: (item: SidebarItem) => void;
|
||||||
|
logoutTitle?: string;
|
||||||
|
logoutIcon?: any;
|
||||||
|
primaryMobileCount?: number;
|
||||||
|
moreTitle?: string;
|
||||||
|
moreDrawerTitle?: string;
|
||||||
|
desktopProps?: Record<string, any>;
|
||||||
|
mobileProps?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sidebar(props: SidebarProps): any;
|
||||||
|
export function isSidebarPathActive(
|
||||||
|
currentPath: string | undefined,
|
||||||
|
item: SidebarItem,
|
||||||
|
): boolean;
|
||||||
|
export function getActiveSidebarItem(
|
||||||
|
items: SidebarItem[],
|
||||||
|
currentPath: string | undefined,
|
||||||
|
): SidebarItem | undefined;
|
||||||
|
|
||||||
|
export const Header: any;
|
||||||
445
dist/layout.js
vendored
445
dist/layout.js
vendored
File diff suppressed because one or more lines are too long
10
package.json
10
package.json
@ -15,7 +15,10 @@
|
|||||||
"import": "./dist/utils.js"
|
"import": "./dist/utils.js"
|
||||||
},
|
},
|
||||||
"./pagination": "./dist/pagination.js",
|
"./pagination": "./dist/pagination.js",
|
||||||
"./layout": "./dist/layout.js",
|
"./layout": {
|
||||||
|
"types": "./dist/layout.d.ts",
|
||||||
|
"import": "./dist/layout.js"
|
||||||
|
},
|
||||||
"./core": {
|
"./core": {
|
||||||
"types": "./dist/core.d.ts",
|
"types": "./dist/core.d.ts",
|
||||||
"import": "./dist/core.js"
|
"import": "./dist/core.js"
|
||||||
@ -36,11 +39,12 @@
|
|||||||
"build": "yarn build:lib && yarn build:remote && yarn copy-types",
|
"build": "yarn build:lib && yarn build:remote && yarn copy-types",
|
||||||
"build:lib": "vite build",
|
"build:lib": "vite build",
|
||||||
"build:remote": "vite build --mode remote",
|
"build:remote": "vite build --mode remote",
|
||||||
"copy-types": "yarn copy-index-types && yarn copy-utils-types && yarn copy-core-types && yarn copy-remote-ui-types",
|
"copy-types": "yarn copy-index-types && yarn copy-utils-types && yarn copy-core-types && yarn copy-layout-types && yarn copy-remote-ui-types",
|
||||||
"copy-index-types": "cp src/index.d.ts dist/index.d.ts",
|
"copy-index-types": "cp src/index.d.ts dist/index.d.ts",
|
||||||
"copy-utils-types": "cp src/utils/index.d.ts dist/utils.d.ts",
|
"copy-utils-types": "cp src/utils/index.d.ts dist/utils.d.ts",
|
||||||
"copy-core-types": "cp src/core/index.d.ts dist/core.d.ts",
|
"copy-core-types": "cp src/core/index.d.ts dist/core.d.ts",
|
||||||
"copy-remote-ui-types": "cp src/remote/ui/index.d.ts dist/remote-ui.d.ts"
|
"copy-remote-ui-types": "cp src/remote/ui/index.d.ts dist/remote-ui.d.ts",
|
||||||
|
"copy-layout-types": "cp src/layout/index.d.ts dist/layout.d.ts"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@chakra-ui/react": "^2.0.0",
|
"@chakra-ui/react": "^2.0.0",
|
||||||
|
|||||||
441
src/layout/Sidebar.jsx
Normal file
441
src/layout/Sidebar.jsx
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
"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 { forwardRef, useEffect, useRef } from "react";
|
||||||
|
import { FiMoreHorizontal } from "react-icons/fi";
|
||||||
|
import { IoExit } from "react-icons/io5";
|
||||||
|
import { logoutKeycloak } from "../core/keycloak.jsx";
|
||||||
|
import { usePermission } from "../core/permission.jsx";
|
||||||
|
|
||||||
|
export const isSidebarPathActive = (currentPath, item) => {
|
||||||
|
const normalizedPath = currentPath?.split("?")[0] || "";
|
||||||
|
const candidatePaths = [item?.path, ...(item?.aliases || [])].filter(Boolean);
|
||||||
|
|
||||||
|
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 content = (
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
icon={<Icon as={item.icon} />}
|
||||||
|
fontSize="xl"
|
||||||
|
size={{ base: "sm", lg: "lg" }}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isActive={isActive}
|
||||||
|
aria-label={item.title}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
onClick={isDisabled ? undefined : onNavigate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={isDisabled ? "عدم دسترسی" : item.title}
|
||||||
|
placement="left-start"
|
||||||
|
hasArrow
|
||||||
|
rounded="md"
|
||||||
|
py={2}
|
||||||
|
px={4}
|
||||||
|
fontWeight="bold"
|
||||||
|
closeDelay={150}
|
||||||
|
>
|
||||||
|
{isDisabled ? (
|
||||||
|
<Box as="span">{content}</Box>
|
||||||
|
) : (
|
||||||
|
<SidebarLink
|
||||||
|
linkComponent={linkComponent}
|
||||||
|
href={item.path}
|
||||||
|
_hover={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</SidebarLink>
|
||||||
|
)}
|
||||||
|
</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) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flex="1"
|
||||||
|
minW={0}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Sidebar = ({
|
||||||
|
items = [],
|
||||||
|
currentPath,
|
||||||
|
linkComponent,
|
||||||
|
onNavigate,
|
||||||
|
logoutTitle = "خروج",
|
||||||
|
logoutIcon = IoExit,
|
||||||
|
primaryMobileCount = 4,
|
||||||
|
moreTitle = "بیشتر",
|
||||||
|
moreDrawerTitle = "سایر بخشهای سامانه",
|
||||||
|
desktopProps,
|
||||||
|
mobileProps,
|
||||||
|
}) => {
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const moreButtonRef = useRef();
|
||||||
|
const { cannot, can, isLoading } = usePermission();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
if (!can("panel_access")) {
|
||||||
|
logoutKeycloak();
|
||||||
|
}
|
||||||
|
}, [can, isLoading]);
|
||||||
|
|
||||||
|
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 = true;
|
||||||
|
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<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) {
|
||||||
|
return <Box key={item.path}>{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>
|
||||||
|
|
||||||
|
<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">خروج از سامانه</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</DrawerBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
src/layout/index.d.ts
vendored
Normal file
35
src/layout/index.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export type SidebarItem = {
|
||||||
|
title: string;
|
||||||
|
mobileTitle?: string;
|
||||||
|
icon: any;
|
||||||
|
path: string;
|
||||||
|
aliases?: string[];
|
||||||
|
perm?: string | string[];
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SidebarProps = {
|
||||||
|
items?: SidebarItem[];
|
||||||
|
currentPath?: string;
|
||||||
|
linkComponent?: any;
|
||||||
|
onNavigate?: (item: SidebarItem) => void;
|
||||||
|
logoutTitle?: string;
|
||||||
|
logoutIcon?: any;
|
||||||
|
primaryMobileCount?: number;
|
||||||
|
moreTitle?: string;
|
||||||
|
moreDrawerTitle?: string;
|
||||||
|
desktopProps?: Record<string, any>;
|
||||||
|
mobileProps?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sidebar(props: SidebarProps): any;
|
||||||
|
export function isSidebarPathActive(
|
||||||
|
currentPath: string | undefined,
|
||||||
|
item: SidebarItem,
|
||||||
|
): boolean;
|
||||||
|
export function getActiveSidebarItem(
|
||||||
|
items: SidebarItem[],
|
||||||
|
currentPath: string | undefined,
|
||||||
|
): SidebarItem | undefined;
|
||||||
|
|
||||||
|
export const Header: any;
|
||||||
@ -1 +1,6 @@
|
|||||||
export { Header } from "./Header.jsx";
|
export { Header } from "./Header.jsx";
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
isSidebarPathActive,
|
||||||
|
getActiveSidebarItem,
|
||||||
|
} from "./Sidebar.jsx";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user