From e04d140d6a7fcd1f608751cbd57d52e693bd6245 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Sat, 23 May 2026 09:54:42 -0700 Subject: [PATCH] feat(dashboard): full-height sidebar shell (Vercel-style layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the app layout grid so the sidebar runs the full vertical extent of the page and the TopHeader sits only over the content column. - AppLayout / OrgLayout: outer flex changes from column to row - SidebarProvider: --header-offset defaults to 0 (was 3.5rem when not impersonating); only the impersonation banner shifts the sidebar - TopHeader: stripped of logo, org slug, and user avatar — kept project switcher, breadcrumb anchor, Docs/Changelog links, plus a new SidebarTrigger - New SidebarBrand mounts at the top of AppSidebar / OrgSidebar - New SidebarUserMenu mounts at the bottom of both sidebars - ThemeSwitcher moved into the user dropdown Targets the org-home redesign branch — meant to land on top of that work before merging the whole thing into main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/src/components/app-layout.tsx | 44 +-- .../dashboard/src/components/app-sidebar.tsx | 15 +- .../dashboard/src/components/org-sidebar.tsx | 12 +- .../src/components/sidebar-brand.tsx | 31 ++ .../src/components/sidebar-user-menu.tsx | 165 ++++++++ .../dashboard/src/components/top-header.tsx | 370 +++++------------- 6 files changed, 343 insertions(+), 294 deletions(-) create mode 100644 client/dashboard/src/components/sidebar-brand.tsx create mode 100644 client/dashboard/src/components/sidebar-user-menu.tsx diff --git a/client/dashboard/src/components/app-layout.tsx b/client/dashboard/src/components/app-layout.tsx index 70c4e2f824..10755c8c55 100644 --- a/client/dashboard/src/components/app-layout.tsx +++ b/client/dashboard/src/components/app-layout.tsx @@ -38,15 +38,15 @@ export const AppLayout = () => { return ( @@ -97,12 +97,12 @@ const AppLayoutContent = ({ isImpersonating: boolean; }) => { return ( -
- {isImpersonating && } - - -
- +
+ +
+ {isImpersonating && } + + @@ -208,22 +208,18 @@ export const OrgLayout = () => { style={ { "--sidebar-width": "14rem", - ...(isImpersonating - ? { - "--header-offset": "5.75rem", - "--banner-offset": "2.25rem", - } - : undefined), + "--header-offset": isImpersonating ? "2.25rem" : "0rem", + ...(isImpersonating ? { "--banner-offset": "2.25rem" } : undefined), } as React.CSSProperties } > -
- {isImpersonating && } - - -
- +
+ +
+ {isImpersonating && } + + diff --git a/client/dashboard/src/components/app-sidebar.tsx b/client/dashboard/src/components/app-sidebar.tsx index dffb603ebd..dd4a0235d4 100644 --- a/client/dashboard/src/components/app-sidebar.tsx +++ b/client/dashboard/src/components/app-sidebar.tsx @@ -8,9 +8,12 @@ import { Sidebar, SidebarContent, SidebarFooter, + SidebarHeader, SidebarMenu, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { SidebarBrand } from "@/components/sidebar-brand"; +import { SidebarUserMenu } from "@/components/sidebar-user-menu"; import { useSlugs } from "@/contexts/Sdk"; import { useTelemetry } from "@/contexts/Telemetry"; import { Scope } from "@/hooks/useRBAC"; @@ -131,7 +134,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) { return ( - + + + + ) {
- - + +
+ +
+
) { return ( - + + + + ) { + + + ); } diff --git a/client/dashboard/src/components/sidebar-brand.tsx b/client/dashboard/src/components/sidebar-brand.tsx new file mode 100644 index 0000000000..99a84e79c6 --- /dev/null +++ b/client/dashboard/src/components/sidebar-brand.tsx @@ -0,0 +1,31 @@ +import { Link } from "react-router"; + +import { useOrganization } from "@/contexts/Auth"; +import { useRBAC } from "@/hooks/useRBAC"; + +import { GramLogo } from "./gram-logo"; + +/** + * Top-of-sidebar brand strip: Gram logo, then a hairline divider with the + * current org slug below. Mounts inside . When the sidebar + * collapses to icons we hide the wordmark. + */ +export function SidebarBrand() { + const organization = useOrganization(); + const { hasAnyScope } = useRBAC(); + const canAccessOrgRoutes = hasAnyScope(["org:read", "org:admin"]); + + return ( +
+ + + +
+ {organization.slug} +
+
+ ); +} diff --git a/client/dashboard/src/components/sidebar-user-menu.tsx b/client/dashboard/src/components/sidebar-user-menu.tsx new file mode 100644 index 0000000000..03604b0331 --- /dev/null +++ b/client/dashboard/src/components/sidebar-user-menu.tsx @@ -0,0 +1,165 @@ +import { useCallback, useState } from "react"; + +import { useIsAdmin, useUser } from "@/contexts/Auth"; +import { useSdkClient, useSlugs } from "@/contexts/Sdk"; +import { useRBAC } from "@/hooks/useRBAC"; +import { useOrgRoutes, useRoutes } from "@/routes"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + ThemeSwitcher, +} from "@speakeasy-api/moonshine"; +import { + ArrowRightLeftIcon, + BugIcon, + CreditCardIcon, + LogOutIcon, + MailIcon, + MessageCircleIcon, + SettingsIcon, +} from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; + +export function SidebarUserMenu() { + const routes = useRoutes(); + const orgRoutes = useOrgRoutes(); + const user = useUser(); + const isAdmin = useIsAdmin(); + const client = useSdkClient(); + const { projectSlug } = useSlugs(); + const { hasAnyScope } = useRBAC(); + const canAccessOrgRoutes = hasAnyScope(["org:read", "org:admin"]); + const [pylonOpen, setPylonOpen] = useState(false); + + const togglePylon = useCallback(() => { + if (pylonOpen) { + window.Pylon?.("hide"); + } else { + window.Pylon?.("show"); + } + setPylonOpen((prev) => !prev); + }, [pylonOpen]); + + const userInitials = + user.displayName + ?.split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) || + user.email?.slice(0, 2).toUpperCase() || + "?"; + + return ( + + + + + + +
+

+ {user.displayName || "User"} +

+

+ {user.email} +

+
+
+ + + {projectSlug && ( + routes.settings.goTo()}> + + Project Settings + + )} + {canAccessOrgRoutes && ( + orgRoutes.billing.goTo()}> + + Billing + + )} + {isAdmin && ( + orgRoutes.adminSettings.goTo()}> + + Organization Override + + )} + + + + {"Pylon" in window && ( + + + {pylonOpen ? "Close Support" : "Get Support"} + + )} + + + + Email Team + + + + + + Bug or Feature Request + + + + +
+ Theme + +
+ + { + await client.auth.logout(); + window.location.href = "/login"; + }} + > + + Log out + +
+
+ ); +} diff --git a/client/dashboard/src/components/top-header.tsx b/client/dashboard/src/components/top-header.tsx index 97d3e0375e..755cf6d2a5 100644 --- a/client/dashboard/src/components/top-header.tsx +++ b/client/dashboard/src/components/top-header.tsx @@ -1,40 +1,13 @@ -import { - useIsAdmin, - useOrganization, - useProject, - useUser, -} from "@/contexts/Auth"; -import { useSdkClient, useSlugs } from "@/contexts/Sdk"; -import { useOrgRoutes, useRoutes } from "@/routes"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - ThemeSwitcher, -} from "@speakeasy-api/moonshine"; -import { - BugIcon, - CheckIcon, - ChevronsUpDown, - ArrowRightLeftIcon, - CreditCardIcon, - LogOutIcon, - MailIcon, - MessageCircleIcon, - PlusIcon, - SettingsIcon, -} from "lucide-react"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Link } from "react-router"; + +import { useOrganization, useProject } from "@/contexts/Auth"; +import { useSdkClient, useSlugs } from "@/contexts/Sdk"; import { useRBAC } from "@/hooks/useRBAC"; -import { GramLogo } from "./gram-logo"; +import { CheckIcon, ChevronsUpDown, PlusIcon } from "lucide-react"; + import { InputDialog } from "./input-dialog"; import { ProjectAvatar } from "./project-menu"; -import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Button } from "./ui/button"; import { Command, @@ -45,41 +18,25 @@ import { CommandList, } from "./ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { SidebarTrigger } from "./ui/sidebar"; +/** + * Slim top strip that sits only over the content column. Org/logo/user + * chrome moved into the sidebar (SidebarBrand + SidebarUserMenu); what + * remains is per-page context: sidebar trigger, breadcrumb anchor, project + * switcher, and right-side external links. + */ export function TopHeader() { - const routes = useRoutes(); - const orgRoutes = useOrgRoutes(); const organization = useOrganization(); const project = useProject(); - const user = useUser(); const { projectSlug } = useSlugs(); const [open, setOpen] = useState(false); - const isAdmin = useIsAdmin(); const { hasAnyScope } = useRBAC(); const canAccessOrgRoutes = hasAnyScope(["org:read", "org:admin"]); const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [pylonOpen, setPylonOpen] = useState(false); - const togglePylon = useCallback(() => { - if (pylonOpen) { - window.Pylon?.("hide"); - } else { - window.Pylon?.("show"); - } - setPylonOpen((prev) => !prev); - }, [pylonOpen]); const [newProjectName, setNewProjectName] = useState(""); const client = useSdkClient(); - const userInitials = - user.displayName - ?.split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2) || - user.email?.slice(0, 2).toUpperCase() || - "?"; - const handleProjectSelect = (slug: string) => { if (slug === "new-project") { setCreateDialogOpen(true); @@ -103,226 +60,107 @@ export function TopHeader() { return ( <> -
-
- {/* Logo */} +
+ + + {canAccessOrgRoutes ? ( - + {organization.slug} - - {/* Separator */} - - / + ) : ( + + {organization.slug} + )} - {/* Org link */} - {canAccessOrgRoutes ? ( - - {organization.slug} - - ) : ( - - {organization.slug} + {projectSlug && ( + <> + + / - )} - - {/* Project Switcher - hidden on org-level pages */} - {projectSlug && ( - <> - - / - - - - + + + +
+ - - {project?.slug || projectSlug || "Select"} - - - - - - -
- -
- - No projects found. - - {[...organization.projects] - .sort((a, b) => a.slug.localeCompare(b.slug)) - .map((p) => ( - handleProjectSelect(p.slug)} - className="flex cursor-pointer items-center gap-2" - > - - {p.slug} - {p.id === project.id && ( - - )} - - ))} - - -
- -
- - - )} -
+
+ + No projects found. + + {[...organization.projects] + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map((p) => ( + handleProjectSelect(p.slug)} + className="flex cursor-pointer items-center gap-2" + > + + {p.slug} + {p.id === project.id && ( + + )} + + ))} + + + + + + + + )} - {/* Right side - Nav links, Theme toggle & User menu */} -
- - - - - - - - -
-

- {user.displayName || "User"} -

-

- {user.email} -

-
-
- - - {projectSlug && ( - routes.settings.goTo()}> - - Project Settings - - )} - {canAccessOrgRoutes && ( - orgRoutes.billing.goTo()}> - - Billing - - )} - {isAdmin && ( - orgRoutes.adminSettings.goTo()} - > - - Organization Override - - )} - - - - {"Pylon" in window && ( - - - {pylonOpen ? "Close Support" : "Get Support"} - - )} - - - - Email Team - - - - - - Bug or Feature Request - - - - - { - await client.auth.logout(); - window.location.href = "/login"; - }} - > - - Log out - -
-
+
+ {createDialogOpen && (