import { useEffect, useRef, useState } from "react" const SIDEBAR_KEY = "crema.shell.sidebar" import { NavLink, 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, // 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 } const navItems: 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: "/ai", icon: Bot, label: "AI" }, { to: "/settings", icon: Settings, label: "Settings" }, // CREMA:NAV-ITEMS ] 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)) 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 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) => (
  • )) )}
) }