admin/components/layouts/sidebar/SidebarNav.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

269 lines
7.0 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import type { SidebarRoute } from '@/types'
import Button from '@/components/formElements/Button'
import AngleDownIcon from '@/components/icons/AngleDownIcon'
import { isSidebarGroupOpen, isSidebarRouteActive } from '@/helpers/sidebarRoute'
interface SidebarNavProps {
items: SidebarRoute[]
pathname: string
onLinkClick?: () => void
compact?: boolean
}
const iconClass = (active: boolean, size: 'md' | 'sm' = 'md') =>
`${size === 'sm' ? 'size-4' : 'size-5'} ${active ? 'text-white' : 'text-secondary-30'}`
const SidebarNavIcon = ({ active, item, size = 'md' }: { item: SidebarRoute; active: boolean; size?: 'md' | 'sm' }) => {
const boxClass = size === 'sm' ? 'size-6' : 'size-8'
if (!item.icon) {
return (
<span
aria-hidden
className={`shrink-0 ${boxClass}`}
/>
)
}
return (
<span
className={`shrink-0 rounded-xl transition-colors ${size === 'sm' ? 'p-1' : 'p-2'} ${active ? 'bg-primary-600 shadow-md shadow-primary-600/20' : 'bg-secondary-40'}`}
>
{item.icon(iconClass(active, size))}
</span>
)
}
const SidebarNavLink = ({
active,
href,
item,
onLinkClick,
size = 'md',
compact = false,
}: {
item: SidebarRoute
href: string
active: boolean
onLinkClick?: () => void
size?: 'md' | 'sm'
compact?: boolean
}) => {
const isChild = size === 'sm'
return (
<Link
aria-current={active ? 'page' : undefined}
className={`group flex w-full rounded-2xl transition-all duration-200 hover:bg-secondary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 ${
compact ? 'min-h-[66px] flex-col items-center justify-center gap-1 px-1 py-2' : 'flex-row items-center gap-3'
} ${isChild ? 'py-2 pe-2 ps-9' : 'px-2 py-3'} ${active ? 'bg-secondary-40 text-secondary-10' : ''}`}
href={href}
target={item.target}
title={compact ? item.title : undefined}
onClick={onLinkClick}
>
{!isChild &&
(compact && item.icon ? (
item.icon(`size-6 ${active ? 'text-primary-700' : 'text-secondary-20'}`)
) : (
<SidebarNavIcon
active={active}
item={item}
/>
))}
{isChild &&
(item.icon ? (
<SidebarNavIcon
active={active}
item={item}
size="sm"
/>
) : null)}
<span
className={`${compact ? 'w-full text-center text-2xs' : 'flex-1 text-start'} font-normal leading-tight ${
isChild ? 'text-xs' : compact ? '' : 'text-sm'
} ${active ? `font-semibold ${compact ? 'text-secondary-10' : 'text-primary-700'}` : 'text-secondary-30'}`}
>
{item.title}
</span>
</Link>
)
}
const SidebarNavGroup = ({
groupKey,
isOpen,
item,
onLinkClick,
onToggle,
pathname,
}: {
item: SidebarRoute
groupKey: string
pathname: string
isOpen: boolean
onToggle: (key: string) => void
onLinkClick?: () => void
}) => {
const active = isSidebarRouteActive(pathname, item)
return (
<div className="sidebar-accordion flex flex-col">
<Button
fullWidth
aria-expanded={isOpen}
className={`flex w-full cursor-pointer flex-row items-center justify-start gap-3 rounded-xl px-2 py-3 hover:bg-secondary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 ${
active ? 'bg-primary-50' : ''
}`}
variant="light"
onClick={() => {
onToggle(groupKey)
}}
>
<SidebarNavIcon
active={active}
item={item}
/>
<span className={`flex-1 text-start text-sm ${active ? 'font-semibold text-primary-700' : 'font-normal text-secondary-30'}`}>
{item.title}
</span>
<AngleDownIcon
className={`size-5 shrink-0 transition-transform duration-200 ${
active ? 'text-primary-700' : 'text-secondary-30'
} ${isOpen ? 'rotate-180' : ''}`}
/>
</Button>
{isOpen && item.children ? (
<div className="flex flex-col gap-1 pb-1 mt-1">
{item.children.map((child, childIndex) => {
if (!child.link) {
return null
}
const childActive = isSidebarRouteActive(pathname, child)
return (
<SidebarNavLink
key={child.link ?? `child-${childIndex}`}
active={childActive}
href={child.link}
item={child}
size="sm"
onLinkClick={onLinkClick}
/>
)
})}
</div>
) : null}
</div>
)
}
const SidebarNav = ({ compact = false, items, onLinkClick, pathname }: SidebarNavProps) => {
const autoOpenKeys = useMemo(() => {
const keys = new Set<string>()
items.forEach((item, index) => {
if (item.children && isSidebarGroupOpen(pathname, item)) {
keys.add(String(index))
}
})
return keys
}, [pathname, items])
const [prevPathname, setPrevPathname] = useState(pathname)
const [manualClosed, setManualClosed] = useState<Set<string>>(() => new Set())
const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set())
if (pathname !== prevPathname) {
setPrevPathname(pathname)
setManualClosed(new Set())
setManualOpen(new Set())
}
const isGroupOpen = (key: string) => {
if (manualClosed.has(key)) {
return false
}
if (manualOpen.has(key)) {
return true
}
return autoOpenKeys.has(key)
}
const toggleGroup = (key: string) => {
if (isGroupOpen(key)) {
setManualClosed((prev) => new Set(prev).add(key))
setManualOpen((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
} else {
setManualOpen((prev) => new Set(prev).add(key))
setManualClosed((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
}
return (
<nav
aria-label="منوی مدیریت"
className={`flex flex-col ${compact ? 'gap-1' : 'gap-1'}`}
>
{items.map((item, index) => {
const key = item.link ?? `group-${index}`
if (item.children?.length) {
return (
<SidebarNavGroup
key={key}
groupKey={String(index)}
isOpen={isGroupOpen(String(index))}
item={item}
pathname={pathname}
onLinkClick={onLinkClick}
onToggle={toggleGroup}
/>
)
}
if (item.link) {
const active = isSidebarRouteActive(pathname, item)
return (
<SidebarNavLink
key={key}
active={active}
compact={compact}
href={item.link}
item={item}
onLinkClick={onLinkClick}
/>
)
}
return null
})}
</nav>
)
}
export default SidebarNav