Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
269 lines
7.0 KiB
TypeScript
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
|