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, Menu, Play, HardDrive, Users as UsersIcon, KeyRound, Webhook as WebhookIcon, CalendarClock, Gauge, UserCheck, Network, Building, ShieldCheck, Megaphone, AlertOctagon, SearchCode, ChevronDown, Database, Plug, MessageSquare, CreditCard, Flag, Eye, // 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 { endImpersonation, signOut, useSession } from "~/lib/session" import { capabilityForPath, useCapabilities } from "~/lib/capabilities" import { addNotification, dismiss, dismissAll, markAllRead, markRead, 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. // Audit log is cross-cutting rather than owned by any one group, so it sits // here next to Settings. const pinnedBottom: NavItem[] = [ { to: "/audit-log", icon: Activity, label: "Audit log" }, { to: "/settings", icon: Settings, label: "Settings" }, ] const navGroups: NavGroup[] = [ { key: "tenancy", label: "Tenancy", icon: Building2, items: [ { to: "/tenants", icon: Building2, label: "Tenants" }, { to: "/organizations", icon: Building, label: "Organizations" }, { to: "/users", icon: UsersIcon, label: "Users" }, { to: "/memberships", icon: UserCheck, label: "Memberships" }, { to: "/sso", icon: ShieldCheck, label: "SSO" }, ], }, { key: "billing", label: "Billing", icon: CreditCard, // One item today (Plan/Entitlements/Apps collapsed here — none has a live // endpoint yet). They split back into siblings under this group once wired. items: [{ to: "/billing", icon: CreditCard, label: "Plan & usage" }], }, { 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" }, ], }, { key: "automation", label: "Automation", icon: Plug, items: [ { to: "/feature-flags", icon: Flag, label: "Feature flags" }, { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" }, { to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" }, { to: "/integrations", icon: Plug, label: "Integrations" }, ], }, { key: "infrastructure", label: "Infrastructure", icon: Network, items: [ { to: "/networking", icon: Network, label: "Networking" }, { to: "/monitoring", icon: Gauge, label: "Monitoring" }, ], }, { key: "comms", label: "Communications", icon: MessageSquare, items: [ { to: "/announcements", icon: Megaphone, label: "Announcements" }, { to: "/status-page", icon: AlertOctagon, label: "Status page" }, ], }, { 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 true // Default to expanded on first run — an icon-only rail of ~18 pictograms is // unreadable to anyone who hasn't memorised it. Collapse stays available and // is remembered once chosen. const stored = localStorage.getItem(SIDEBAR_KEY) return stored === null ? true : stored === "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 { 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. */} {/* pt-16 on mobile reserves a row for the floating hamburger (left) and actions pill (right) so they never overlap the page H1; the desktop rail/pill sit outside this column, so pt drops to p-6. */}
{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 ImpersonationBanner() { const session = useSession() const navigate = useNavigate() if (!session?.impersonatedBy) return null return (
Viewing as {session.email} — actions you take happen as this user.
) } function NotificationsBell() { const items = useNotifications() const unread = unreadCount(items) const navigate = useNavigate() return ( {unread > 0 && ( {unread > 9 ? "9+" : unread} )} } />
Notifications
    {items.length === 0 ? (
  • No notifications.
  • ) : ( items.map((n) => (
  • )) )}
) }