Sections across the top, and a choice between the two shells

The old web UI put everything in a sidebar, and a reskin of it would
still read as the old web UI. The modern shell splits navigation in two
instead: the layout switcher already in the top bar is tier one, and a
new bar under it carries that layout's own top-level items, each group
opening its children in a menu. Nothing on the left, so a queue table or
a dashboard gets the whole window.

- Management's nine entries and Account's eleven suit a menu bar; the
  bar measures its items once per layout and folds whatever doesn't fit
  into "More", recomputing on resize from the cached widths.
- Settings keeps the sidebar. Nineteen groups is a configuration browser,
  not a set of destinations, and a menu bar stops helping however wide
  the window; the threshold is on the count, not on the name, so a
  changed schema can't strand anyone.
- Which shell to use is the reader's, beside the theme: user menu,
  Layout, Modern or Legacy. Modern is the default, and the choice is
  remembered in the browser rather than with the account — settings.json
  is shared with the webmail, which has no notion of this.
- A phone is unchanged. The section bar is hidden below md and the
  sidebar stays as the slide-over behind the hamburger.
- The tree walking both shells need moves to lib/navTree.ts, so the
  sidebar and the section bar agree on what is visible, what is locked
  and what is active.
This commit is contained in:
2026-09-19 22:03:53 -07:00
parent e04975915e
commit 8ae4156aee
7 changed files with 587 additions and 88 deletions
+336
View File
@@ -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 (
<DropdownMenuItem
key={sub.viewName}
className={cn(
pathMatchesView(currentPath, sectionName, sub.viewName) && 'bg-accent text-accent-foreground',
)}
onClick={() => onPick(sub.viewName, locked)}
>
<span className="truncate">{sub.name || 'Overview'}</span>
{locked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</DropdownMenuItem>
);
}
if (!subtreeHasVisibleLink(sub.items, edition)) return null;
const links = visibleLinks(sub.items, edition);
return (
<DropdownMenuGroup key={`${sub.name}-${i}`}>
<DropdownMenuLabel className="pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{sub.name}
</DropdownMenuLabel>
{links.map((l) => (
<DropdownMenuItem
key={l.viewName}
className={cn(
pathMatchesView(currentPath, sectionName, l.viewName) && 'bg-accent text-accent-foreground',
)}
onClick={() => onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')}
>
<span className="truncate">{l.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
);
})}
</>
);
}
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 (
<button
type="button"
ref={measureRef}
aria-current={isActive ? 'page' : undefined}
className={cn(TRIGGER_CLASS, isActive && TRIGGER_ACTIVE)}
onClick={() => onPick(viewName, locked)}
>
{name}
{locked && <Lock className="h-3 w-3" />}
</button>
);
}
const { name, items } = item.container;
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
ref={measureRef}
className={cn(TRIGGER_CLASS, 'data-[state=open]:text-foreground', containsActive && TRIGGER_ACTIVE)}
>
{name}
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70 transition-transform duration-200 data-[state=open]:rotate-180" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={0} className="w-60">
<MenuBody items={items} sectionName={sectionName} currentPath={currentPath} edition={edition} onPick={onPick} />
</DropdownMenuContent>
</DropdownMenu>
);
}
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<HTMLDivElement>(null);
const measured = useRef<number[]>([]);
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 (
<nav
aria-label={layout.name}
className="sticky top-14 z-30 hidden h-12 items-stretch border-b bg-background px-4 md:flex"
>
<div ref={scrollerRef} className="flex min-w-0 flex-1 items-stretch overflow-hidden">
{items.slice(0, shown).map((item, i) => (
<SectionNavItem
key={topItemKey(item)}
item={item}
sectionName={layout.name}
currentPath={location.pathname}
edition={edition}
onPick={onPick}
measureRef={widths === null ? measureRefFor(i) : undefined}
/>
))}
</div>
{overflowed.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
TRIGGER_CLASS,
'data-[state=open]:text-foreground',
overflowed.some((item) =>
'link' in item
? pathMatchesView(location.pathname, layout.name, item.link.viewName)
: subtreeContainsActive(item.container.items, location.pathname, layout.name),
) && TRIGGER_ACTIVE,
)}
>
<MoreHorizontal className="h-4 w-4" />
{t('nav.more', 'More')}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={0} className="w-60">
{overflowed.map((item, i) => {
if ('link' in item) {
const { name, viewName } = item.link;
const enterprise = checkIsEnterprise(viewName);
return (
<DropdownMenuItem
key={viewName}
className={cn(
pathMatchesView(location.pathname, layout.name, viewName) && 'bg-accent text-accent-foreground',
)}
onClick={() => onPick(viewName, enterprise && edition === 'community')}
>
<span className="truncate">{name}</span>
</DropdownMenuItem>
);
}
const { name, items: subItems } = item.container;
return (
<DropdownMenuGroup key={name}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{name}
</DropdownMenuLabel>
{visibleLinks(subItems, edition).map((l) => (
<DropdownMenuItem
key={l.viewName}
className={cn(
pathMatchesView(location.pathname, layout.name, l.viewName) &&
'bg-accent text-accent-foreground',
)}
onClick={() => onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')}
>
<span className="truncate">{l.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</nav>
);
}
+33 -82
View File
@@ -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)}
/>
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
<aside
className={cn(
'fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background',
mobileOnly && 'md:hidden',
)}
>
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{layout.name}
@@ -484,7 +432,10 @@ export function Sidebar() {
aria-label="Collapse sidebar"
title="Collapse sidebar"
onClick={toggleSidebarCollapsed}
className="hidden h-7 w-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground md:flex"
className={cn(
'hidden h-7 w-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground md:flex',
mobileOnly && 'md:hidden',
)}
>
<PanelLeftClose className="h-4 w-4" />
</button>
+34 -4
View File
@@ -8,7 +8,7 @@
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette } = LucideIcons;
const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate } = LucideIcons;
import { Button } from '@/components/ui/button';
import { CommandPalette } from '@/components/common/CommandPalette';
import {
@@ -33,7 +33,7 @@ import { sourceDownloadUrl } from '@/lib/sourceDownload';
import { visibleLayouts } from '@/lib/layout';
import { sectionLandingLink } from '@/lib/lastVisited';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/uiStore';
import { isAdminLayout, useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore';
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
import { createElement, useEffect, useState } from 'react';
@@ -57,6 +57,8 @@ export function TopBar() {
const toggleTheme = useUIStore((s) => s.toggleTheme);
const palette = useUIStore((s) => s.palette);
const setPalette = useUIStore((s) => s.setPalette);
const adminLayout = useUIStore((s) => s.adminLayout);
const setAdminLayout = useUIStore((s) => s.setAdminLayout);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const activeSection = useUIStore((s) => s.activeSection);
@@ -159,11 +161,12 @@ export function TopBar() {
if (firstLink) navigate(`/${layout.name}/${firstLink}`);
}}
className={cn(
'flex h-8 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground',
'flex h-8 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground lg:px-3',
isActive && 'bg-card text-primary shadow-soft',
)}
>
{createElement(getIcon(layout.icon), { className: 'h-4 w-4' })}
{createElement(getIcon(layout.icon), { className: 'h-4 w-4 shrink-0' })}
<span className="hidden lg:inline">{layout.name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{layout.name}</TooltipContent>
@@ -228,6 +231,33 @@ export function TopBar() {
</>
)}
{/* INBUXA: the shell is the reader's choice, the way the palette is. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LayoutTemplate className="mr-2 h-4 w-4" />
{t('nav.layoutMenu', 'Layout')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuRadioGroup
value={adminLayout}
onValueChange={(v) => isAdminLayout(v) && setAdminLayout(v)}
>
<DropdownMenuRadioItem value="modern" className="gap-2">
{t('nav.layoutModern', 'Modern')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="legacy" className="gap-2">
{t('nav.layoutLegacy', 'Legacy')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<p className="px-2 py-1.5 text-[11px] leading-snug text-muted-foreground">
{adminLayout === 'modern'
? t('nav.layoutModernHint', 'Sections across the top; the page gets the full width.')
: t('nav.layoutLegacyHint', 'The sidebar, as the old web UI had it.')}
</p>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* INBUXA: the same palettes as INBUXA webmail. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>