diff --git a/src/components/layout/SectionNav.tsx b/src/components/layout/SectionNav.tsx new file mode 100644 index 0000000..2ce02ed --- /dev/null +++ b/src/components/layout/SectionNav.tsx @@ -0,0 +1,336 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: tier two of the modern shell. Tier one is the layout switcher in the + * top bar (Management / Settings / Account, straight from `schema.layouts`); + * this bar carries the active layout's own top-level items, each container + * opening its children in a menu. No sidebar, so a list or a form gets the + * whole window width. + * + * The item count comes from the server's schema, so it is never known ahead of + * time: the bar measures its items once, then keeps whatever fits and folds the + * rest into "More". A layout too deep for a menu bar at all keeps the sidebar — + * AdminPanel decides that, not this component. + */ + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import * as LucideIcons from 'lucide-react'; +const { ChevronDown, Lock, MoreHorizontal } = LucideIcons; +import { cn } from '@/lib/utils'; +import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useAccountStore } from '@/stores/accountStore'; +import { + checkIsEnterprise, + checkLinkVisible, + pathMatchesView, + resolveViewPath, + subtreeContainsActive, + subtreeHasVisibleLink, + topItemKey, + topItemVisible, + visibleLinks, +} from '@/lib/navTree'; +import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; + +/** Room kept for the "More" trigger when not everything fits. */ +const MORE_WIDTH = 92; + +function howManyFit(widths: number[], available: number): number { + let total = 0; + for (const w of widths) { + total += w; + if (total > available) { + let withMore = 0; + for (let j = 0; j < widths.length; j++) { + withMore += widths[j]; + if (withMore + MORE_WIDTH > available) return j; + } + return widths.length; + } + } + return widths.length; +} + +const TRIGGER_CLASS = + 'relative flex h-12 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-[13px] font-normal text-muted-foreground transition-colors hover:text-foreground'; +const TRIGGER_ACTIVE = + "font-medium text-foreground after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary after:content-['']"; + +interface MenuBodyProps { + items: LayoutSubItem[]; + sectionName: string; + currentPath: string; + edition: string; + onPick: (viewName: string, locked: boolean) => void; +} + +/** + * A container's children. Direct links stay as items; a nested group becomes a + * label over its own links, so "Emails" reads Queued / History: Inbound, + * Outbound / Delivery tests rather than one flat list. + */ +function MenuBody({ items, sectionName, currentPath, edition, onPick }: MenuBodyProps) { + return ( + <> + {items.map((sub, i) => { + if (sub.type === 'link') { + if (!checkLinkVisible(sub.viewName)) return null; + const enterprise = checkIsEnterprise(sub.viewName); + if (enterprise && edition === 'oss') return null; + const locked = enterprise && edition === 'community'; + return ( + onPick(sub.viewName, locked)} + > + {sub.name || 'Overview'} + {locked && } + + ); + } + + if (!subtreeHasVisibleLink(sub.items, edition)) return null; + const links = visibleLinks(sub.items, edition); + return ( + + + {sub.name} + + {links.map((l) => ( + onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')} + > + {l.name} + + ))} + + ); + })} + + ); +} + +interface ItemProps { + item: LayoutItem; + sectionName: string; + currentPath: string; + edition: string; + onPick: (viewName: string, locked: boolean) => void; + measureRef?: (el: HTMLElement | null) => void; +} + +function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measureRef }: ItemProps) { + if ('link' in item) { + const { name, viewName } = item.link; + const enterprise = checkIsEnterprise(viewName); + const locked = enterprise && edition === 'community'; + const isActive = pathMatchesView(currentPath, sectionName, viewName); + return ( + + ); + } + + const { name, items } = item.container; + const containsActive = subtreeContainsActive(items, currentPath, sectionName); + return ( + + + + + + + + + ); +} + +export function SectionNav({ layout }: { layout: Layout }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const edition = useAccountStore((s) => s.edition); + const [upsellOpen, setUpsellOpen] = useState(false); + + const items = useMemo(() => layout.items.filter((item) => topItemVisible(item, edition)), [layout, edition]); + + const scrollerRef = useRef(null); + const measured = useRef([]); + const [available, setAvailable] = useState(0); + + /** + * A different layout means different labels, so a measurement is only good + * for the layout it was taken on: keeping the key beside the widths retires + * the old ones without a reset pass. + */ + const measureKey = `${layout.name}|${edition}`; + const [measurement, setMeasurement] = useState<{ key: string; widths: number[] } | null>(null); + const widths = measurement?.key === measureKey ? measurement.widths : null; + + /** + * Attached only while a measurement is wanted. A ref closure is new on every + * render, so React would re-run it — and force a reflow reading offsetWidth — + * on each one; leaving it off once the widths are known keeps the bar free of + * that on ordinary navigation. + */ + const measureRefFor = useCallback( + (index: number) => (el: HTMLElement | null) => { + if (el) measured.current[index] = el.offsetWidth; + }, + [], + ); + + useLayoutEffect(() => { + if (widths !== null) return; + const seen = measured.current.slice(0, items.length); + if (seen.length !== items.length || seen.some((w) => !w)) return; + setMeasurement({ key: measureKey, widths: seen }); + }, [widths, items.length, measureKey, location.pathname]); + + useEffect(() => { + const el = scrollerRef.current; + if (!el) return; + setAvailable(el.clientWidth); + if (typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver(() => setAvailable(el.clientWidth)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const onPick = useCallback( + (viewName: string, locked: boolean) => { + if (locked) { + setUpsellOpen(true); + return; + } + navigate(resolveViewPath(layout.name, viewName)); + }, + [navigate, layout.name], + ); + + // Before the first measurement every item renders, clipped by the scroller. + const shown = widths === null || available === 0 ? items.length : howManyFit(widths, available); + const overflowed = items.slice(shown); + + return ( + + ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4346dbc..3ff25fe 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -25,48 +25,18 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { useUIStore } from '@/stores/uiStore'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; -import { visibleLayouts, isLinkEnterprise, isLinkVisible } from '@/lib/layout'; +import { visibleLayouts } from '@/lib/layout'; +import { + checkIsEnterprise, + checkLinkVisible, + pathMatchesView, + resolveViewPath, + subtreeContainsActive, + subtreeHasVisibleLink, + visibleLinks, +} from '@/lib/navTree'; 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; @@ -86,27 +56,6 @@ function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsiblePr ); } -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 { @@ -300,22 +249,6 @@ function SidebarTopItem({ 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, @@ -387,7 +320,16 @@ function RailItem({ ); } -export function Sidebar() { +interface SidebarProps { + /** + * INBUXA: in the modern shell the section bar does the navigating on a wide + * screen, but a phone has no room for it — the sidebar stays as the + * slide-over behind the hamburger, and nothing else. + */ + mobileOnly?: boolean; +} + +export function Sidebar({ mobileOnly = false }: SidebarProps = {}) { const navigate = useNavigate(); const location = useLocation(); const activeSection = useUIStore((s) => s.activeSection); @@ -427,9 +369,10 @@ export function Sidebar() { 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. + // Folding to a rail is for wide screens; a phone keeps the slide-over, and so + // does the modern shell, where the rail would sit under the section bar. const collapsed = - sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches; + !mobileOnly && sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches; if (collapsed) { return ( @@ -474,7 +417,12 @@ export function Sidebar() { className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden" onClick={() => setSidebarOpen(false)} /> -