add sidebar
All checks were successful
Build and Deploy Next.js + Nginx Docker Image / build-and-deploy (push) Successful in 49s
Build and Deploy Next.js + Nginx Docker Image / deploy (push) Successful in 7s

This commit is contained in:
Mohamadzadeh 2026-08-19 17:19:15 +03:30
parent fbd2ca137e
commit c3d9a696f6
9 changed files with 1440 additions and 455 deletions

View File

@ -38,7 +38,7 @@ yarn upgrade uikit
| `uikit` | utility functions | - |
| `uikit/utils` | utility functions | - |
| `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/table` | DataTable | Chakra + TanStack Table + Pagination |
| `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
required dependencies:

184
dist/chunks/permission-DL7ciWwc.js vendored Normal file
View 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

File diff suppressed because one or more lines are too long

35
dist/layout.d.ts vendored Normal file
View 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

File diff suppressed because one or more lines are too long

View File

@ -15,7 +15,10 @@
"import": "./dist/utils.js"
},
"./pagination": "./dist/pagination.js",
"./layout": "./dist/layout.js",
"./layout": {
"types": "./dist/layout.d.ts",
"import": "./dist/layout.js"
},
"./core": {
"types": "./dist/core.d.ts",
"import": "./dist/core.js"
@ -36,11 +39,12 @@
"build": "yarn build:lib && yarn build:remote && yarn copy-types",
"build:lib": "vite build",
"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-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-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": {
"@chakra-ui/react": "^2.0.0",

441
src/layout/Sidebar.jsx Normal file
View 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
View 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;

View File

@ -1 +1,6 @@
export { Header } from "./Header.jsx";
export {
Sidebar,
isSidebarPathActive,
getActiveSidebarItem,
} from "./Sidebar.jsx";