import { useEffect, useMemo, useRef, useState } from "react" const SIDEBAR_KEY = "crema.shell.sidebar" const NAV_GROUPS_KEY = "crema.shell.nav-groups" import { NavLink, useLocation, useNavigate } from "react-router" import { Bell, LayoutDashboard, Boxes, Activity, Building2, Sparkles, Bot, BookOpen, Settings, Search, PanelLeftClose, PanelLeftOpen, User as UserIcon, LogOut, HelpCircle, Menu, Play, HardDrive, Users as UsersIcon, KeyRound, Webhook as WebhookIcon, CalendarClock, Gauge, UserCheck, Network, Building, ShieldCheck, Megaphone, AlertOctagon, SearchCode, ChevronDown, Database, Plug, MessageSquare, Eye, LayoutGrid, CreditCard, // CREMA:NAV-ICONS } from "lucide-react" import { useBrand, useUser, type Brand, type User, } from "~/lib/identity" import { Appbar, AppbarActions, AppbarSpacer, AppbarTitle, } from "~/components/layout/appbar" import { ThemeToggle } from "~/components/layout/theme-toggle" import { BackgroundPicker } from "~/components/layout/background-picker" import { FontSizePicker } from "~/components/layout/font-size-picker" import { SurfacePicker } from "~/components/layout/surface-picker" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Popover, PopoverContent, PopoverTrigger, } from "~/components/ui/popover" import { profileInitials, useProfile } from "~/lib/profile" import { signOut, useSession } from "~/lib/session" import { capabilityForPath, useCapabilities } from "~/lib/capabilities" import { addNotification, dismiss, dismissAll, markAllRead, markRead, seedIfEmpty, unreadCount, useNotifications, } from "~/lib/notifications" import { Button } from "~/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu" import { Input } from "~/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from "~/components/ui/sheet" import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog" import { RouteGuard } from "~/components/route-guard" type NavItem = { to: string icon: React.ComponentType<{ className?: string }> label: string end?: boolean } type NavGroup = { key: string label: string icon: React.ComponentType<{ className?: 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 }, ] // 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", icon: Building2, items: [ { to: "/tenants", icon: Building2, label: "Tenants" }, { to: "/memberships", icon: UserCheck, label: "Memberships" }, { to: "/organizations", icon: Building, label: "Organizations" }, { to: "/users", icon: UsersIcon, label: "Users" }, { to: "/sso", icon: ShieldCheck, label: "SSO" }, ], }, { key: "billing", label: "Billing", icon: CreditCard, items: [ { to: "/apps", icon: LayoutGrid, label: "Apps" }, { to: "/plan", icon: CreditCard, label: "Plan" }, { to: "/entitlements", icon: Gauge, label: "Entitlements" }, ], }, { key: "data", label: "Data", icon: Database, items: [ { to: "/storage", icon: HardDrive, label: "Storage" }, { to: "/buckets", icon: Boxes, label: "Buckets" }, { to: "/secrets", icon: KeyRound, label: "Secrets" }, { to: "/integrations", icon: Plug, label: "Integrations" }, ], }, { key: "integrations", label: "Integrations", icon: Plug, items: [ { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" }, { to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" }, { to: "/networking", icon: Network, label: "Networking" }, ], }, { key: "comms", label: "Communications", icon: MessageSquare, items: [ { to: "/announcements", icon: Megaphone, label: "Announcements" }, { to: "/status-page", icon: AlertOctagon, label: "Status page" }, ], }, { key: "observability", label: "Observability", icon: Eye, items: [ { to: "/monitoring", icon: Gauge, label: "Monitoring" }, { to: "/activity", icon: Activity, label: "Audit log" }, ], }, { key: "ai", label: "AI & Search", icon: Sparkles, 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 ] 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 user?: User /** * Optional theme name. When set, the shell wraps itself in * `data-theme={theme}` so a route can opt into an alternate theme without * the caller having to add an extra wrapping div. */ theme?: string } export function AppShell({ children, brand: brandOverride, user: userOverride, theme, }: AppShellProps) { const defaultBrand = useBrand() const defaultUser = useUser() const profile = useProfile() const session = useSession() const caps = useCapabilities() const navigate = useNavigate() const brand = brandOverride ?? defaultBrand // Prefer the live session for identity, fall back to the stub user. const user = userOverride ?? { name: session?.name || defaultUser.name, email: session?.email || defaultUser.email, initials: profileInitials(session?.name || defaultUser.name), } // Protected shell: bounce to /login when there's no session. useEffect(() => { if (typeof window === "undefined") return if (!session) { const next = encodeURIComponent( window.location.pathname + window.location.search, ) navigate(`/login?next=${next}`, { replace: true }) } }, [session, navigate]) // All hooks must run unconditionally — keep them above the session // short-circuit so a sign-out doesn't reduce the hook count and trip // React's "rendered fewer hooks than expected" check. const [expanded, setExpanded] = useState(() => { if (typeof window === "undefined") return false return localStorage.getItem(SIDEBAR_KEY) === "1" }) useEffect(() => { localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0") }, [expanded]) const [mobileOpen, setMobileOpen] = useState(false) const [scriptsOpen, setScriptsOpen] = useState(false) useScriptsHotkey(() => setScriptsOpen(true)) const location = useLocation() // Filter the nav by what the active session can actually reach. A // capability map exists for every protected route — items without one // (or whose capability isn't held) are dropped here, so the sidebar // doesn't advertise routes the user will only hit a 403 from. const allowed = (item: NavItem): boolean => { const cap = capabilityForPath(item.to) if (!cap) return true // unknown routes default to visible return caps.has(cap) } const visiblePinnedTop = useMemo( () => pinnedTop.filter(allowed), [caps], ) const visiblePinnedBottom = useMemo( () => pinnedBottom.filter(allowed), [caps], ) const visibleNavGroups: NavGroup[] = useMemo( () => navGroups .map((g) => ({ ...g, items: g.items.filter(allowed) })) .filter((g) => g.items.length > 0), [caps], ) const visibleExtraItems = useMemo( () => extraNavItems.filter(allowed), [caps], ) const visibleAllNavItems: NavItem[] = useMemo( () => [ ...visiblePinnedTop, ...visibleNavGroups.flatMap((g) => g.items), ...visibleExtraItems, ...visiblePinnedBottom, ], [visiblePinnedTop, visibleNavGroups, visibleExtraItems, visiblePinnedBottom], ) const activeGroupKey = useMemo( () => visibleNavGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)), )?.key ?? null, [location.pathname, visibleNavGroups], ) 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 return (
Skip to main content
{/* Mobile-only menu trigger, floating top-left of main */}
{brand.name}
{/* Floating glass pill, top-right, replacing the appbar action group */}
{profile.avatarUrl ? ( ) : null} {user.initials} {user.name} {user.email} navigate("/profile")} > Profile navigate("/settings")} > Settings Help { signOut() navigate("/login", { replace: true }) }} > Sign out
{/* Centered content column. Caps line lengths and frames pages on wide displays so the canvas reads as composed instead of one floating card in a sea of black. The floating actions pill is fixed to the viewport edge and lives outside this column, so it stays clear regardless of cap width. */}
{children}
) } 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 ( [ "relative flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard", // 2px left accent rail on active. Absolute-positioned so the rail // anchors to the rail's edge regardless of per-item left padding, // and a fixed 14px height keeps it from filling tall rows. "before:absolute before:left-0 before:top-1/2 before:h-3.5 before:w-[2px] before:-translate-y-1/2 before:rounded-r-full before:bg-primary before:opacity-0 before:transition-opacity before:duration-fast", 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/[0.08] text-primary before:opacity-100" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground", ].join(" ") } > {showIcon ? : null} {expanded ? {item.label} : null} ) } function NotificationDispatcher() { // Hidden bridge so the action bus can create real notifications: // fill notify-title "Hello" // fill notify-body "Body text" // fill notify-kind "info" # info|success|warning|error // fill notify-href "/library" # optional // click notify-create const titleRef = useRef(null) const bodyRef = useRef(null) const kindRef = useRef(null) const hrefRef = useRef(null) const submit = () => { const title = titleRef.current?.value.trim() ?? "" if (!title) return const body = bodyRef.current?.value.trim() || undefined const rawKind = (kindRef.current?.value || "info").trim().toLowerCase() const kind = ( ["info", "success", "warning", "error"].includes(rawKind) ? rawKind : "info" ) as "info" | "success" | "warning" | "error" const href = hrefRef.current?.value.trim() || undefined addNotification({ title, body, kind, href }) if (titleRef.current) titleRef.current.value = "" if (bodyRef.current) bodyRef.current.value = "" if (kindRef.current) kindRef.current.value = "" if (hrefRef.current) hrefRef.current.value = "" } return (
) } function NotificationsBell() { const items = useNotifications() const unread = unreadCount(items) const navigate = useNavigate() useEffect(() => { seedIfEmpty() }, []) return ( {unread > 0 && ( {unread > 9 ? "9+" : unread} )} } />
Notifications
    {items.length === 0 ? (
  • No notifications.
  • ) : ( items.map((n) => (
  • )) )}
) }