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
+24
View File
@@ -12,10 +12,26 @@ import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
/**
* INBUXA: which shell draws the navigation. "modern" is the two-tier shell —
* the layout switcher in the top bar over a section bar carrying that layout's
* own items, and no sidebar. "legacy" is the sidebar the old web UI had. A
* layout too deep for a menu bar keeps the sidebar under either setting.
*/
export type AdminLayout = 'modern' | 'legacy';
export const DEFAULT_ADMIN_LAYOUT: AdminLayout = 'modern';
export function isAdminLayout(value: unknown): value is AdminLayout {
return value === 'modern' || value === 'legacy';
}
interface UIState {
theme: Theme;
/** INBUXA: the color palette, the same set INBUXA webmail offers. */
palette: PaletteId;
/** INBUXA: the navigation shell, the reader's own choice. */
adminLayout: AdminLayout;
sidebarOpen: boolean;
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
sidebarCollapsed: boolean;
@@ -24,6 +40,7 @@ interface UIState {
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
setPalette: (palette: PaletteId) => void;
setAdminLayout: (layout: AdminLayout) => void;
/** INBUXA: take the theme stored with the account, without writing it back. */
applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void;
toggleSidebar: () => void;
@@ -48,6 +65,7 @@ export const useUIStore = create<UIState>()(
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
sidebarCollapsed: false,
palette: DEFAULT_PALETTE,
adminLayout: DEFAULT_ADMIN_LAYOUT,
activeSection: '',
toggleTheme: () => {
@@ -77,6 +95,10 @@ export const useUIStore = create<UIState>()(
queueAccountTheme(palette, null, get().theme);
},
setAdminLayout: (layout) => {
set({ adminLayout: layout });
},
applyAccountTheme: (palette, mode) => {
const next: Partial<UIState> = {};
if (palette) {
@@ -105,6 +127,7 @@ export const useUIStore = create<UIState>()(
partialize: (state) => ({
theme: state.theme,
palette: state.palette,
adminLayout: state.adminLayout,
sidebarCollapsed: state.sidebarCollapsed,
}),
onRehydrateStorage: () => {
@@ -112,6 +135,7 @@ export const useUIStore = create<UIState>()(
if (state) {
applyThemeClass(state.theme);
applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
if (!isAdminLayout(state.adminLayout)) state.adminLayout = DEFAULT_ADMIN_LAYOUT;
}
};
},