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, ShieldCheck, Megaphone, AlertOctagon, SearchCode, ChevronRight, // 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 { 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" type NavItem = { to: string icon: React.ComponentType<{ className?: string }> label: string end?: boolean } 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 }, ] // 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 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 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() 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 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
{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 ( [ "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 ? : 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) => (
  • )) )}
) }