/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import * as LucideIcons from 'lucide-react'; const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen } = LucideIcons; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { IconTile } from '@/components/common/IconTile'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useUIStore } from '@/stores/uiStore'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; import { visibleLayouts, isLinkEnterprise, isLinkVisible } from '@/lib/layout'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; function resolveViewPath(sectionName: string, viewName: string): string { return `/${sectionName}/${viewName}`; } function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean { const base = `/${sectionName}/${viewName}`; if (currentPath === base || currentPath.startsWith(`${base}/`)) return true; if (viewName === 'CustomComponent/Dashboard') { const dashBase = `/${sectionName}/Dashboard/`; return currentPath.startsWith(dashBase); } return false; } function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean { for (const item of items) { if (item.type === 'link') { if (pathMatchesView(currentPath, sectionName, item.viewName)) return true; } else if (item.type === 'container') { if (subtreeContainsActive(item.items, currentPath, sectionName)) return true; } } return false; } function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean { for (const item of items) { if (item.type === 'link') { if (!checkLinkVisible(item.viewName)) continue; const enterprise = checkIsEnterprise(item.viewName); if (enterprise && edition === 'oss') continue; return true; } else if (item.type === 'container') { if (subtreeHasVisibleLink(item.items, edition)) return true; } } return false; } interface AutoOpenCollapsibleProps { containsActive: boolean; children: React.ReactNode; } function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) { const [open, setOpen] = useState(containsActive); const [prevContainsActive, setPrevContainsActive] = useState(containsActive); if (containsActive !== prevContainsActive) { setPrevContainsActive(containsActive); if (containsActive) setOpen(true); } return ( {children} ); } function checkLinkVisible(viewName: string): boolean { const schema = useSchemaStore.getState().schema; if (!schema) return true; const accountStore = useAccountStore.getState(); return isLinkVisible( schema, viewName, accountStore.edition, (prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'), (perm: string) => accountStore.hasPermission(perm), ); } function checkIsEnterprise(viewName: string): boolean { const schema = useSchemaStore.getState().schema; if (!schema) return false; const edition = useAccountStore.getState().edition; return isLinkEnterprise(schema, viewName, edition); } type ActiveItemRef = (el: HTMLButtonElement | null) => void; interface SidebarSubItemProps { item: LayoutSubItem; depth: number; sectionName: string; currentPath: string; navigate: ReturnType; edition: string; onUpsell: () => void; activeItemRef: ActiveItemRef; } function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell, activeItemRef, }: SidebarSubItemProps) { if (item.type === 'link') { if (!checkLinkVisible(item.viewName)) return null; const path = resolveViewPath(sectionName, item.viewName); const isActive = pathMatchesView(currentPath, sectionName, item.viewName); const enterprise = checkIsEnterprise(item.viewName); const isLocked = enterprise && edition === 'community'; const isHidden = enterprise && edition === 'oss'; if (isHidden) return null; return ( ); } if (item.type === 'container') { if (!subtreeHasVisibleLink(item.items, edition)) return null; const containsActive = subtreeContainsActive(item.items, currentPath, sectionName); return ( {item.items.map((sub) => ( ))} ); } return null; } interface SidebarTopItemProps { item: LayoutItem; sectionName: string; currentPath: string; navigate: ReturnType; edition: string; onUpsell: () => void; activeItemRef: ActiveItemRef; } function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell, activeItemRef, }: SidebarTopItemProps) { if ('link' in item) { const { name, icon, viewName } = item.link; if (!checkLinkVisible(viewName)) return null; const path = resolveViewPath(sectionName, viewName); const isActive = pathMatchesView(currentPath, sectionName, viewName); const enterprise = checkIsEnterprise(viewName); const isLocked = enterprise && edition === 'community'; const isHidden = enterprise && edition === 'oss'; if (isHidden) return null; return ( ); } if ('container' in item) { const { name, icon, items } = item.container; if (!subtreeHasVisibleLink(items, edition)) return null; const containsActive = subtreeContainsActive(items, currentPath, sectionName); return ( {items.map((sub) => ( ))} ); } return null; } /** INBUXA: every visible link under a container, flattened, for the rail's pop-out menu. */ function visibleLinks(items: LayoutSubItem[], edition: string, prefix = ''): { name: string; viewName: string }[] { const out: { name: string; viewName: string }[] = []; for (const it of items) { if (it.type === 'link') { if (!checkLinkVisible(it.viewName)) continue; const enterprise = checkIsEnterprise(it.viewName); if (enterprise && edition === 'oss') continue; out.push({ name: `${prefix}${it.name || 'Overview'}`, viewName: it.viewName }); } else if (subtreeHasVisibleLink(it.items, edition)) { out.push(...visibleLinks(it.items, edition, `${prefix}${it.name} › `)); } } return out; } /** INBUXA: one entry of the collapsed sidebar: its tile, a label on hover, a menu for a group. */ function RailItem({ item, sectionName, currentPath, navigate, edition, }: { item: LayoutItem; sectionName: string; currentPath: string; navigate: ReturnType; edition: string; }) { const base = 'mx-auto flex h-11 w-11 items-center justify-center rounded-xl transition-colors hover:bg-muted'; if ('link' in item) { const { name, icon, viewName } = item.link; if (!checkLinkVisible(viewName)) return null; const isActive = pathMatchesView(currentPath, sectionName, viewName); return ( {name} ); } const { name, icon, items } = item.container; if (!subtreeHasVisibleLink(items, edition)) return null; const links = visibleLinks(items, edition); const containsActive = subtreeContainsActive(items, currentPath, sectionName); return ( {name} {name} {links.map((l) => ( navigate(resolveViewPath(sectionName, l.viewName))} > {l.name} ))} ); } export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); const activeSection = useUIStore((s) => s.activeSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); const setSidebarOpen = useUIStore((s) => s.setSidebarOpen); const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed); const schema = useSchemaStore((s) => s.schema); const edition = useAccountStore((s) => s.edition); const permissions = useAccountStore((s) => s.permissions); const hasPermission = useAccountStore((s) => s.hasPermission); const [upsellOpen, setUpsellOpen] = useState(false); const activeItem = useRef(null); const activeItemRef = useCallback((el) => { activeItem.current = el; }, []); const layouts = useMemo(() => { if (!schema) return []; const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); return visibleLayouts(schema, edition, canGet, hasPermission); }, [schema, edition, permissions, hasPermission]); useEffect(() => { if (typeof window === 'undefined') return; if (window.matchMedia('(max-width: 767px)').matches) { setSidebarOpen(false); } }, [location.pathname, setSidebarOpen]); useEffect(() => { activeItem.current?.scrollIntoView({ block: 'nearest' }); }, [location.pathname, activeSection]); if (!sidebarOpen || !schema) return null; const layout: Layout | undefined = layouts.find((l) => l.name === activeSection); if (!layout) return null; // Folding to a rail is for wide screens; a phone keeps the slide-over. const collapsed = sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches; if (collapsed) { return ( ); } return ( <>