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:
jules
2026-05-05 07:39:02 +10:00
parent 5b0281574e
commit 725540617b
3 changed files with 314 additions and 74 deletions

View File

@@ -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" },
]
// 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: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
{ 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" },
{ to: "/search", icon: SearchCode, label: "Search" },
],
},
{
key: "ai",
label: "AI & Search",
items: [
{ to: "/ai", icon: Bot, label: "AI" },
{ to: "/settings", icon: Settings, label: "Settings" },
{ 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
<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 (
<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(" ")
}
<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"
>
<Icon className="size-5 shrink-0" />
{expanded && <span className="truncate">{item.label}</span>}
</NavLink>
<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
return (
<NavLink
<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}
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(" ")
}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
{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-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"
>
<Icon className="size-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
<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"

View File

@@ -226,7 +226,7 @@ export function LlmConfigurationsPanel() {
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<CardHeader className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start">
<div className="flex-1">
<CardTitle>LLM configurations</CardTitle>
<CardDescription>
@@ -235,7 +235,7 @@ export function LlmConfigurationsPanel() {
</CardDescription>
</div>
{usage ? (
<div className="flex shrink-0 flex-col items-end rounded-md border bg-muted/40 px-3 py-2 text-right">
<div className="flex shrink-0 flex-col items-start rounded-md border bg-muted/40 px-3 py-2 text-left sm:items-end sm:text-right">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
Spend (30d)
</span>
@@ -248,7 +248,7 @@ export function LlmConfigurationsPanel() {
</span>
</div>
) : null}
<div className="flex shrink-0 gap-2">
<div className="flex shrink-0 flex-wrap gap-2">
{hasLocalSettings && configs.length === 0 ? (
<Button
variant="outline"
@@ -368,7 +368,7 @@ function ConfigRow({
onDelete: () => void
}) {
return (
<li className="flex items-center justify-between gap-3 px-1 py-2.5">
<li className="flex flex-col items-stretch justify-between gap-3 px-1 py-2.5 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
@@ -421,7 +421,7 @@ function ConfigRow({
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="flex shrink-0 items-center justify-end gap-3 pl-7 sm:pl-0">
{spend && spend.cost_cents > 0 ? (
<div className="flex flex-col items-end text-right">
<span className="font-mono text-sm tabular-nums">

View File

@@ -171,10 +171,10 @@ export default function SettingsRoute() {
return (
<AppShell>
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
<div className="grid gap-6 pt-10 md:grid-cols-[14rem_1fr] md:pt-0">
<nav
aria-label="Settings sections"
className="flex flex-row gap-1 overflow-x-auto md:flex-col md:gap-0.5"
className="flex flex-row flex-wrap gap-1 md:flex-col md:flex-nowrap md:gap-0.5"
>
{sections.map((s) => {
const Icon = s.icon