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
+125
View File
@@ -0,0 +1,125 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
/**
* INBUXA: walking the server's menu tree, shared by the two shells that draw it
* — the sidebar (legacy) and the section bar (modern). The tree itself is the
* schema's `layouts`, so neither shell may assume a depth or a fan-out.
*/
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import { isLinkEnterprise, isLinkVisible } from '@/lib/layout';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
/**
* Past this many top-level entries a layout is a configuration browser rather
* than a set of destinations, and a menu bar stops helping however wide the
* window: on the stock schema that is Settings, with its nineteen groups, which
* keeps the sidebar under either shell. Management (nine) and Account (eleven)
* fit, and anything left over folds into the section bar's "More".
*/
export const SECTION_NAV_MAX_ITEMS = 12;
export function resolveViewPath(sectionName: string, viewName: string): string {
return `/${sectionName}/${viewName}`;
}
export 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;
}
export 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),
);
}
export function checkIsEnterprise(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return false;
const edition = useAccountStore.getState().edition;
return isLinkEnterprise(schema, viewName, edition);
}
export 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;
}
export 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;
}
/** Every visible link under a container, flattened, deeper names prefixed with their group. */
export 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;
}
/** Whether a top-level entry has anything left to show once edition and permissions are applied. */
export function topItemVisible(item: LayoutItem, edition: string): boolean {
if ('link' in item) {
if (!checkLinkVisible(item.link.viewName)) return false;
return !(checkIsEnterprise(item.link.viewName) && edition === 'oss');
}
return subtreeHasVisibleLink(item.container.items, edition);
}
/** A stable key for a top-level entry, for React lists. */
export function topItemKey(item: LayoutItem): string {
return 'link' in item ? item.link.viewName : item.container.name;
}
/** Whether this layout is shallow enough to be drawn as a menu bar at all. */
export function fitsSectionNav(layout: Layout, edition: string): boolean {
return layout.items.filter((item) => topItemVisible(item, edition)).length <= SECTION_NAV_MAX_ITEMS;
}