shell: collapsible nav groups + mobile-friendly settings
- Reorganize sidenav into collapsible groups (Tenancy, Data, Integrations, Communications, Observability, AI & Search) with Overview/Settings pinned at top/bottom. Group open/close persists in localStorage; the group containing the active route auto-opens. Icon-only collapsed rail flattens to a single icon column. Sub-items inside groups drop their per-item icons and indent under the header. - Fix mobile sheet scroll — the nav couldn't reach items past viewport height. SheetContent is now flex-col h-svh, header shrink-0, nav flex-1 min-h-0 overflow-y-auto. - Settings page mobile fixes: section nav wraps instead of horizontal scroll, top padding clears the floating actions pill, LLM config card header and rows stack on narrow widths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
const SIDEBAR_KEY = "crema.shell.sidebar"
|
||||
import { NavLink, useNavigate } from "react-router"
|
||||
const NAV_GROUPS_KEY = "crema.shell.nav-groups"
|
||||
import { NavLink, useLocation, useNavigate } from "react-router"
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
Megaphone,
|
||||
AlertOctagon,
|
||||
SearchCode,
|
||||
ChevronRight,
|
||||
// CREMA:NAV-ICONS
|
||||
} from "lucide-react"
|
||||
|
||||
@@ -97,28 +99,102 @@ type NavItem = {
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
type NavGroup = {
|
||||
key: string
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
// Pinned items render flat at the top of the rail, above any groups.
|
||||
const pinnedTop: NavItem[] = [
|
||||
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
|
||||
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||||
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
||||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||
{ to: "/networking", icon: Network, label: "Networking" },
|
||||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
{ to: "/search", icon: SearchCode, label: "Search" },
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
]
|
||||
|
||||
// Pinned items render flat at the bottom of the rail, below all groups.
|
||||
const pinnedBottom: NavItem[] = [
|
||||
{ to: "/settings", icon: Settings, label: "Settings" },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
key: "tenancy",
|
||||
label: "Tenancy",
|
||||
items: [
|
||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "data",
|
||||
label: "Data",
|
||||
items: [
|
||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
label: "Integrations",
|
||||
items: [
|
||||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||
{ to: "/networking", icon: Network, label: "Networking" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "comms",
|
||||
label: "Communications",
|
||||
items: [
|
||||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "observability",
|
||||
label: "Observability",
|
||||
items: [
|
||||
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: "AI & Search",
|
||||
items: [
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
{ to: "/search", icon: SearchCode, label: "Search" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Items appended by `crema add <lib>` land here. Rendered ungrouped at
|
||||
// the bottom of the groups, above the pinned footer.
|
||||
const extraNavItems: NavItem[] = [
|
||||
// CREMA:NAV-ITEMS
|
||||
]
|
||||
|
||||
// Flat list — used by the icon-only collapsed rail, where group headers
|
||||
// don't render and items appear as a single column of icons.
|
||||
const allNavItems: NavItem[] = [
|
||||
...pinnedTop,
|
||||
...navGroups.flatMap((g) => g.items),
|
||||
...extraNavItems,
|
||||
...pinnedBottom,
|
||||
]
|
||||
|
||||
function readNavGroupState(): Record<string, boolean> {
|
||||
if (typeof window === "undefined") return {}
|
||||
try {
|
||||
const raw = localStorage.getItem(NAV_GROUPS_KEY)
|
||||
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
type AppShellProps = {
|
||||
children: React.ReactNode
|
||||
brand?: Brand
|
||||
@@ -174,6 +250,35 @@ export function AppShell({
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||
useScriptsHotkey(() => setScriptsOpen(true))
|
||||
|
||||
const location = useLocation()
|
||||
const activeGroupKey = useMemo(
|
||||
() =>
|
||||
navGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)))
|
||||
?.key ?? null,
|
||||
[location.pathname],
|
||||
)
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
|
||||
readNavGroupState(),
|
||||
)
|
||||
|
||||
// Auto-open the group that owns the current route on first mount or
|
||||
// navigation, but never auto-close — the user's explicit toggles win.
|
||||
useEffect(() => {
|
||||
if (!activeGroupKey) return
|
||||
setOpenGroups((prev) =>
|
||||
prev[activeGroupKey] ? prev : { ...prev, [activeGroupKey]: true },
|
||||
)
|
||||
}, [activeGroupKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(NAV_GROUPS_KEY, JSON.stringify(openGroups))
|
||||
}, [openGroups])
|
||||
|
||||
const toggleGroup = (key: string) =>
|
||||
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
if (!session) return null
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
@@ -218,31 +323,65 @@ export function AppShell({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={expanded ? undefined : item.label}
|
||||
data-action={`nav-${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
|
||||
expanded ? "justify-start" : "justify-center",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
{expanded && <span className="truncate">{item.label}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{expanded ? (
|
||||
<>
|
||||
{pinnedTop.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
|
||||
{navGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
return (
|
||||
<div key={group.key} className="mt-1.5 flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action={`nav-group-${group.key}`}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground/80 transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={[
|
||||
"size-3 shrink-0 transition-transform duration-fast ease-standard",
|
||||
isOpen ? "rotate-90" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
<span className="truncate">{group.label}</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded inGroup />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{extraNavItems.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||
{extraNavItems.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||
{pinnedBottom.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Icon-only rail: flat list, no group headers.
|
||||
<>
|
||||
{allNavItems.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded={false} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t p-2">
|
||||
@@ -276,8 +415,11 @@ export function AppShell({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="flex h-svh w-72 flex-col p-0"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<BrandIcon className="size-4" />
|
||||
@@ -285,30 +427,79 @@ export function AppShell({
|
||||
{brand.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{pinnedTop.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{navGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-action={`nav-mobile-${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
<div key={group.key} className="mt-1.5 flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action={`nav-mobile-group-${group.key}`}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground/80 transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={[
|
||||
"size-3 shrink-0 transition-transform duration-fast ease-standard",
|
||||
isOpen ? "rotate-90" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
<span className="truncate">{group.label}</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
inGroup
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{extraNavItems.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||
{extraNavItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||
{pinnedBottom.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -404,6 +595,55 @@ export function AppShell({
|
||||
)
|
||||
}
|
||||
|
||||
function NavRow({
|
||||
item,
|
||||
expanded,
|
||||
mobile = false,
|
||||
inGroup = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: NavItem
|
||||
expanded: boolean
|
||||
mobile?: boolean
|
||||
/** True when rendered inside a collapsible group — hides the per-item
|
||||
* icon and indents the label so it aligns under the group header. */
|
||||
inGroup?: boolean
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
const prefix = mobile ? "nav-mobile-" : "nav-"
|
||||
// Icons are hidden inside groups in the expanded rail. The collapsed
|
||||
// icon-only rail (expanded=false) always shows icons regardless.
|
||||
const showIcon = !inGroup || !expanded
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={expanded ? undefined : item.label}
|
||||
onClick={onNavigate}
|
||||
data-action={`${prefix}${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
|
||||
expanded
|
||||
? inGroup
|
||||
? // Indent the label by chevron(12) + gap(8) = 20px so it
|
||||
// visually aligns under the group header label.
|
||||
"justify-start pl-[1.625rem] pr-3"
|
||||
: "justify-start px-3"
|
||||
: "justify-center px-3",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{showIcon ? <Icon className="size-5 shrink-0" /> : null}
|
||||
{expanded ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationDispatcher() {
|
||||
// Hidden bridge so the action bus can create real notifications:
|
||||
// fill notify-title "Hello"
|
||||
|
||||
Reference in New Issue
Block a user