collapsible sidebar

This commit is contained in:
Francesco Sansalvadore committed 2026-04-03 12:11:54 +02:00
1 parent d4c5f6102c
commit 75ca80f465
12 files changed
+399 -315

No files matched your search

+93 -70
View File
@@ -30,15 +30,18 @@ import {
import { Route } from '../ui/ui.types'
import {
useIsAPIDocsSidePanelEnabled,
useIsNavigationV2Enabled,
useIsPlatformWebhooksEnabled,
useUnifiedLogsPreview,
} from './App/FeaturePreview/FeaturePreviewContext'
import { ConnectSheet } from './ConnectSheet/ConnectSheet'
import {
generateOtherRoutes,
generateProductRoutes,
generateSettingsRoutes,
generateToolRoutes,
} from '@/components/layouts/Navigation/NavigationBar/NavigationBar.utils'
import { SidebarHeader } from '@/components/layouts/NavigationV2/SidebarHeader'
import { ProjectIndexPageLink } from '@/data/prefetchers/project.$ref'
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
import { useHideSidebar } from '@/hooks/misc/useHideSidebar'
@@ -65,6 +68,7 @@ export interface SidebarProps extends ComponentPropsWithoutRef<typeof SidebarPri
export const Sidebar = ({ className, ...props }: SidebarProps) => {
const { setOpen } = useSidebar()
const hideSideBar = useHideSidebar()
const isNavigationV2 = useIsNavigationV2Enabled()
const [sidebarBehaviour, setSidebarBehaviour] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR,
@@ -78,52 +82,69 @@ export const Sidebar = ({ className, ...props }: SidebarProps) => {
}, [sidebarBehaviour, setOpen])
return (
<AnimatePresence>
{!hideSideBar && (
<SidebarMotion
{...props}
className={cn('z-50', className)}
transition={{ delay: 0.4, duration: 0.4 }}
overflowing={sidebarBehaviour === 'expandable'}
collapsible="icon"
variant="sidebar"
onMouseEnter={() => {
if (sidebarBehaviour === 'expandable') setOpen(true)
}}
onMouseLeave={() => {
if (sidebarBehaviour === 'expandable') setOpen(false)
}}
>
<SidebarContent
footer={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className={`w-min px-1.5 mx-0.5 ${sidebarBehaviour === 'open' ? '!px-2' : ''}`}
icon={<PanelLeftDashed size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-40">
<DropdownMenuRadioGroup
value={sidebarBehaviour}
onValueChange={(value) => setSidebarBehaviour(value as SidebarBehaviourType)}
>
<DropdownMenuLabel>Sidebar control</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioItem value="open">Expanded</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="closed">Collapsed</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="expandable">
Expand on hover
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</SidebarMotion>
)}
</AnimatePresence>
<>
<AnimatePresence>
{!hideSideBar && (
<SidebarMotion
{...props}
className={cn('z-50', className)}
transition={{ delay: 0.4, duration: 0.4 }}
overflowing={sidebarBehaviour === 'expandable'}
collapsible="icon"
variant="sidebar"
onMouseEnter={() => {
if (sidebarBehaviour === 'expandable') setOpen(true)
}}
onMouseLeave={() => {
if (sidebarBehaviour === 'expandable') setOpen(false)
}}
>
{isNavigationV2 && <SidebarHeader variant="desktop" />}
<SidebarContent
footer={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className={cn('w-min px-1.5 mx-0.5', sidebarBehaviour === 'open' && '!px-2')}
icon={<PanelLeftDashed size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-40">
<DropdownMenuRadioGroup
value={sidebarBehaviour}
onValueChange={(value) => setSidebarBehaviour(value as SidebarBehaviourType)}
>
<DropdownMenuLabel>Sidebar control</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioItem value="open">Expanded</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="closed">Collapsed</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="expandable">
Expand on hover
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</SidebarMotion>
)}
</AnimatePresence>
{isNavigationV2 && <ConnectSheet />}
</>
)
}
export function SidebarMobileSheetMenu() {
const router = useRouter()
const sheetScope = router.pathname.startsWith('/project') ? 'project' : 'organization'
return (
<div className="flex h-full min-h-0 flex-col bg-sidebar text-foreground">
<SidebarHeader variant="mobile-sheet" scope={sheetScope} />
<SidebarContent />
<ConnectSheet />
</div>
)
}
@@ -131,30 +152,33 @@ export const SidebarContent = ({ footer }: { footer?: ReactNode }) => {
const { ref: projectRef } = useParams()
return (
<>
<AnimatePresence mode="wait">
<SidebarContentPrimitive>
{projectRef ? (
<motion.div key="project-links">
<ProjectLinks />
</motion.div>
) : (
<motion.div
key="org-links"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<OrganizationLinks />
</motion.div>
)}
</SidebarContentPrimitive>
</AnimatePresence>
<SidebarFooter>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<AnimatePresence mode="wait">
<SidebarContentPrimitive>
{projectRef ? (
<motion.div key="project-links" className="flex min-h-0 min-w-0 flex-1 flex-col">
<ProjectLinks />
</motion.div>
) : (
<motion.div
key="org-links"
className="flex min-h-0 min-w-0 flex-1 flex-col"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<OrganizationLinks />
</motion.div>
)}
</SidebarContentPrimitive>
</AnimatePresence>
</div>
<SidebarFooter className="shrink-0">
<SidebarGroup className="p-0">{footer}</SidebarGroup>
</SidebarFooter>
</>
</div>
)
}
@@ -208,7 +232,7 @@ const ActiveDot = ({ hasErrors, hasWarnings }: { hasErrors: boolean; hasWarnings
return (
<div
className={cn(
'absolute pointer-events-none flex h-2 w-2 left-[18px] group-data-[state=expanded]:left-[20px] top-2 z-10 rounded-full',
'absolute pointer-events-none flex h-2 w-2 left-[18px] top-2 z-10 rounded-full',
hasErrors ? 'bg-destructive-600' : hasWarnings ? 'bg-warning-600' : 'bg-transparent'
)}
/>
@@ -226,7 +250,6 @@ const ProjectLinks = () => {
const { mutate: sendEvent } = useSendEventMutation()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const activeRoute = router.pathname.split('/')[3]
@@ -30,7 +30,7 @@ import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infini
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
export function OrgSelector() {
export function OrgSelector({ isCollapsed = false }: { isCollapsed?: boolean }) {
const router = useRouter()
const { slug: routeSlug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
@@ -57,31 +57,78 @@ export function OrgSelector() {
if (isLoadingOrganizations)
return (
<ShimmeringLoader
className={cn('ml-1 w-[120px]', isNavigationV2 && 'ml-0 w-full h-10 rounded-md')}
className={cn(
'ml-1 w-[120px]',
isNavigationV2 && 'ml-0 h-10 w-full rounded-md',
isCollapsed && 'min-w-0 max-w-full'
)}
/>
)
const collapsedTooltipContent = isCollapsed ? (
<div className="max-w-[280px] text-left text-sm">
<div className="flex min-w-0 flex-1 flex-col pl-0.5 pr-1 text-left">
<div className="truncate font-medium leading-tight text-foreground-light group-hover:text-foreground">
{selectedOrganization?.name ?? 'Select organization'}
</div>
{selectedOrganization && (
<div className="flex items-center gap-1 truncate text-xs leading-tight text-foreground-lighter group-hover:text-foreground-light">
<Boxes className="size-3 shrink-0" strokeWidth={1.5} />
<span className="min-w-0 truncate">{projectsLabel}</span>
</div>
)}
</div>
</div>
) : null
const triggerButton = (
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2 h-10 text-left group px-1.5 py-1 touch-manipulation"
hasIcon={!isCollapsed}
tooltip={
isCollapsed
? {
children: collapsedTooltipContent,
className: 'p-1',
}
: undefined
}
className={cn(
'group/org-selector touch-manipulation text-left data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground hover:bg-transparent',
isCollapsed
? 'h-9 gap-1 !bg-transparent !px-0.5 !py-0.5 overflow-visible'
: 'h-9 gap-2 px-1.5 py-1'
)}
onClick={isMobile ? () => setOpen(true) : undefined}
>
<div className="flex min-w-0 flex-1 flex-col text-left pl-0.5">
<div className="truncate text-foreground font-medium leading-tight min-w-[100px] max-w-[250px]">
{selectedOrganization?.name ?? 'Select organization'}
</div>
{selectedOrganization && (
<div className="flex items-center gap-1 truncate text-foreground-light leading-tight text-xs">
<Boxes className="shrink-0 size-3" strokeWidth={1.5} />
<span>{projectsLabel}</span>
{!isCollapsed ? (
<>
<div className="flex min-w-0 flex-1 flex-col pl-0.5 text-left">
<div className="min-w-[100px] max-w-[250px] truncate font-medium leading-tight text-foreground-light group-hover/org-selector:text-foreground">
{selectedOrganization?.name ?? 'Select organization'}
</div>
{selectedOrganization && (
<div className="flex items-center gap-1 truncate text-xs leading-tight text-foreground-lighter group-hover/org-selector:text-foreground-light">
<Boxes className="size-3 shrink-0" strokeWidth={1.5} />
<span className="min-w-0 truncate">{projectsLabel}</span>
</div>
)}
</div>
)}
</div>
<ChevronsUpDown
strokeWidth={1}
className="ml-auto text-foreground-light md:hidden md:group-hover:block !w-4 !h-4"
/>
<div className="p-1 rounded-md group-hover/org-selector:bg-surface-200 hover:!bg-selection group-hover/org-selector:text-foreground">
<ChevronsUpDown
strokeWidth={1.5}
className={cn(
'ml-auto !h-4 !w-4 text-foreground-lighter group-hover:text-foreground-light',
isCollapsed && 'hidden'
)}
/>
</div>
</>
) : (
<div className="relative flex h-8 aspect-square shrink-0 items-center bg-background-muted group-hover:border-stronger justify-center rounded border border-strong text-xs">
<ChevronsUpDown strokeWidth={1.5} className="!h-4 !w-4 text-foreground-lighter" />
</div>
)}
</SidebarMenuButton>
)
@@ -108,7 +155,11 @@ export function OrgSelector() {
<SidebarMenuItem>
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>{triggerButton}</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0" side="bottom" align="start">
<PopoverContent_Shadcn_
className="p-0"
side="bottom"
align={isCollapsed ? 'center' : 'start'}
>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Find organization..." />
<CommandList_Shadcn_>
@@ -23,7 +23,7 @@ import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganizati
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
export function ProjectBranchSelector() {
export function ProjectBranchSelector({ isCollapsed = false }: { isCollapsed?: boolean }) {
const router = useRouter()
const { ref } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
@@ -63,7 +63,11 @@ export function ProjectBranchSelector() {
if (isLoadingProject || !displayProject)
return (
<ShimmeringLoader
className={cn('w-[120px] ml-1 md:py-3', isNavigationV2 && 'ml-0 w-full h-10 rounded-md')}
className={cn(
'ml-1 w-[120px] md:py-3',
isNavigationV2 && 'ml-0 h-10 w-full rounded-md',
isCollapsed && 'min-w-0 max-w-full'
)}
/>
)
@@ -86,6 +90,7 @@ export function ProjectBranchSelector() {
isProductionBranch,
branchDisplayName,
onGoToOrganization: goToOrganization,
isCollapsed,
}
if (isMobile) {
@@ -113,7 +118,7 @@ export function ProjectBranchSelector() {
<PopoverTrigger_Shadcn_ asChild>
<ProjectBranchSelectorTrigger {...triggerProps} />
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-[780px]" side="bottom" align="start">
<PopoverContent_Shadcn_ className="w-[780px] p-0" side="bottom" align="start">
<ProjectBranchSelectorPopover onClose={() => setOpen(false)} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
@@ -18,6 +18,7 @@ export interface ProjectBranchSelectorTriggerProps {
isProductionBranch: boolean
branchDisplayName: string
onGoToOrganization: () => void
isCollapsed?: boolean
}
export const ProjectBranchSelectorTrigger = React.forwardRef<
@@ -27,46 +28,116 @@ export const ProjectBranchSelectorTrigger = React.forwardRef<
React.ComponentPropsWithoutRef<typeof SidebarMenuButtonComponent>,
keyof ProjectBranchSelectorTriggerProps
>
>(({ displayProjectName, selectedOrg, isBranch, branchDisplayName, ...buttonProps }, ref) => {
const selectedOrgInitial = getSelectedOrgInitial(selectedOrg?.name ?? 'O')
>(
(
{
displayProjectName,
selectedOrg,
isBranch,
branchDisplayName,
isCollapsed,
className,
...buttonProps
},
ref
) => {
const selectedOrgInitial = getSelectedOrgInitial(selectedOrg?.name ?? 'O')
return (
<SidebarMenuButtonComponent
ref={ref}
size="lg"
className="group py-1 gap-1.5 w-full flex h-auto text-left data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground touch-manipulation"
{...buttonProps}
>
<Tooltip>
<TooltipTrigger>
<div className="relative flex h-8 aspect-square shrink-0 items-center bg-background-muted group-hover:border-stronger justify-center rounded border border-strong text-xs">
{selectedOrgInitial}
</div>
</TooltipTrigger>
<TooltipContent>{selectedOrg?.name}</TooltipContent>
</Tooltip>
<div className="text-left flex-grow min-w-0">
<div className="text-left flex-grow min-w-0">
<div className="w-full truncate text-foreground leading-tight max-w-[250px]">
{displayProjectName}
</div>
<div
className={cn(
'flex items-center gap-0.5',
isBranch ? 'text-foreground-lighter' : 'text-warning'
)}
>
<GitBranch className="shrink-0 size-3" strokeWidth={1.5} />
<span className="truncate min-w-0 leading-tight text-xs">{branchDisplayName}</span>
const collapsedTooltipContent = isCollapsed ? (
<div className="flex min-w-[120px] max-w-[250px] items-start gap-1.5 text-left text-sm pr-1 text-foreground-light">
<div className="relative flex aspect-square h-8 shrink-0 items-center justify-center rounded border border-strong bg-background-muted text-xs">
{selectedOrgInitial}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="min-w-0 flex-grow text-left">
<div className="w-full truncate leading-tight">{displayProjectName}</div>
<div
className={cn(
'flex items-center gap-0.5',
isBranch ? 'text-foreground-lighter' : 'text-warning'
)}
>
<GitBranch className="size-3 shrink-0" strokeWidth={1.5} />
<span className="min-w-0 truncate text-xs leading-tight">{branchDisplayName}</span>
</div>
</div>
</div>
</div>
) : null
<ChevronsUpDown
strokeWidth={1.5}
className="ml-auto text-foreground-lighter !w-4 !h-4 md:hidden md:group-hover:flex"
/>
</SidebarMenuButtonComponent>
)
})
return (
<SidebarMenuButtonComponent
ref={ref}
size="lg"
hasIcon={!isCollapsed}
tooltip={
isCollapsed
? {
children: collapsedTooltipContent,
className: 'p-1 bg-sidebar',
}
: undefined
}
className={cn(
'group/project-branch-selector !bg-transparent flex h-auto w-full touch-manipulation text-left data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground hover:bg-transparent',
isCollapsed ? 'min-h-9 gap-1 !pl-0 !py-0.5 overflow-visible' : 'gap-1.5 pl-0 py-0.5',
className
)}
{...buttonProps}
>
{!isCollapsed ? (
<>
<Tooltip>
<TooltipTrigger>
<div className="relative flex h-8 aspect-square shrink-0 items-center bg-background-muted group-hover/project-branch-selector:border-stronger justify-center rounded border border-strong text-xs">
{selectedOrgInitial}
</div>
</TooltipTrigger>
<TooltipContent>{selectedOrg?.name}</TooltipContent>
</Tooltip>
<div className="text-left flex-grow min-w-0">
<div className="text-left flex-grow min-w-0">
<div
className={cn(
'w-full truncate leading-tight transition-colors text-foreground-light group-hover/project-branch-selector:text-foreground',
isCollapsed ? 'max-w-full min-w-0' : 'max-w-[250px]'
)}
>
{displayProjectName}
</div>
<div
className={cn(
'flex items-center gap-0.5',
isBranch
? 'text-foreground-lighter group-hover/project-branch-selector:text-foreground-light'
: 'text-warning'
)}
>
<GitBranch className="shrink-0 size-3" strokeWidth={1.5} />
<span className="truncate min-w-0 leading-tight text-xs">
{branchDisplayName}
</span>
</div>
</div>
</div>
<div className="p-1 rounded-md group-hover:bg-surface-200 hover:!bg-selection group-hover:text-foreground">
<ChevronsUpDown
strokeWidth={1.5}
className={cn(
'ml-auto !h-4 !w-4 text-foreground-lighter group-hover:text-foreground-light',
isCollapsed && 'hidden'
)}
/>
</div>
</>
) : (
<div className="relative flex h-8 aspect-square shrink-0 items-center bg-background-muted group-hover:border-stronger justify-center rounded border border-strong text-xs">
<ChevronsUpDown strokeWidth={1.5} className="!h-4 !w-4 text-foreground-lighter" />
</div>
)}
</SidebarMenuButtonComponent>
)
}
)
ProjectBranchSelectorTrigger.displayName = 'ProjectBranchSelectorTrigger'
@@ -1,147 +0,0 @@
import { useParams } from 'common'
import { Plug } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { Button, cn, Sidebar, SidebarHeader } from 'ui'
import { ShimmeringLoader } from 'ui-patterns'
import type { AppSidebarScope } from './app-sidebar.types'
import { ConnectSheet } from '@/components/interfaces/ConnectSheet/ConnectSheet'
import { SidebarContent as StudioSidebarNav } from '@/components/interfaces/Sidebar'
import { OrgSelector } from '@/components/layouts/Navigation/NavigationBar/OrgSelector'
import { ProjectBranchSelector } from '@/components/layouts/Navigation/NavigationBar/ProjectBranchSelector'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
import { useHideSidebar } from '@/hooks/misc/useHideSidebar'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
export type { AppSidebarScope } from './app-sidebar.types'
type AppSidebarNavBodyVariant = 'desktop' | 'mobile-sheet'
interface AppSidebarNavBodyProps {
scope?: AppSidebarScope
variant?: AppSidebarNavBodyVariant
}
/**
* Shared nav chrome (selectors, Connect) + V1 `SidebarContent` link tree for desktop and mobile sheet.
*/
function AppSidebarNavBody({ scope, variant = 'desktop' }: AppSidebarNavBodyProps) {
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const router = useRouter()
const { slug: orgRouteSlug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { isPending: isLoadingOrganizations } = useOrganizationsQuery()
const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery()
const resolvedScope: AppSidebarScope =
scope ?? (router.pathname.startsWith('/project') ? 'project' : 'organization')
const isProjectScope = resolvedScope === 'project'
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isBranch = project != null && project.parentRef !== project.ref
const { data: parentProject, isPending: isLoadingParentProject } = useProjectDetailQuery(
{ ref: project?.parent_project_ref },
{ enabled: Boolean(isProjectScope && isBranch && project?.parent_project_ref) }
)
const selectedProject = parentProject ?? project
const isOrgSelectorLoading = isLoadingOrganizations
const isProjectSelectorLoading =
isLoadingProject || (isBranch && isLoadingParentProject) || !selectedProject
const shouldShowOrgSelector =
!isProjectScope && IS_PLATFORM && (Boolean(orgRouteSlug) || Boolean(selectedOrganization))
const isHeaderLoading =
(shouldShowOrgSelector && isOrgSelectorLoading) || (isProjectScope && isProjectSelectorLoading)
const isMobileSheet = variant === 'mobile-sheet'
const selectorHeaderClass = cn('flex-col gap-2', isMobileSheet ? 'flex' : 'hidden md:flex')
return (
<>
<SidebarHeader className={selectorHeaderClass}>
{isHeaderLoading ? (
<ShimmeringLoader
className={
isProjectScope
? 'p-2 md:mr-2 md:w-[90px]'
: cn('p-2 md:mr-2 w-[90px]', isMobileSheet && 'w-full max-w-none')
}
/>
) : (
<>
{shouldShowOrgSelector && <OrgSelector />}
{isProjectScope && (
<>
<ProjectBranchSelector />
<div className="flex items-center px-0.5">
<Button
type="default"
size="small"
disabled={!isActiveHealthy}
onClick={() => setShowConnect(true)}
className="h-7 flex-1 justify-center gap-0 pl-2"
icon={<Plug className="rotate-90" strokeWidth={1.5} />}
>
Connect
</Button>
</div>
</>
)}
</>
)}
</SidebarHeader>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<StudioSidebarNav />
</div>
</div>
</>
)
}
interface AppSidebarV2Props {
scope?: AppSidebarScope
}
export function AppSidebarV2({ scope }: AppSidebarV2Props = {}) {
const hideSidebar = useHideSidebar()
return (
<>
{!hideSidebar && (
<Sidebar
collapsible="none"
className="hidden md:flex h-full w-full border-r border-default group"
>
<AppSidebarNavBody scope={scope} variant="desktop" />
</Sidebar>
)}
<ConnectSheet />
</>
)
}
/**
* Full-height V2 navigation for `StudioMobileSheetNav`, opened via `registerOpenMenu` from
* {@link ProjectLayoutV2}. Scope follows the current route like {@link DefaultLayoutV2}.
*/
export function AppSidebarMobileSheetMenu() {
const router = useRouter()
const sheetScope: AppSidebarScope = router.pathname.startsWith('/project')
? 'project'
: 'organization'
return (
<div className="flex h-full min-h-0 flex-col bg-sidebar text-foreground">
<AppSidebarNavBody scope={sheetScope} variant="mobile-sheet" />
<ConnectSheet />
</div>
)
}
@@ -5,12 +5,12 @@ import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useRouter } from 'next/router'
import type { PropsWithChildren } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import { cn } from 'ui'
import { DefaultLayoutProviders } from '../DefaultLayoutProviders'
import { AppSidebarV2 } from './AppSidebar'
import { LayoutHeader } from './LayoutHeader'
import { RightPanelToolbarLayout } from './RightPanelToolbar'
import { Sidebar } from '@/components/interfaces/Sidebar'
import { useHideSidebar } from '@/hooks/misc/useHideSidebar'
export interface DefaultLayoutV2Props {
@@ -18,14 +18,10 @@ export interface DefaultLayoutV2Props {
hideMobileMenu?: boolean
}
const leftSidebarMinSize = 160
const leftSidebarMaxSize = 300
const leftSidebarDefaultSize = '230px'
const mainContentClass = 'flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden'
/**
* V2 navigation shell: mobile bar, then AppSidebar (when shown) + main column. Main column has
* V2 navigation shell: mobile bar, then primary `Sidebar` (when shown) + main column. Main column has
* LayoutHeader above the row of page content and right panel toolbar.
*/
export const DefaultLayoutV2 = ({
@@ -60,7 +56,7 @@ export const DefaultLayoutV2 = ({
const contentRow = <RightPanelToolbarLayout>{children}</RightPanelToolbarLayout>
const mainColumn = (
<div className={mainContentClass}>
<div className={cn(mainContentClass, 'min-w-0 flex-1')}>
<div className="shrink-0">{header}</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{contentRow}</div>
</div>
@@ -77,29 +73,8 @@ export const DefaultLayoutV2 = ({
/>
</div>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
{showLeftSidebar ? (
<ResizablePanelGroup
orientation="horizontal"
autoSaveId="default-layout-v2-left-sidebar"
className="h-full w-full overflow-hidden"
>
<ResizablePanel
id="panel-v2-left-sidebar"
minSize={leftSidebarMinSize}
maxSize={leftSidebarMaxSize}
defaultSize={leftSidebarDefaultSize}
className="h-full min-h-0 overflow-hidden"
>
<AppSidebarV2 scope={scope} />
</ResizablePanel>
<ResizableHandle withHandle className="hidden md:flex bg-background" />
<ResizablePanel id="panel-v2-main-column" className={mainContentClass}>
{mainColumn}
</ResizablePanel>
</ResizablePanelGroup>
) : (
mainColumn
)}
{showLeftSidebar && <Sidebar className="h-full min-h-0 border-r border-default" />}
{mainColumn}
</div>
</div>
</DefaultLayoutProviders>
@@ -15,7 +15,7 @@ import { useSetMainScrollContainer } from '../MainScrollContainerContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import { ContentWrapper } from '../ProjectLayout'
import { ProjectPausedState } from '../ProjectLayout/PausedState/ProjectPausedState'
import { AppSidebarMobileSheetMenu } from './AppSidebar'
import { SidebarMobileSheetMenu } from '@/components/interfaces/Sidebar'
export interface ProjectLayoutV2Props {
title?: string
@@ -27,7 +27,7 @@ export interface ProjectLayoutV2Props {
/**
* V2 Project layout - renders content directly without a secondary product menu sidebar.
* In V2, all navigation is in the primary sidebar (AppSidebar), so this layout
* In V2, all navigation is in the primary `Sidebar`, so this layout
* only handles title, loading states, and content wrapping.
*/
export const ProjectLayoutV2 = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutV2Props>>(
@@ -57,7 +57,7 @@ export const ProjectLayoutV2 = forwardRef<HTMLDivElement, PropsWithChildren<Proj
useLayoutEffect(() => {
const unregister = registerOpenMenu(() => {
setMobileSheetContent(<AppSidebarMobileSheetMenu />)
setMobileSheetContent(<SidebarMobileSheetMenu />)
})
return unregister
}, [registerOpenMenu, setMobileSheetContent])
@@ -0,0 +1,65 @@
import { Plug } from 'lucide-react'
import {
Button,
cn,
SidebarHeader as SidebarHeaderPrimitive,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { useSidebarHeader, type SidebarHeaderProps } from './useSidebarHeader'
import { OrgSelector } from '@/components/layouts/Navigation/NavigationBar/OrgSelector'
import { ProjectBranchSelector } from '@/components/layouts/Navigation/NavigationBar/ProjectBranchSelector'
export type { SidebarHeaderProps }
export function SidebarHeader(props: SidebarHeaderProps) {
const {
setShowConnect,
isProjectScope,
isActiveHealthy,
shouldShowOrgSelector,
selectorHeaderClass,
isCollapsedRail,
} = useSidebarHeader(props)
return (
<SidebarHeaderPrimitive className={cn(selectorHeaderClass, 'shrink-0 pb-0')}>
{shouldShowOrgSelector && <OrgSelector isCollapsed={isCollapsedRail} />}
{isProjectScope && (
<>
<ProjectBranchSelector isCollapsed={isCollapsedRail} />
<div className="flex items-center px-0">
{isCollapsedRail ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="default"
size="tiny"
disabled={!isActiveHealthy}
onClick={() => setShowConnect(true)}
className="h-8 w-8 shrink-0 p-0"
icon={<Plug size={16} className="rotate-90 !size-4" strokeWidth={1.5} />}
/>
</TooltipTrigger>
<TooltipContent side="right">Connect</TooltipContent>
</Tooltip>
) : (
<Button
type="default"
size="small"
disabled={!isActiveHealthy}
onClick={() => setShowConnect(true)}
className="h-8 flex-1 justify-center gap-0 pl-2"
icon={<Plug className="rotate-90" strokeWidth={1.5} />}
>
Connect
</Button>
)}
</div>
</>
)}
</SidebarHeaderPrimitive>
)
}
@@ -1,5 +1,4 @@
export type { AppSidebarScope } from './app-sidebar.types'
export { AppSidebarV2 } from './AppSidebar'
export { DefaultLayoutV2 } from './DefaultLayout'
export { ProjectLayoutV2, ProjectLayoutV2WithAuth } from './ProjectLayout'
export { RightPanelToolbarLayout } from './RightPanelToolbar'
@@ -0,0 +1,42 @@
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useSidebar } from 'ui'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
export type SidebarHeaderProps = {
variant: 'desktop' | 'mobile-sheet'
scope?: 'project' | 'organization'
}
export function useSidebarHeader({ variant, scope: scopeProp }: SidebarHeaderProps) {
const { state, isMobile } = useSidebar()
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const router = useRouter()
const { slug: orgRouteSlug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const resolvedScope =
scopeProp ?? (router.pathname.startsWith('/project') ? 'project' : 'organization')
const isProjectScope = resolvedScope === 'project'
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const shouldShowOrgSelector =
!isProjectScope && IS_PLATFORM && (Boolean(orgRouteSlug) || Boolean(selectedOrganization))
const selectorHeaderClass = 'flex-col gap-2 hidden md:flex'
const isCollapsedRail = variant === 'desktop' && state === 'collapsed' && !isMobile
return {
setShowConnect,
isProjectScope,
isActiveHealthy,
shouldShowOrgSelector,
selectorHeaderClass,
isCollapsedRail,
}
}
@@ -25,7 +25,6 @@ import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useSetMainScrollContainer } from '../MainScrollContainerContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import ProductMenuBar from '../Navigation/ProductMenuBar'
import { AppSidebarMobileSheetMenu } from '../NavigationV2/AppSidebar'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
@@ -41,6 +40,7 @@ import { UpgradingState } from './UpgradingState'
import { useIsNavigationV2Enabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { SidebarMobileSheetMenu } from '@/components/interfaces/Sidebar'
import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
@@ -203,7 +203,7 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
useLayoutEffect(() => {
const unregister = registerOpenMenu(() => {
if (isNavigationV2) {
setMobileSheetContent(<AppSidebarMobileSheetMenu />)
setMobileSheetContent(<SidebarMobileSheetMenu />)
} else {
setMobileSheetContent(
<MobileMenuContent
+3 -3
View File
@@ -191,15 +191,15 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
<MonacoThemeProvider />
</CommandProvider>
</AiAssistantStateContextProvider>
<DevToolbar />
{/* <DevToolbar /> */}
</DevToolbarProvider>
</ThemeProvider>
</RouteValidationWrapper>
</TooltipProvider>
<Telemetry />
{!isTestEnv && (
{/* {!isTestEnv && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
)}
)} */}
</ProfileProvider>
</FeatureFlagProviderWithOrgContext>
</AuthProvider>