| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common'
- import { AnimatePresence, motion } from 'framer-motion'
- import { XIcon } from 'lucide-react'
- import Head from 'next/head'
- import { useRouter } from 'next/router'
- import {
- forwardRef,
- Fragment,
- useEffect,
- useLayoutEffect,
- type PropsWithChildren,
- type ReactNode,
- } from 'react'
- import {
- Alert,
- AlertDescription,
- AlertTitle,
- cn,
- LogoLoader,
- ResizableHandle,
- ResizablePanel,
- ResizablePanelGroup,
- useIsMobile,
- usePanelRef,
- } from 'ui'
- import { useEditorType } from '../editors/EditorsLayout.hooks'
- import { useSetMainScrollContainer } from '../MainScrollContainerContext'
- import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
- import ProductMenuBar from '../Navigation/ProductMenuBar'
- import BuildingState from './BuildingState'
- import ConnectingState from './ConnectingState'
- import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
- import { LoadingState } from './LoadingState'
- import { ProjectPausedState } from './PausedState/ProjectPausedState'
- import { PauseFailedState } from './PauseFailedState'
- import { PausingState } from './PausingState'
- import { ResizingState } from './ResizingState'
- import RestartingState from './RestartingState'
- import { RestoreFailedState } from './RestoreFailedState'
- import { RestoringState } from './RestoringState'
- import { UnhealthyState } from './UnhealthyState'
- import { UpgradingState } from './UpgradingState'
- import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
- import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
- import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
- import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import PartnerIcon from '@/components/ui/PartnerIcon'
- import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
- import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
- import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
- import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { withAuth } from '@/hooks/misc/withAuth'
- import { PROJECT_STATUS } from '@/lib/constants'
- import { MANAGED_BY } from '@/lib/constants/infrastructure'
- import { buildStudioPageTitle } from '@/lib/page-title'
- import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
- import { useAppStateSnapshot } from '@/state/app-state'
- import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
- // [Joshen] This is temporary while we unblock users from managing their project
- // if their project is not responding well for any reason. Eventually needs a bit of an overhaul
- const routesToIgnoreProjectDetailsRequest = [
- '/project/[ref]/settings/infrastructure',
- '/project/[ref]/settings/addons',
- '/project/[ref]/settings/general',
- '/project/[ref]/database/settings',
- '/project/[ref]/storage/settings',
- ]
- const routesToIgnoreDBConnection = [
- '/project/[ref]/branches',
- '/project/[ref]/database/backups',
- '/project/[ref]/settings',
- '/project/[ref]/functions',
- '/project/[ref]/logs',
- ]
- const routesToIgnorePostgrestConnection = [
- '/project/[ref]/settings/general',
- '/project/[ref]/settings/infrastructure',
- '/project/[ref]/settings/addons',
- '/project/[ref]/database/settings',
- '/project/[ref]/reports',
- ]
- const DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY =
- LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('unknown', 'unknown')
- function getProjectIntegrationBannerDismissKey({
- projectRef,
- integrationSource,
- }: {
- projectRef?: string
- integrationSource?: string | null
- }) {
- if (!projectRef || !integrationSource) return DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY
- return LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED(projectRef, integrationSource)
- }
- export interface ProjectLayoutProps {
- isLoading?: boolean
- isBlocking?: boolean
- product?: string
- productMenu?: ReactNode
- browserTitle?: {
- entity?: string
- section?: string
- override?: string
- }
- // Deprecated: use browserTitle.entity instead. Kept for backwards compatibility.
- selectedTable?: string
- resizableSidebar?: boolean
- productMenuClassName?: string
- }
- export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
- (
- {
- isLoading = false,
- isBlocking = true,
- product = '',
- productMenu,
- browserTitle,
- children,
- selectedTable,
- resizableSidebar = false,
- productMenuClassName,
- },
- ref
- ) => {
- const router = useRouter()
- const { data: selectedOrganization } = useSelectedOrganizationQuery()
- const { data: selectedProject } = useSelectedProjectQuery()
- const { addBanner, dismissBanner } = useBannerStack()
- const { data: resourceWarnings } = useResourceWarningsQuery({
- slug: selectedOrganization?.slug,
- })
- const projectResourceWarnings = resourceWarnings?.find(
- (w) => w.project === selectedProject?.ref
- )
- const isComputeNearExhaustion =
- !!projectResourceWarnings?.cpu_exhaustion ||
- !!projectResourceWarnings?.memory_and_swap_exhaustion ||
- !!projectResourceWarnings?.disk_space_exhaustion ||
- !!projectResourceWarnings?.disk_io_exhaustion
- const isNanoCompute = selectedProject?.infra_compute_size === 'nano'
- const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion
- const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''),
- false
- )
- const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] =
- useLocalStorageQuery(
- getProjectIntegrationBannerDismissKey({
- projectRef: selectedProject?.ref,
- integrationSource: selectedProject?.integration_source,
- }),
- false
- )
- const { showSidebar } = useAppStateSnapshot()
- const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
- const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
- const currentSectionKey = getSectionKeyFromPathname(pathname)
- const setMainScrollContainer = useSetMainScrollContainer()
- const combinedRef = mergeRefs(ref, setMainScrollContainer)
- const { appTitle } = useCustomContent(['app:title'])
- const brandTitle = appTitle || 'Briven'
- const isMobile = useIsMobile()
- const editor = useEditorType()
- const forceShowProductMenu = editor === undefined
- const sideBarIsOpen = (forceShowProductMenu || showSidebar) && !isMobile
- const panelRef = usePanelRef()
- const projectName = selectedProject?.name
- const organizationName = selectedOrganization?.name
- const pageTitle =
- browserTitle?.override ||
- buildStudioPageTitle({
- entity: browserTitle?.entity ?? selectedTable,
- section: browserTitle?.section,
- surface: product,
- project: projectName,
- org: organizationName,
- brand: brandTitle,
- }) ||
- brandTitle
- const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
- const ignorePausedState =
- router.pathname === '/project/[ref]' ||
- router.pathname.includes('/project/[ref]/settings') ||
- router.pathname.includes('/project/[ref]/functions') ||
- router.pathname.includes('/project/[ref]/logs')
- const showPausedState = isPaused && !ignorePausedState
- const showStripeProjectBanner =
- selectedProject?.integration_source === 'stripe_projects' &&
- !isProjectIntegrationBannerDismissed
- useEffect(() => {
- if (!selectedProject?.ref) return
- const isProjectHomepage = router.pathname === '/project/[ref]'
- if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
- addBanner({
- id: BANNER_ID.FREE_MICRO_UPGRADE,
- isDismissed: false,
- content: <BannerFreeMicroUpgrade />,
- priority: 2,
- })
- } else {
- dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
- }
- }, [
- router.pathname,
- selectedProject?.ref,
- showUpgradeBanner,
- isFreeMicroUpgradeBannerDismissed,
- addBanner,
- dismissBanner,
- ])
- useLayoutEffect(() => {
- const unregister = registerOpenMenu(() => {
- setMobileSheetContent(
- <MobileMenuContent
- currentProductMenu={productMenu ?? null}
- currentProduct={product}
- currentSectionKey={currentSectionKey}
- onCloseSheet={() => setMobileSheetContent(null)}
- />
- )
- })
- return unregister
- }, [registerOpenMenu, productMenu, product, currentSectionKey, setMobileSheetContent])
- return (
- <>
- <Head>
- <title>{pageTitle}</title>
- <meta name="description" content="Briven Studio" />
- </Head>
- <div className="flex flex-row h-full w-full">
- <ResizablePanelGroup orientation="horizontal">
- {productMenu && sideBarIsOpen && (
- <ResizablePanel
- panelRef={panelRef}
- minSize={256}
- maxSize={resizableSidebar ? 512 : 256}
- defaultSize={256}
- id="panel-left"
- disabled={!resizableSidebar}
- >
- <AnimatePresence initial={false}>
- <motion.div
- initial={{ width: 0, opacity: 0, height: '100%' }}
- animate={{ width: 'auto', opacity: 1, height: '100%' }}
- exit={{ width: 0, opacity: 0, height: '100%' }}
- className="h-full"
- transition={{ duration: 0.12 }}
- >
- <MenuBarWrapper
- isLoading={isLoading}
- isBlocking={isBlocking}
- productMenu={productMenu}
- >
- <ProductMenuBar title={product} className={productMenuClassName}>
- {productMenu}
- </ProductMenuBar>
- </MenuBarWrapper>
- </motion.div>
- </AnimatePresence>
- </ResizablePanel>
- )}
- {productMenu && sideBarIsOpen && (
- <ResizableHandle
- withHandle
- disabled={resizableSidebar ? false : true}
- className="hidden md:flex"
- />
- )}
- <ResizablePanel
- className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
- id="panel-project-content"
- >
- <main
- className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
- ref={combinedRef}
- >
- {showStripeProjectBanner && (
- <Alert
- variant="default"
- className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
- >
- <PartnerIcon
- organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
- showTooltip={false}
- size="medium"
- />
- <div className="flex-1">
- <AlertTitle>This project is connected to Stripe</AlertTitle>
- <AlertDescription>
- Changes made here may affect your connected Stripe project.
- </AlertDescription>
- </div>
- <ButtonTooltip
- type="text"
- icon={<XIcon size={14} />}
- className="h-7 w-7 p-0"
- onClick={() => setIsProjectIntegrationBannerDismissed(true)}
- aria-label="Dismiss project integration banner"
- tooltip={{ content: { text: 'Dismiss' } }}
- />
- </Alert>
- )}
- {showPausedState ? (
- <div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center px-4">
- <div className="w-full">
- <ProjectPausedState product={product} />
- </div>
- </div>
- ) : (
- <ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
- <ResourceExhaustionWarningBanner />
- {children}
- </ContentWrapper>
- )}
- </main>
- </ResizablePanel>
- </ResizablePanelGroup>
- </div>
- <CreateBranchModal />
- <ProjectAPIDocs />
- </>
- )
- }
- )
- ProjectLayout.displayName = 'ProjectLayout'
- export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
- interface MenuBarWrapperProps {
- isLoading: boolean
- isBlocking?: boolean
- productMenu?: ReactNode
- children: ReactNode
- }
- const MenuBarWrapper = ({
- isLoading,
- isBlocking = true,
- productMenu,
- children,
- }: MenuBarWrapperProps) => {
- const router = useRouter()
- const { data: selectedProject } = useSelectedProjectQuery()
- const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
- if (!isBlocking) {
- return children
- }
- const showMenuBar =
- !requiresProjectDetails || (requiresProjectDetails && selectedProject !== undefined)
- return !isLoading && productMenu && showMenuBar ? children : null
- }
- interface ContentWrapperProps {
- isLoading: boolean
- isBlocking?: boolean
- children: ReactNode
- }
- /**
- * Check project.status to show building state or error state
- *
- * [Joshen] As of 210422: Current testing connection by pinging postgres
- * Ideally we'd have a more specific monitoring of the project such as during restarts
- * But that will come later: https://briven.slack.com/archives/C01D6TWFFFW/p1650427619665549
- *
- * Just note that this logic does not differentiate between a "restarting" state and
- * a "something is wrong and can't connect to project" state.
- *
- * [TODO] Next iteration should scrape long polling and just listen to the project's status
- */
- const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapperProps) => {
- const router = useRouter()
- const { ref } = useParams()
- const state = useDatabaseSelectorStateSnapshot()
- const { data: selectedProject } = useSelectedProjectQuery()
- const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
- const isHomePage = router.pathname === '/project/[ref]'
- const requiresDbConnection = !routesToIgnoreDBConnection.some((x) => router.pathname.includes(x))
- const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
- const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
- const isRestarting = selectedProject?.status === PROJECT_STATUS.RESTARTING
- const isResizing = selectedProject?.status === PROJECT_STATUS.RESIZING
- const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING
- const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING
- const isProjectRestoreFailed = selectedProject?.status === PROJECT_STATUS.RESTORE_FAILED
- const isProjectBuilding =
- selectedProject?.status === PROJECT_STATUS.COMING_UP ||
- selectedProject?.status === PROJECT_STATUS.UNKNOWN
- const isProjectPausing = selectedProject?.status === PROJECT_STATUS.PAUSING
- const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
- const isProjectUnhealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
- const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
- const ignoreUnhealthyState =
- isHomePage ||
- router.pathname.includes('/project/[ref]/settings') ||
- router.pathname.includes('/project/[ref]/logs')
- const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
- // Don't show building state on the home page — it handles building state inline
- const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
- useEffect(() => {
- if (shouldRedirectToHomeForBuilding && ref) {
- router.replace(`/project/${ref}`)
- }
- }, [shouldRedirectToHomeForBuilding, ref, router])
- useEffect(() => {
- if (ref) state.setSelectedDatabaseId(ref)
- }, [ref])
- if (isBlocking && (isLoading || (requiresProjectDetails && selectedProject === undefined))) {
- return router.pathname.endsWith('[ref]') ? <LoadingState /> : <LogoLoader />
- }
- if (isRestarting && !isBackupsPage) {
- return <RestartingState />
- }
- if (isResizing && !isBackupsPage) {
- return <ResizingState />
- }
- if (isProjectUpgrading && !isBackupsPage) {
- return <UpgradingState />
- }
- if (isProjectPausing) {
- return <PausingState project={selectedProject} />
- }
- if (isProjectPauseFailed) {
- return <PauseFailedState />
- }
- if (isProjectUnhealthy && !ignoreUnhealthyState) {
- return <UnhealthyState />
- }
- if (requiresPostgrestConnection && isProjectOffline) {
- return <ConnectingState project={selectedProject} />
- }
- if (requiresDbConnection && isProjectRestoring) {
- return <RestoringState />
- }
- if (requiresDbConnection && isProjectRestoreFailed) {
- return <RestoreFailedState />
- }
- if (shouldRedirectToHomeForBuilding) {
- return <LogoLoader />
- }
- if (shouldShowBuildingState) {
- return <BuildingState />
- }
- return <Fragment key={selectedProject?.ref}>{children}</Fragment>
- }
|