/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import * as LucideIcons from 'lucide-react'; const { ChevronDown, Lock } = LucideIcons; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useUIStore } from '@/stores/uiStore'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; import { visibleLayouts, findFirstVisibleLinkInLayout, findFirstAccessibleLinkInLayout, isLinkEnterprise, isLinkVisible, } from '@/lib/layout'; import { findLastVisitedLinkInLayout, setLastVisitedSection } from '@/lib/lastVisited'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; function LucideIcon({ name, className }: { name: string; className?: string }) { const formatted = name .split('-') .map((s) => s[0].toUpperCase() + s.slice(1)) .join(''); const IconComp = (LucideIcons as Record)[formatted] as LucideIcons.LucideIcon | undefined; if (!IconComp) return ; return ; } 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 AccordionLevelContextValue { openId: string | null; setOpenId: (id: string | null) => void; } const AccordionLevelContext = createContext(null); // Sibling collapsibles share a single open id, so expanding one collapses the // others at the same level (accordion behavior). function AccordionLevel({ children }: { children: React.ReactNode }) { const [openId, setOpenId] = useState(null); const value = useMemo(() => ({ openId, setOpenId }), [openId]); return {children}; } // A collapsible wired to its accordion level. It opens itself whenever the // active page lands inside it, while still allowing manual toggling. function AccordionCollapsible({ id, containsActive, children, }: { id: string; containsActive: boolean; children: React.ReactNode; }) { const level = useContext(AccordionLevelContext); if (!level) throw new Error('AccordionCollapsible must be used within AccordionLevel'); const { openId, setOpenId } = level; // Layout effect so the branch containing the active page is already open on // the first paint after a navigation. useLayoutEffect(() => { if (containsActive) setOpenId(id); }, [containsActive, id, setOpenId]); return ( setOpenId(open ? id : null)}> {children} ); } // Softer than the default ghost hover, closer to documentation sidebars: // muted text that brightens with a faint background instead of a strong fill. const sidebarItemClass = 'w-full justify-start gap-2 font-normal text-muted-foreground hover:bg-accent/50 hover:text-foreground'; // In square mode the sidebar follows documentation conventions (better-auth): // full-bleed rows with a barely-there hover wash instead of inset pills. const sidebarItemSquareClass = "[[data-radius='square']_&]:hover:bg-foreground/[0.03] [[data-radius='square']_&]:hover:text-foreground/90"; 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); } interface SidebarSubItemProps { item: LayoutSubItem; depth: number; sectionName: string; currentPath: string; navigate: ReturnType; edition: string; onUpsell: () => void; } function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: 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; } function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: 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; } export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); const activeSection = useUIStore((s) => s.activeSection); const setActiveSection = useUIStore((s) => s.setActiveSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); const setSidebarOpen = useUIStore((s) => s.setSidebarOpen); 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 navRef = useRef(null); // Build the permission checks from the permissions array itself: the store // accessors are stable refs, so depending on them alone would keep a stale // layout list after access data finishes loading. 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]); // Keep the active page visible in the sidebar after any navigation // (e.g. from the command palette or an external link). useEffect(() => { const active = navRef.current?.querySelector('[data-sidebar-active="true"]'); active?.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; const handleSectionClick = (target: Layout) => { setActiveSection(target.name); const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); const last = findLastVisitedLinkInLayout(schema, target, edition, canGet, hasPermission); const first = last ?? findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ?? findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission); if (first) navigate(`/${target.name}/${first}`); }; return ( <>