From 725540617b5a657d799e22580b2e4d049f028730 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 5 May 2026 07:39:02 +1000 Subject: [PATCH] shell: collapsible nav groups + mobile-friendly settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/components/layout/app-shell.tsx | 374 ++++++++++++++---- .../settings/llm-configurations-panel.tsx | 10 +- app/routes/settings.tsx | 4 +- 3 files changed, 314 insertions(+), 74 deletions(-) diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 6eaa7f0..297a90f 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -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 ` 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 { + if (typeof window === "undefined") return {} + try { + const raw = localStorage.getItem(NAV_GROUPS_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } 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>(() => + 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({ )} -