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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,48 +25,18 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
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';
|
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 {
|
interface AutoOpenCollapsibleProps {
|
||||||
containsActive: boolean;
|
containsActive: boolean;
|
||||||
children: React.ReactNode;
|
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;
|
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
|
||||||
|
|
||||||
interface SidebarSubItemProps {
|
interface SidebarSubItemProps {
|
||||||
@@ -300,22 +249,6 @@ function SidebarTopItem({
|
|||||||
return null;
|
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. */
|
/** INBUXA: one entry of the collapsed sidebar: its tile, a label on hover, a menu for a group. */
|
||||||
function RailItem({
|
function RailItem({
|
||||||
item,
|
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 navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const activeSection = useUIStore((s) => s.activeSection);
|
const activeSection = useUIStore((s) => s.activeSection);
|
||||||
@@ -427,9 +369,10 @@ export function Sidebar() {
|
|||||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||||
if (!layout) return null;
|
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 =
|
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) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -474,7 +417,12 @@ export function Sidebar() {
|
|||||||
className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
|
className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
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">
|
<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">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{layout.name}
|
{layout.name}
|
||||||
@@ -484,7 +432,10 @@ export function Sidebar() {
|
|||||||
aria-label="Collapse sidebar"
|
aria-label="Collapse sidebar"
|
||||||
title="Collapse sidebar"
|
title="Collapse sidebar"
|
||||||
onClick={toggleSidebarCollapsed}
|
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" />
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as LucideIcons from 'lucide-react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { CommandPalette } from '@/components/common/CommandPalette';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +33,7 @@ import { sourceDownloadUrl } from '@/lib/sourceDownload';
|
|||||||
import { visibleLayouts } from '@/lib/layout';
|
import { visibleLayouts } from '@/lib/layout';
|
||||||
import { sectionLandingLink } from '@/lib/lastVisited';
|
import { sectionLandingLink } from '@/lib/lastVisited';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { isAdminLayout, useUIStore } from '@/stores/uiStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||||
import { createElement, useEffect, useState } from 'react';
|
import { createElement, useEffect, useState } from 'react';
|
||||||
@@ -57,6 +57,8 @@ export function TopBar() {
|
|||||||
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
||||||
const palette = useUIStore((s) => s.palette);
|
const palette = useUIStore((s) => s.palette);
|
||||||
const setPalette = useUIStore((s) => s.setPalette);
|
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 toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const activeSection = useUIStore((s) => s.activeSection);
|
const activeSection = useUIStore((s) => s.activeSection);
|
||||||
@@ -159,11 +161,12 @@ export function TopBar() {
|
|||||||
if (firstLink) navigate(`/${layout.name}/${firstLink}`);
|
if (firstLink) navigate(`/${layout.name}/${firstLink}`);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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',
|
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>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{layout.name}</TooltipContent>
|
<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. */}
|
{/* INBUXA: the same palettes as INBUXA webmail. */}
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
|
|||||||
@@ -301,6 +301,14 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"label": "INBUXA Admin {{version}}"
|
"label": "INBUXA Admin {{version}}"
|
||||||
},
|
},
|
||||||
|
"nav": {
|
||||||
|
"layoutLegacy": "Legacy",
|
||||||
|
"layoutLegacyHint": "The sidebar, as the old web UI had it.",
|
||||||
|
"layoutMenu": "Layout",
|
||||||
|
"layoutModern": "Modern",
|
||||||
|
"layoutModernHint": "Sections across the top; the page gets the full width.",
|
||||||
|
"more": "More"
|
||||||
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"backToLogin": "Back to login",
|
"backToLogin": "Back to login",
|
||||||
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
|
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSetting
|
|||||||
import { setLocale } from '@/i18n';
|
import { setLocale } from '@/i18n';
|
||||||
import { TopBar } from '@/components/layout/TopBar';
|
import { TopBar } from '@/components/layout/TopBar';
|
||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
import { SectionNav } from '@/components/layout/SectionNav';
|
||||||
import { MainContent } from '@/components/layout/MainContent';
|
import { MainContent } from '@/components/layout/MainContent';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
@@ -31,6 +32,8 @@ import { usePermissions } from '@/hooks/usePermissions';
|
|||||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import { friendlyName } from '@/hooks/useGlobalSearch';
|
import { friendlyName } from '@/hooks/useGlobalSearch';
|
||||||
import { rememberLastVisited, sectionLandingLink } from '@/lib/lastVisited';
|
import { rememberLastVisited, sectionLandingLink } from '@/lib/lastVisited';
|
||||||
|
import { fitsSectionNav } from '@/lib/navTree';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const BootstrapWizard = lazy(() =>
|
const BootstrapWizard = lazy(() =>
|
||||||
import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })),
|
import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })),
|
||||||
@@ -83,6 +86,8 @@ export default function AdminPanel() {
|
|||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
|
const activeSection = useUIStore((s) => s.activeSection);
|
||||||
|
const adminLayout = useUIStore((s) => s.adminLayout);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||||
const { canViewObject } = usePermissions();
|
const { canViewObject } = usePermissions();
|
||||||
@@ -188,6 +193,22 @@ export default function AdminPanel() {
|
|||||||
};
|
};
|
||||||
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INBUXA: which shell draws the navigation. The modern one puts the layout's
|
||||||
|
* items in a bar under the top bar and drops the sidebar; a layout with too
|
||||||
|
* many top-level groups for a menu bar keeps the sidebar whatever the reader
|
||||||
|
* chose, so Settings never turns into nineteen dropdowns.
|
||||||
|
*/
|
||||||
|
const currentLayout = useMemo(() => {
|
||||||
|
if (!schema) return null;
|
||||||
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
|
const hasPerm = (perm: string) => permissions.includes(perm);
|
||||||
|
const name = (section ?? activeSection ?? '').toLowerCase();
|
||||||
|
return visibleLayouts(schema, edition, canGet, hasPerm).find((l) => l.name.toLowerCase() === name) ?? null;
|
||||||
|
}, [schema, edition, permissions, section, activeSection]);
|
||||||
|
|
||||||
|
const useSectionNav = adminLayout === 'modern' && currentLayout !== null && fitsSectionNav(currentLayout, edition);
|
||||||
|
|
||||||
const isBootstrapMode = useMemo(() => {
|
const isBootstrapMode = useMemo(() => {
|
||||||
if (!schema) return false;
|
if (!schema) return false;
|
||||||
if (!canViewObject('x:Bootstrap')) return false;
|
if (!canViewObject('x:Bootstrap')) return false;
|
||||||
@@ -288,10 +309,14 @@ export default function AdminPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
{useSectionNav && currentLayout && <SectionNav layout={currentLayout} />}
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<Sidebar />
|
<Sidebar mobileOnly={useSectionNav} />
|
||||||
<main
|
<main
|
||||||
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64') : ''}`}
|
className={cn(
|
||||||
|
'flex-1 overflow-auto bg-content-background p-6 transition-[margin]',
|
||||||
|
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||||
<MainContent viewName={viewName} id={id} section={section} />
|
<MainContent viewName={viewName} id={id} section={section} />
|
||||||
|
|||||||
@@ -12,10 +12,26 @@ import { persist } from 'zustand/middleware';
|
|||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
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 {
|
interface UIState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
/** INBUXA: the color palette, the same set INBUXA webmail offers. */
|
/** INBUXA: the color palette, the same set INBUXA webmail offers. */
|
||||||
palette: PaletteId;
|
palette: PaletteId;
|
||||||
|
/** INBUXA: the navigation shell, the reader's own choice. */
|
||||||
|
adminLayout: AdminLayout;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
|
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -24,6 +40,7 @@ interface UIState {
|
|||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setPalette: (palette: PaletteId) => void;
|
setPalette: (palette: PaletteId) => void;
|
||||||
|
setAdminLayout: (layout: AdminLayout) => void;
|
||||||
/** INBUXA: take the theme stored with the account, without writing it back. */
|
/** INBUXA: take the theme stored with the account, without writing it back. */
|
||||||
applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void;
|
applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -48,6 +65,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
palette: DEFAULT_PALETTE,
|
palette: DEFAULT_PALETTE,
|
||||||
|
adminLayout: DEFAULT_ADMIN_LAYOUT,
|
||||||
activeSection: '',
|
activeSection: '',
|
||||||
|
|
||||||
toggleTheme: () => {
|
toggleTheme: () => {
|
||||||
@@ -77,6 +95,10 @@ export const useUIStore = create<UIState>()(
|
|||||||
queueAccountTheme(palette, null, get().theme);
|
queueAccountTheme(palette, null, get().theme);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setAdminLayout: (layout) => {
|
||||||
|
set({ adminLayout: layout });
|
||||||
|
},
|
||||||
|
|
||||||
applyAccountTheme: (palette, mode) => {
|
applyAccountTheme: (palette, mode) => {
|
||||||
const next: Partial<UIState> = {};
|
const next: Partial<UIState> = {};
|
||||||
if (palette) {
|
if (palette) {
|
||||||
@@ -105,6 +127,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
palette: state.palette,
|
palette: state.palette,
|
||||||
|
adminLayout: state.adminLayout,
|
||||||
sidebarCollapsed: state.sidebarCollapsed,
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => {
|
onRehydrateStorage: () => {
|
||||||
@@ -112,6 +135,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
if (state) {
|
if (state) {
|
||||||
applyThemeClass(state.theme);
|
applyThemeClass(state.theme);
|
||||||
applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
|
applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
|
||||||
|
if (!isAdminLayout(state.adminLayout)) state.adminLayout = DEFAULT_ADMIN_LAYOUT;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user