void
+}
+
+export function MobileMenuContent({
+ currentProductMenu,
+ currentProduct,
+ currentSectionKey,
+ onCloseSheet,
+}: MobileMenuContentProps) {
+ const router = useRouter()
+ const { ref } = useParams()
+ const { data: project } = useSelectedProjectQuery()
+ const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
+ const activeRoute = getPathSegment(pathname, 3)
+
+ const { viewLevel, selectedSectionKey, handleTopLevelClick, handleBackToTop } =
+ useMobileMenuNavigation({
+ currentSectionKey,
+ hasCurrentProductMenu: !!currentProductMenu,
+ onCloseSheet,
+ })
+
+ const {
+ projectAuthAll: authEnabled,
+ projectEdgeFunctionAll: edgeFunctionsEnabled,
+ projectStorageAll: storageEnabled,
+ realtimeAll: realtimeEnabled,
+ } = useIsFeatureEnabled([
+ 'project_auth:all',
+ 'project_edge_function:all',
+ 'project_storage:all',
+ 'realtime:all',
+ ])
+ const authOverviewPageEnabled = useFlag('authOverviewPage')
+ const showReports = useIsFeatureEnabled('reports:all')
+ const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
+ const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
+
+ const toolRoutes = useMemo(() => generateToolRoutes(ref, project), [ref, project])
+ const productRoutes = useMemo(
+ () =>
+ generateProductRoutes(ref, project, {
+ auth: authEnabled,
+ edgeFunctions: edgeFunctionsEnabled,
+ storage: storageEnabled,
+ realtime: realtimeEnabled,
+ authOverviewPage: authOverviewPageEnabled,
+ }),
+ [
+ ref,
+ project,
+ authEnabled,
+ edgeFunctionsEnabled,
+ storageEnabled,
+ realtimeEnabled,
+ authOverviewPageEnabled,
+ ]
+ )
+ const otherRoutes = useMemo(
+ () =>
+ generateOtherRoutes(ref, project, {
+ unifiedLogs: isUnifiedLogsEnabled,
+ showReports,
+ apiDocsSidePanel: isNewAPIDocsEnabled,
+ }),
+ [ref, project, isUnifiedLogsEnabled, showReports, isNewAPIDocsEnabled]
+ )
+ const settingsRoutes = useMemo(() => generateSettingsRoutes(ref, project), [ref, project])
+
+ const homeRoute: Route = useMemo(
+ () => ({
+ key: 'HOME',
+ label: 'Project Overview',
+ icon:
,
+ link: ref ? `/project/${ref}` : undefined,
+ }),
+ [ref]
+ )
+
+ const allTopLevelRoutes = useMemo(
+ () => [homeRoute, ...toolRoutes, ...productRoutes, ...otherRoutes, ...settingsRoutes],
+ [homeRoute, toolRoutes, productRoutes, otherRoutes, settingsRoutes]
+ )
+
+ const { sectionKey: sectionKeyToShow, sectionLabel } = resolveSectionDisplay({
+ viewLevel,
+ selectedSectionKey,
+ currentSectionKey,
+ currentProduct,
+ routes: allTopLevelRoutes,
+ })
+
+ const SectionMenuContent = sectionKeyToShow ? getProductMenuComponent(sectionKeyToShow) : null
+ const pageSegment = getPathSegment(pathname, 4)
+
+ const renderRoute = (route: Route, isActive: boolean) => (
+
+ )
+
+ return (
+
+ {viewLevel === 'section' && sectionLabel && (
+
+ }
+ onClick={handleBackToTop}
+ aria-label="Back to menu"
+ block
+ >
+ {sectionLabel}
+
+
+ )}
+
+ {viewLevel === 'top' && (
+
+ )}
+ {viewLevel === 'section' && sectionKeyToShow && (
+
+ {sectionKeyToShow === currentSectionKey && currentProductMenu ? (
+ currentProductMenu
+ ) : SectionMenuContent ? (
+ }>
+ {sectionKeyToShow === 'advisors' ? (
+ )}
+ />
+ ) : (
+
+ )}
+
+ ) : null}
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts
new file mode 100644
index 00000000000..fd81be51610
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest'
+
+import { getSectionKeyFromPathname, resolveSectionDisplay } from './MobileMenuContent.utils'
+
+describe('getSectionKeyFromPathname', () => {
+ it('returns section key for project section paths', () => {
+ expect(getSectionKeyFromPathname('/project/abc123/database/schemas')).toBe('database')
+ expect(getSectionKeyFromPathname('/project/abc123/auth/users')).toBe('auth')
+ expect(getSectionKeyFromPathname('/project/abc123/settings/general')).toBe('settings')
+ expect(getSectionKeyFromPathname('/project/abc123/integrations')).toBe('integrations')
+ expect(getSectionKeyFromPathname('/project/abc123/realtime/inspector')).toBe('realtime')
+ expect(getSectionKeyFromPathname('/project/abc123/functions')).toBe('functions')
+ expect(getSectionKeyFromPathname('/project/abc123/logs/explorer')).toBe('logs')
+ expect(getSectionKeyFromPathname('/project/abc123/advisors/security')).toBe('advisors')
+ })
+
+ it('returns null for project home (no section segment)', () => {
+ expect(getSectionKeyFromPathname('/project/abc123')).toBeNull()
+ expect(getSectionKeyFromPathname('/project/abc123/')).toBeNull()
+ })
+
+ it('returns null when pathname does not contain "project"', () => {
+ expect(getSectionKeyFromPathname('/org/my-org')).toBeNull()
+ expect(getSectionKeyFromPathname('/account/me')).toBeNull()
+ expect(getSectionKeyFromPathname('/')).toBeNull()
+ expect(getSectionKeyFromPathname('')).toBeNull()
+ })
+
+ it('returns null when project segment is missing or dynamic', () => {
+ expect(getSectionKeyFromPathname('/project')).toBeNull()
+ expect(getSectionKeyFromPathname('/project/')).toBeNull()
+ expect(getSectionKeyFromPathname('/project/[ref]/database/schemas')).toBeNull()
+ })
+
+ it('returns first segment after project ref as section key', () => {
+ expect(getSectionKeyFromPathname('/project/foo/bar/baz')).toBe('bar')
+ })
+})
+
+describe('resolveSectionDisplay', () => {
+ const routes = [
+ { key: 'database', label: 'Database' },
+ { key: 'auth', label: 'Authentication' },
+ { key: 'settings', label: 'Settings' },
+ ]
+
+ it('returns nulls when viewLevel is top', () => {
+ const result = resolveSectionDisplay({
+ viewLevel: 'top',
+ selectedSectionKey: 'database',
+ currentSectionKey: 'auth',
+ currentProduct: 'Authentication',
+ routes,
+ })
+ expect(result).toEqual({ sectionKey: null, sectionLabel: null })
+ })
+
+ it('uses selectedSectionKey when set', () => {
+ const result = resolveSectionDisplay({
+ viewLevel: 'section',
+ selectedSectionKey: 'database',
+ currentSectionKey: 'auth',
+ currentProduct: 'Authentication',
+ routes,
+ })
+ expect(result).toEqual({ sectionKey: 'database', sectionLabel: 'Database' })
+ })
+
+ it('falls back to currentSectionKey when selectedSectionKey is null', () => {
+ const result = resolveSectionDisplay({
+ viewLevel: 'section',
+ selectedSectionKey: null,
+ currentSectionKey: 'auth',
+ currentProduct: 'Authentication',
+ routes,
+ })
+ expect(result).toEqual({ sectionKey: 'auth', sectionLabel: 'Authentication' })
+ })
+
+ it('uses currentProduct as label when sectionKey matches currentSectionKey', () => {
+ const result = resolveSectionDisplay({
+ viewLevel: 'section',
+ selectedSectionKey: 'auth',
+ currentSectionKey: 'auth',
+ currentProduct: 'Auth Users',
+ routes,
+ })
+ expect(result).toEqual({ sectionKey: 'auth', sectionLabel: 'Auth Users' })
+ })
+
+ it('falls back to sectionKey itself when no matching route', () => {
+ const result = resolveSectionDisplay({
+ viewLevel: 'section',
+ selectedSectionKey: 'unknown',
+ currentSectionKey: null,
+ currentProduct: '',
+ routes,
+ })
+ expect(result).toEqual({ sectionKey: 'unknown', sectionLabel: 'unknown' })
+ })
+
+ it('returns nulls when in section view but both keys are null', () => {
+ const result = resolveSectionDisplay({
+ viewLevel: 'section',
+ selectedSectionKey: null,
+ currentSectionKey: null,
+ currentProduct: '',
+ routes,
+ })
+ expect(result).toEqual({ sectionKey: null, sectionLabel: null })
+ })
+})
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts
new file mode 100644
index 00000000000..e3adc212db4
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts
@@ -0,0 +1,64 @@
+/**
+ * Derives the current section key from the project pathname.
+ * e.g. /project/[ref]/database/schemas → 'database', /project/[ref] → null (home).
+ */
+export function getSectionKeyFromPathname(pathname: string): string | null {
+ const segments = pathname.split('/').filter(Boolean)
+
+ const projectIndex = segments.indexOf('project')
+ if (projectIndex === -1 || segments.length <= projectIndex + 1) return null
+
+ const refSegment = segments[projectIndex + 1]
+ if (!refSegment || refSegment.startsWith('[')) return null
+
+ const sectionSegment = segments[projectIndex + 2]
+ if (!sectionSegment) return null
+
+ return sectionSegment
+}
+
+export interface ResolveSectionDisplayParams {
+ viewLevel: 'top' | 'section'
+ selectedSectionKey: string | null
+ currentSectionKey: string | null
+ currentProduct: string
+ routes: Array<{ key: string; label: string }>
+}
+
+export interface SectionDisplay {
+ sectionKey: string | null
+ sectionLabel: string | null
+}
+
+/**
+ * Resolves which section to show and its label for the mobile menu.
+ * When in section view: uses selectedSectionKey (user clicked) or falls back to currentSectionKey.
+ * Label resolves from currentProduct when matching the current section, otherwise from route labels.
+ */
+export function resolveSectionDisplay({
+ viewLevel,
+ selectedSectionKey,
+ currentSectionKey,
+ currentProduct,
+ routes,
+}: ResolveSectionDisplayParams): SectionDisplay {
+ if (viewLevel !== 'section') {
+ return { sectionKey: null, sectionLabel: null }
+ }
+
+ const sectionKey = selectedSectionKey ?? currentSectionKey
+
+ if (!sectionKey) {
+ return { sectionKey: null, sectionLabel: null }
+ }
+
+ const isCurrentSection = sectionKey === currentSectionKey
+ if (isCurrentSection) {
+ return { sectionKey, sectionLabel: currentProduct }
+ }
+
+ const matchingRoute = routes.find((r) => r.key === sectionKey)
+ const sectionLabel = matchingRoute?.label ?? sectionKey
+
+ return { sectionKey, sectionLabel }
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx
new file mode 100644
index 00000000000..2beaa1f3cb8
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx
@@ -0,0 +1,171 @@
+'use client'
+
+import { useIsMFAEnabled, useParams } from 'common'
+import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { getPathnameWithoutQuery } from 'lib/pathname.utils'
+import { useTrack } from 'lib/telemetry/track'
+import { Blocks, Boxes, ChartArea, ChevronLeft, Receipt, Settings, Users } from 'lucide-react'
+import { useRouter } from 'next/router'
+import React, { useMemo } from 'react'
+import { Button, cn, SidebarGroup, SidebarMenu } from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns'
+
+import { getOrgMenuComponent } from './mobileOrgMenuRegistry'
+import type { OrgNavItem } from './OrgMenuContent.utils'
+import {
+ getOrgActiveRoute,
+ getOrgSectionKeyFromPathname,
+ isOrgMenuActive,
+} from './OrgMenuContent.utils'
+import { OrgMenuItem } from './OrgMenuItem'
+import { orgItemHasSubmenu, useOrgMenuNavigation } from './useOrgMenuNavigation'
+
+export interface OrgMenuContentProps {
+ onCloseSheet?: () => void
+}
+
+export function OrgMenuContent({ onCloseSheet }: OrgMenuContentProps) {
+ const router = useRouter()
+ const { slug } = useParams()
+ const organizationSlug: string = slug ?? (router.query.orgSlug as string) ?? ''
+ const { data: org } = useSelectedOrganizationQuery()
+ const isUserMFAEnabled = useIsMFAEnabled()
+ const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled
+
+ const showBilling = useIsFeatureEnabled('billing:all')
+
+ const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
+ const activeRoute = getOrgActiveRoute(pathname)
+ const initialSectionKey = getOrgSectionKeyFromPathname(activeRoute)
+
+ const track = useTrack()
+ const {
+ viewLevel,
+ selectedSectionKey,
+ handleSubmenuClick: navigateToSubmenu,
+ handleBackToTop: navigateBackToTop,
+ } = useOrgMenuNavigation({ initialSectionKey })
+
+ const handleSubmenuClick = (item: OrgNavItem) => {
+ track('org_submenu_opened', { itemKey: item.key, itemLabel: item.label })
+ navigateToSubmenu(item)
+ }
+
+ const handleBackToTop = () => {
+ track('org_menu_back_clicked')
+ navigateBackToTop()
+ }
+
+ const navMenuItems: OrgNavItem[] = useMemo(
+ () => [
+ {
+ label: 'Projects',
+ href: `/org/${organizationSlug}`,
+ key: 'projects',
+ icon:
,
+ },
+ {
+ label: 'Team',
+ href: `/org/${organizationSlug}/team`,
+ key: 'team',
+ icon:
,
+ },
+ {
+ label: 'Integrations',
+ href: `/org/${organizationSlug}/integrations`,
+ key: 'integrations',
+ icon:
,
+ },
+ {
+ label: 'Usage',
+ href: `/org/${organizationSlug}/usage`,
+ key: 'usage',
+ icon:
,
+ },
+ ...(showBilling
+ ? [
+ {
+ label: 'Billing',
+ href: `/org/${organizationSlug}/billing`,
+ key: 'billing',
+ icon:
,
+ },
+ ]
+ : []),
+ {
+ label: 'Organization settings',
+ href: `/org/${organizationSlug}/general`,
+ key: 'settings',
+ icon:
,
+ },
+ ],
+ [organizationSlug, showBilling]
+ )
+
+ const sectionKeyToShow = viewLevel === 'section' ? selectedSectionKey : null
+ const sectionLabel =
+ sectionKeyToShow && navMenuItems.find((item) => item.key === sectionKeyToShow)?.label
+
+ const SectionMenuContent = sectionKeyToShow ? getOrgMenuComponent(sectionKeyToShow) : null
+
+ if (!organizationSlug) return null
+
+ if (viewLevel === 'section' && sectionKeyToShow && SectionMenuContent) {
+ return (
+
+
+ }
+ onClick={handleBackToTop}
+ aria-label="Back to menu"
+ block
+ >
+ {sectionLabel ?? sectionKeyToShow}
+
+
+
+ }>
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts
new file mode 100644
index 00000000000..c8555408764
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getOrgActiveRoute,
+ getOrgSectionKeyFromPathname,
+ isOrgMenuActive,
+ type OrgNavItem,
+} from './OrgMenuContent.utils'
+
+const mockItem = (key: string): OrgNavItem => ({
+ key,
+ label: key,
+ href: `/org/foo/${key}`,
+ icon: null,
+})
+
+describe('getOrgActiveRoute', () => {
+ it('returns segment after org slug', () => {
+ expect(getOrgActiveRoute('/org/my-org/team')).toBe('team')
+ expect(getOrgActiveRoute('/org/my-org/integrations')).toBe('integrations')
+ expect(getOrgActiveRoute('/org/my-org/settings/general')).toBe('settings')
+ })
+
+ it('returns undefined for org home', () => {
+ expect(getOrgActiveRoute('/org/my-org')).toBeUndefined()
+ expect(getOrgActiveRoute('/org/my-org/')).toBeUndefined()
+ })
+
+ it('returns undefined when path too short', () => {
+ expect(getOrgActiveRoute('/org')).toBeUndefined()
+ expect(getOrgActiveRoute('/')).toBeUndefined()
+ expect(getOrgActiveRoute('')).toBeUndefined()
+ })
+
+ it('returns undefined when org segment not found', () => {
+ expect(getOrgActiveRoute('/project/ref/database')).toBeUndefined()
+ })
+})
+
+describe('isOrgMenuActive', () => {
+ it('first item (index 0) is active when activeRoute is undefined', () => {
+ expect(isOrgMenuActive(mockItem('projects'), 0, '/org/foo', undefined)).toBe(true)
+ })
+
+ it('first item is not active when activeRoute is defined', () => {
+ expect(isOrgMenuActive(mockItem('projects'), 0, '/org/foo/team', 'team')).toBe(false)
+ })
+
+ it('item is active when activeRoute matches item key', () => {
+ expect(isOrgMenuActive(mockItem('team'), 1, '/org/foo/team', 'team')).toBe(true)
+ expect(
+ isOrgMenuActive(mockItem('integrations'), 2, '/org/foo/integrations', 'integrations')
+ ).toBe(true)
+ })
+
+ it('settings item is active when pathname includes settings sub-routes', () => {
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/settings/general', 'settings')).toBe(
+ true
+ )
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/general', undefined)).toBe(true)
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/apps', undefined)).toBe(true)
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/audit', undefined)).toBe(true)
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/documents', undefined)).toBe(true)
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/security', undefined)).toBe(true)
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/sso', undefined)).toBe(true)
+ })
+
+ it('settings item is not active when pathname has no settings sub-route', () => {
+ expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/team', 'team')).toBe(false)
+ })
+
+ it('item is not active when activeRoute does not match', () => {
+ expect(isOrgMenuActive(mockItem('team'), 1, '/org/foo/team', 'usage')).toBe(false)
+ })
+})
+
+describe('getOrgSectionKeyFromPathname', () => {
+ it('returns settings for org settings sub-routes', () => {
+ expect(getOrgSectionKeyFromPathname('general')).toBe('settings')
+ expect(getOrgSectionKeyFromPathname('security')).toBe('settings')
+ expect(getOrgSectionKeyFromPathname('sso')).toBe('settings')
+ expect(getOrgSectionKeyFromPathname('apps')).toBe('settings')
+ expect(getOrgSectionKeyFromPathname('audit')).toBe('settings')
+ expect(getOrgSectionKeyFromPathname('documents')).toBe('settings')
+ })
+
+ it('returns null for non-settings routes', () => {
+ expect(getOrgSectionKeyFromPathname('team')).toBeNull()
+ expect(getOrgSectionKeyFromPathname('integrations')).toBeNull()
+ expect(getOrgSectionKeyFromPathname('billing')).toBeNull()
+ expect(getOrgSectionKeyFromPathname('usage')).toBeNull()
+ })
+
+ it('returns null when activeRoute is undefined', () => {
+ expect(getOrgSectionKeyFromPathname(undefined)).toBeNull()
+ })
+})
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts
new file mode 100644
index 00000000000..04e7f6aa607
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts
@@ -0,0 +1,51 @@
+import type { ReactNode } from 'react'
+
+export interface OrgNavItem {
+ label: string
+ href: string
+ key: string
+ icon: ReactNode
+}
+
+export function getOrgActiveRoute(pathname: string): string | undefined {
+ const segments = pathname.split('/').filter(Boolean)
+ const orgIndex = segments.indexOf('org')
+ if (orgIndex === -1 || segments.length <= orgIndex + 1) return undefined
+ return segments[orgIndex + 2]
+}
+
+const ORG_SETTINGS_ROUTES: string[] = [
+ 'general',
+ 'apps',
+ 'audit',
+ 'documents',
+ 'security',
+ 'sso',
+] as const
+
+export function getOrgSectionKeyFromPathname(activeRoute: string | undefined): string | null {
+ if (activeRoute && ORG_SETTINGS_ROUTES.includes(activeRoute)) {
+ return 'settings'
+ }
+ return null
+}
+
+export function isOrgMenuActive(
+ item: OrgNavItem,
+ index: number,
+ pathname: string,
+ activeRoute: string | undefined
+): boolean {
+ if (index === 0) {
+ return activeRoute === undefined
+ }
+ if (item.key === 'settings') {
+ const route = activeRoute ?? getOrgActiveRoute(pathname)
+ if (route === undefined) return false
+ return (
+ route === 'settings' ||
+ ORG_SETTINGS_ROUTES.includes(route as (typeof ORG_SETTINGS_ROUTES)[number])
+ )
+ }
+ return activeRoute === item.key
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx
new file mode 100644
index 00000000000..1c68ac67172
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx
@@ -0,0 +1,64 @@
+import Link from 'next/link'
+import { cn, SidebarMenuItem, sidebarMenuButtonVariants } from 'ui'
+
+import type { OrgNavItem } from './OrgMenuContent.utils'
+
+export interface OrgMenuItemProps {
+ item: OrgNavItem
+ isActive: boolean
+ disabled?: boolean
+ onCloseSheet?: () => void
+ onSubmenuClick?: (item: OrgNavItem) => void
+ onSelect?: () => void
+}
+
+export function OrgMenuItem({
+ item,
+ isActive,
+ disabled = false,
+ onCloseSheet,
+ onSubmenuClick,
+ onSelect,
+}: OrgMenuItemProps) {
+ const menuButtonClass = cn(
+ sidebarMenuButtonVariants({ size: 'default', hasIcon: true }),
+ disabled && 'opacity-50 pointer-events-none'
+ )
+
+ const content = (
+ <>
+
+ {item.icon}
+
+
{item.label}
+ >
+ )
+
+ return (
+
+ {onSubmenuClick ? (
+
+ ) : (
+ {
+ onSelect?.()
+ onCloseSheet?.()
+ }}
+ data-active={isActive}
+ className={menuButtonClass}
+ >
+ {content}
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx
new file mode 100644
index 00000000000..5c95ddc9cdc
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx
@@ -0,0 +1,66 @@
+import type { Route } from 'components/ui/ui.types'
+import Link from 'next/link'
+import { cn, sidebarMenuButtonVariants, SidebarMenuItem } from 'ui'
+
+import { isDirectLinkAtTopLevel } from './useMobileMenuNavigation'
+
+export interface TopLevelRouteItemProps {
+ route: Route
+ isActive: boolean
+ hasSubmenu: boolean
+ onTopLevelClick: (route: Route) => void
+ onCloseSheet?: () => void
+}
+
+export function TopLevelRouteItem({
+ route,
+ isActive,
+ hasSubmenu,
+ onTopLevelClick,
+ onCloseSheet,
+}: TopLevelRouteItemProps) {
+ const hasItems = hasSubmenu && !isDirectLinkAtTopLevel(route)
+ const content = (
+ <>
+ {route.icon && (
+
+ {route.icon}
+
+ )}
+
{route.label}
+ >
+ )
+ const menuButtonClass = cn(
+ sidebarMenuButtonVariants({ size: 'default', hasIcon: !!route.icon }),
+ route.disabled && 'opacity-50 pointer-events-none'
+ )
+
+ return (
+
+ {hasItems ? (
+
+ ) : route.link ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts
new file mode 100644
index 00000000000..fec0e2d4249
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts
@@ -0,0 +1,4 @@
+export { MobileMenuContent } from './MobileMenuContent'
+export type { MobileMenuContentProps } from './MobileMenuContent'
+export { getSectionKeyFromPathname } from './MobileMenuContent.utils'
+export { getProductMenuComponent } from './mobileProductMenuRegistry'
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx
new file mode 100644
index 00000000000..74932451c94
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx
@@ -0,0 +1,22 @@
+import React, { type ComponentType } from 'react'
+
+/**
+ * Lazy-loaded org menu components for the mobile org sheet submenu.
+ * Sections without a dedicated submenu map to null (they navigate directly).
+ */
+export const MOBILE_ORG_MENU_REGISTRY: Record
| null> = {
+ projects: null,
+ team: null,
+ integrations: null,
+ usage: null,
+ billing: null,
+ settings: React.lazy(() =>
+ import('@/components/layouts/ProjectLayout/OrganizationSettingsMenu').then((m) => ({
+ default: m.OrganizationSettingsMenu,
+ }))
+ ),
+}
+
+export function getOrgMenuComponent(sectionKey: string): ComponentType | null {
+ return MOBILE_ORG_MENU_REGISTRY[sectionKey] ?? null
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx
new file mode 100644
index 00000000000..aa882e0a170
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx
@@ -0,0 +1,75 @@
+import React, { type ComponentType } from 'react'
+
+/**
+ * Lazy-loaded product menu components to avoid circular dependencies
+ * (registry -> Layout -> ProjectLayout -> MobileMenuContent -> registry).
+ * Sections without a dedicated product menu map to null.
+ */
+export const MOBILE_PRODUCT_MENU_REGISTRY: Record = {
+ HOME: null,
+ editor: React.lazy(() =>
+ import('components/layouts/TableEditorLayout/TableEditorMenu').then((m) => ({
+ default: m.TableEditorMenu,
+ }))
+ ),
+ sql: React.lazy(() =>
+ import('components/layouts/SQLEditorLayout/SQLEditorMenu').then((m) => ({
+ default: m.SQLEditorMenu,
+ }))
+ ),
+ database: React.lazy(() =>
+ import('components/layouts/DatabaseLayout/DatabaseLayout').then((m) => ({
+ default: m.DatabaseProductMenu,
+ }))
+ ),
+ auth: React.lazy(() =>
+ import('components/layouts/AuthLayout/AuthLayout').then((m) => ({
+ default: m.AuthProductMenu,
+ }))
+ ),
+ storage: React.lazy(() =>
+ import('components/interfaces/Storage/StorageMenuV2').then((m) => ({
+ default: m.StorageMenuV2,
+ }))
+ ),
+ functions: React.lazy(() =>
+ import('components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout').then((m) => ({
+ default: m.EdgeFunctionsProductMenu,
+ }))
+ ),
+ realtime: React.lazy(() =>
+ import('components/layouts/RealtimeLayout/RealtimeLayout').then((m) => ({
+ default: m.RealtimeProductMenu,
+ }))
+ ),
+ advisors: React.lazy(() =>
+ import('components/layouts/AdvisorsLayout/AdvisorsSidebarMenu').then((m) => ({
+ default: m.AdvisorsSidebarMenu,
+ }))
+ ),
+ observability: React.lazy(() =>
+ import('components/layouts/ObservabilityLayout/ObservabilityMenu').then((m) => ({
+ default: m.default,
+ }))
+ ),
+ logs: React.lazy(() =>
+ import('components/layouts/LogsLayout/LogsSidebarMenuV2').then((m) => ({
+ default: m.LogsSidebarMenuV2,
+ }))
+ ),
+ api: null,
+ integrations: React.lazy(() =>
+ import('components/layouts/Integrations/IntegrationsProductMenu').then((m) => ({
+ default: m.IntegrationsProductMenu,
+ }))
+ ),
+ settings: React.lazy(() =>
+ import('components/layouts/ProjectSettingsLayout/SettingsLayout').then((m) => ({
+ default: m.SettingsProductMenu,
+ }))
+ ),
+}
+
+export function getProductMenuComponent(sectionKey: string): ComponentType | null {
+ return MOBILE_PRODUCT_MENU_REGISTRY[sectionKey] ?? null
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts
new file mode 100644
index 00000000000..2a92d079bd8
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts
@@ -0,0 +1,75 @@
+'use client'
+
+import type { Route } from 'components/ui/ui.types'
+import { useRouter } from 'next/router'
+import { useCallback, useState } from 'react'
+
+import { getProductMenuComponent } from './mobileProductMenuRegistry'
+
+const TOP_LEVEL_DIRECT_LINK_KEYS = ['editor', 'sql'] as const
+
+export function isDirectLinkAtTopLevel(route: Route): boolean {
+ return TOP_LEVEL_DIRECT_LINK_KEYS.includes(
+ route.key as (typeof TOP_LEVEL_DIRECT_LINK_KEYS)[number]
+ )
+}
+
+export function routeHasSubmenu(route: Route): boolean {
+ if (route.items && Array.isArray(route.items) && route.items.length > 0) return true
+ return getProductMenuComponent(route.key) !== null
+}
+
+interface UseMobileMenuNavigationParams {
+ currentSectionKey: string | null
+ hasCurrentProductMenu: boolean
+ onCloseSheet?: () => void
+}
+
+export function useMobileMenuNavigation({
+ currentSectionKey,
+ hasCurrentProductMenu,
+ onCloseSheet,
+}: UseMobileMenuNavigationParams) {
+ const router = useRouter()
+
+ const [viewLevel, setViewLevel] = useState<'top' | 'section'>(
+ hasCurrentProductMenu && currentSectionKey ? 'section' : 'top'
+ )
+ const [selectedSectionKey, setSelectedSectionKey] = useState(null)
+
+ const handleTopLevelClick = useCallback(
+ (route: Route) => {
+ if (route.disabled) return
+
+ if (isDirectLinkAtTopLevel(route) && route.link) {
+ router.push(route.link)
+ onCloseSheet?.()
+ return
+ }
+
+ if (routeHasSubmenu(route)) {
+ setSelectedSectionKey(route.key)
+ setViewLevel('section')
+ return
+ }
+
+ if (route.link) {
+ router.push(route.link)
+ onCloseSheet?.()
+ }
+ },
+ [router, onCloseSheet]
+ )
+
+ const handleBackToTop = useCallback(() => {
+ setViewLevel('top')
+ setSelectedSectionKey(null)
+ }, [])
+
+ return {
+ viewLevel,
+ selectedSectionKey,
+ handleTopLevelClick,
+ handleBackToTop,
+ }
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts
new file mode 100644
index 00000000000..4af4d87bef9
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts
@@ -0,0 +1,38 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+
+import { getOrgMenuComponent } from './mobileOrgMenuRegistry'
+import type { OrgNavItem } from './OrgMenuContent.utils'
+
+export function orgItemHasSubmenu(item: OrgNavItem): boolean {
+ return getOrgMenuComponent(item.key) !== null
+}
+
+interface UseOrgMenuNavigationParams {
+ initialSectionKey: string | null
+}
+
+export function useOrgMenuNavigation({ initialSectionKey }: UseOrgMenuNavigationParams) {
+ const [viewLevel, setViewLevel] = useState<'top' | 'section'>(
+ initialSectionKey ? 'section' : 'top'
+ )
+ const [selectedSectionKey, setSelectedSectionKey] = useState(initialSectionKey)
+
+ const handleSubmenuClick = useCallback((item: OrgNavItem) => {
+ setSelectedSectionKey(item.key)
+ setViewLevel('section')
+ }, [])
+
+ const handleBackToTop = useCallback(() => {
+ setViewLevel('top')
+ setSelectedSectionKey(null)
+ }, [])
+
+ return {
+ viewLevel,
+ selectedSectionKey,
+ handleSubmenuClick,
+ handleBackToTop,
+ }
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
index 74aa05af224..a77795d82c4 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
@@ -34,6 +34,8 @@ export const SIDEBAR_KEYS = {
HELP_PANEL: 'help-panel',
} as const
+export type TYPEOF_SIDEBAR_KEYS = (typeof SIDEBAR_KEYS)[keyof typeof SIDEBAR_KEYS]
+
export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
index ac76bf48164..2f9ad556ac6 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
@@ -2,7 +2,6 @@ import { useBreakpoint } from 'common'
import { useEffect } from 'react'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { cn, ResizableHandle, ResizablePanel } from 'ui'
-import { MobileSheetNav } from 'ui-patterns'
import { useMobileSheet } from '../NavigationBar/MobileSheetContext'
@@ -19,13 +18,17 @@ export const LayoutSidebar = ({
maxSize = '50',
defaultSize = '30',
}: LayoutSidebarProps) => {
- const { activeSidebar, closeActive } = useSidebarManagerSnapshot()
+ const { activeSidebar } = useSidebarManagerSnapshot()
const isMobile = useBreakpoint('md')
- const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet()
+ const { setContent: setMobileSheetContent } = useMobileSheet()
- // On mobile the sidebar content is rendered in MobileSheetNav
+ // On mobile, sidebar content is shown in the sheet. Sync sheet content with active sidebar; clear when none or when switching to desktop.
useEffect(() => {
- if (isMobile && activeSidebar?.component) {
+ if (!isMobile) {
+ setMobileSheetContent(null)
+ return
+ }
+ if (activeSidebar?.component) {
setMobileSheetContent(activeSidebar.id)
} else {
setMobileSheetContent(null)
@@ -33,23 +36,7 @@ export const LayoutSidebar = ({
}, [isMobile, activeSidebar, setMobileSheetContent])
if (!activeSidebar?.component) return null
-
- if (isMobile)
- return (
- {
- if (!open) {
- setMobileSheetContent(null)
- closeActive()
- }
- }}
- >
- {activeSidebar?.component?.()}
-
- )
+ if (isMobile) return null
return (
<>
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx
index 15873138927..58031a0440f 100644
--- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx
@@ -1,19 +1,19 @@
import { useParams } from 'common'
-import { SidebarContent } from 'components/interfaces/Sidebar'
import { IS_PLATFORM } from 'lib/constants'
import { Menu, Search } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
-import { useState } from 'react'
import { Button, cn } from 'ui'
-import { CommandMenuTrigger, MobileSheetNav } from 'ui-patterns'
+import { CommandMenuTrigger } from 'ui-patterns'
+
+import { useMobileSheet } from './MobileSheetContext'
export const ICON_SIZE = 20
export const ICON_STROKE_WIDTH = 1.5
const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
const router = useRouter()
- const [isSheetOpen, setIsSheetOpen] = useState(false)
+ const { openMenu } = useMobileSheet()
const { ref: projectRef } = useParams()
return (
@@ -39,6 +39,7 @@ const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) =
{!hideMobileMenu && (
@@ -61,14 +60,11 @@ const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) =
type="default"
className="flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30"
icon={
}
- onClick={() => setIsSheetOpen(true)}
+ onClick={() => openMenu()}
/>
)}
-
-
-
)
}
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx
index 72a6f76ff50..336ae4b5a4a 100644
--- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx
@@ -1,24 +1,50 @@
-import type { PropsWithChildren } from 'react'
-import { createContext, useCallback, useContext, useState } from 'react'
+import type { PropsWithChildren, ReactNode } from 'react'
+import { createContext, useCallback, useContext, useRef, useState } from 'react'
-export type MobileSheetContentType = null | string
+import type { TYPEOF_SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
+
+/**
+ * Sheet content: null = closed; sidebar id = one of SIDEBAR_KEYS; ReactNode = custom content (menu, search, etc.).
+ */
+export type MobileSheetContentType = null | TYPEOF_SIDEBAR_KEYS | ReactNode
type MobileSheetContextValue = {
content: MobileSheetContentType
setContent: (content: MobileSheetContentType) => void
+ isOpen: boolean
+ /** Open the sheet with the current menu content. Registered by ProjectLayout (project menu) or OrganizationLayout (org menu). */
+ openMenu: () => void
+ /** Register the callback run when openMenu() is called (e.g. from MobileNavigationBar). Returns an unregister function. */
+ registerOpenMenu: (fn: () => void) => () => void
}
const MobileSheetContext = createContext