index.tsx493 lines · main
1import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common'
2import { AnimatePresence, motion } from 'framer-motion'
3import { XIcon } from 'lucide-react'
4import Head from 'next/head'
5import { useRouter } from 'next/router'
6import {
7 forwardRef,
8 Fragment,
9 useEffect,
10 useLayoutEffect,
11 type PropsWithChildren,
12 type ReactNode,
13} from 'react'
14import {
15 Alert,
16 AlertDescription,
17 AlertTitle,
18 cn,
19 LogoLoader,
20 ResizableHandle,
21 ResizablePanel,
22 ResizablePanelGroup,
23 useIsMobile,
24 usePanelRef,
25} from 'ui'
26
27import { useEditorType } from '../editors/EditorsLayout.hooks'
28import { useSetMainScrollContainer } from '../MainScrollContainerContext'
29import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
30import ProductMenuBar from '../Navigation/ProductMenuBar'
31import BuildingState from './BuildingState'
32import ConnectingState from './ConnectingState'
33import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
34import { LoadingState } from './LoadingState'
35import { ProjectPausedState } from './PausedState/ProjectPausedState'
36import { PauseFailedState } from './PauseFailedState'
37import { PausingState } from './PausingState'
38import { ResizingState } from './ResizingState'
39import RestartingState from './RestartingState'
40import { RestoreFailedState } from './RestoreFailedState'
41import { RestoringState } from './RestoringState'
42import { UnhealthyState } from './UnhealthyState'
43import { UpgradingState } from './UpgradingState'
44import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
45import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
46import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
47import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
48import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
49import PartnerIcon from '@/components/ui/PartnerIcon'
50import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
51import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
52import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
53import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
54import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
55import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
56import { withAuth } from '@/hooks/misc/withAuth'
57import { PROJECT_STATUS } from '@/lib/constants'
58import { MANAGED_BY } from '@/lib/constants/infrastructure'
59import { buildStudioPageTitle } from '@/lib/page-title'
60import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
61import { useAppStateSnapshot } from '@/state/app-state'
62import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
63
64// [Joshen] This is temporary while we unblock users from managing their project
65// if their project is not responding well for any reason. Eventually needs a bit of an overhaul
66const routesToIgnoreProjectDetailsRequest = [
67 '/project/[ref]/settings/infrastructure',
68 '/project/[ref]/settings/addons',
69 '/project/[ref]/settings/general',
70 '/project/[ref]/database/settings',
71 '/project/[ref]/storage/settings',
72]
73
74const routesToIgnoreDBConnection = [
75 '/project/[ref]/branches',
76 '/project/[ref]/database/backups',
77 '/project/[ref]/settings',
78 '/project/[ref]/functions',
79 '/project/[ref]/logs',
80]
81
82const routesToIgnorePostgrestConnection = [
83 '/project/[ref]/settings/general',
84 '/project/[ref]/settings/infrastructure',
85 '/project/[ref]/settings/addons',
86 '/project/[ref]/database/settings',
87 '/project/[ref]/reports',
88]
89
90const DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY =
91 LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('unknown', 'unknown')
92
93function getProjectIntegrationBannerDismissKey({
94 projectRef,
95 integrationSource,
96}: {
97 projectRef?: string
98 integrationSource?: string | null
99}) {
100 if (!projectRef || !integrationSource) return DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY
101
102 return LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED(projectRef, integrationSource)
103}
104
105export interface ProjectLayoutProps {
106 isLoading?: boolean
107 isBlocking?: boolean
108 product?: string
109 productMenu?: ReactNode
110 browserTitle?: {
111 entity?: string
112 section?: string
113 override?: string
114 }
115 // Deprecated: use browserTitle.entity instead. Kept for backwards compatibility.
116 selectedTable?: string
117 resizableSidebar?: boolean
118 productMenuClassName?: string
119}
120
121export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
122 (
123 {
124 isLoading = false,
125 isBlocking = true,
126 product = '',
127 productMenu,
128 browserTitle,
129 children,
130 selectedTable,
131 resizableSidebar = false,
132
133 productMenuClassName,
134 },
135 ref
136 ) => {
137 const router = useRouter()
138 const { data: selectedOrganization } = useSelectedOrganizationQuery()
139 const { data: selectedProject } = useSelectedProjectQuery()
140 const { addBanner, dismissBanner } = useBannerStack()
141 const { data: resourceWarnings } = useResourceWarningsQuery({
142 slug: selectedOrganization?.slug,
143 })
144 const projectResourceWarnings = resourceWarnings?.find(
145 (w) => w.project === selectedProject?.ref
146 )
147 const isComputeNearExhaustion =
148 !!projectResourceWarnings?.cpu_exhaustion ||
149 !!projectResourceWarnings?.memory_and_swap_exhaustion ||
150 !!projectResourceWarnings?.disk_space_exhaustion ||
151 !!projectResourceWarnings?.disk_io_exhaustion
152 const isNanoCompute = selectedProject?.infra_compute_size === 'nano'
153 const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion
154 const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery(
155 LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''),
156 false
157 )
158 const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] =
159 useLocalStorageQuery(
160 getProjectIntegrationBannerDismissKey({
161 projectRef: selectedProject?.ref,
162 integrationSource: selectedProject?.integration_source,
163 }),
164 false
165 )
166 const { showSidebar } = useAppStateSnapshot()
167 const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
168
169 const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
170 const currentSectionKey = getSectionKeyFromPathname(pathname)
171
172 const setMainScrollContainer = useSetMainScrollContainer()
173 const combinedRef = mergeRefs(ref, setMainScrollContainer)
174
175 const { appTitle } = useCustomContent(['app:title'])
176 const brandTitle = appTitle || 'Briven'
177
178 const isMobile = useIsMobile()
179
180 const editor = useEditorType()
181 const forceShowProductMenu = editor === undefined
182 const sideBarIsOpen = (forceShowProductMenu || showSidebar) && !isMobile
183
184 const panelRef = usePanelRef()
185
186 const projectName = selectedProject?.name
187 const organizationName = selectedOrganization?.name
188 const pageTitle =
189 browserTitle?.override ||
190 buildStudioPageTitle({
191 entity: browserTitle?.entity ?? selectedTable,
192 section: browserTitle?.section,
193 surface: product,
194 project: projectName,
195 org: organizationName,
196 brand: brandTitle,
197 }) ||
198 brandTitle
199
200 const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
201
202 const ignorePausedState =
203 router.pathname === '/project/[ref]' ||
204 router.pathname.includes('/project/[ref]/settings') ||
205 router.pathname.includes('/project/[ref]/functions') ||
206 router.pathname.includes('/project/[ref]/logs')
207 const showPausedState = isPaused && !ignorePausedState
208 const showStripeProjectBanner =
209 selectedProject?.integration_source === 'stripe_projects' &&
210 !isProjectIntegrationBannerDismissed
211
212 useEffect(() => {
213 if (!selectedProject?.ref) return
214 const isProjectHomepage = router.pathname === '/project/[ref]'
215 if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
216 addBanner({
217 id: BANNER_ID.FREE_MICRO_UPGRADE,
218 isDismissed: false,
219 content: <BannerFreeMicroUpgrade />,
220 priority: 2,
221 })
222 } else {
223 dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
224 }
225 }, [
226 router.pathname,
227 selectedProject?.ref,
228 showUpgradeBanner,
229 isFreeMicroUpgradeBannerDismissed,
230 addBanner,
231 dismissBanner,
232 ])
233
234 useLayoutEffect(() => {
235 const unregister = registerOpenMenu(() => {
236 setMobileSheetContent(
237 <MobileMenuContent
238 currentProductMenu={productMenu ?? null}
239 currentProduct={product}
240 currentSectionKey={currentSectionKey}
241 onCloseSheet={() => setMobileSheetContent(null)}
242 />
243 )
244 })
245 return unregister
246 }, [registerOpenMenu, productMenu, product, currentSectionKey, setMobileSheetContent])
247
248 return (
249 <>
250 <Head>
251 <title>{pageTitle}</title>
252 <meta name="description" content="Briven Studio" />
253 </Head>
254 <div className="flex flex-row h-full w-full">
255 <ResizablePanelGroup orientation="horizontal">
256 {productMenu && sideBarIsOpen && (
257 <ResizablePanel
258 panelRef={panelRef}
259 minSize={256}
260 maxSize={resizableSidebar ? 512 : 256}
261 defaultSize={256}
262 id="panel-left"
263 disabled={!resizableSidebar}
264 >
265 <AnimatePresence initial={false}>
266 <motion.div
267 initial={{ width: 0, opacity: 0, height: '100%' }}
268 animate={{ width: 'auto', opacity: 1, height: '100%' }}
269 exit={{ width: 0, opacity: 0, height: '100%' }}
270 className="h-full"
271 transition={{ duration: 0.12 }}
272 >
273 <MenuBarWrapper
274 isLoading={isLoading}
275 isBlocking={isBlocking}
276 productMenu={productMenu}
277 >
278 <ProductMenuBar title={product} className={productMenuClassName}>
279 {productMenu}
280 </ProductMenuBar>
281 </MenuBarWrapper>
282 </motion.div>
283 </AnimatePresence>
284 </ResizablePanel>
285 )}
286 {productMenu && sideBarIsOpen && (
287 <ResizableHandle
288 withHandle
289 disabled={resizableSidebar ? false : true}
290 className="hidden md:flex"
291 />
292 )}
293 <ResizablePanel
294 className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
295 id="panel-project-content"
296 >
297 <main
298 className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
299 ref={combinedRef}
300 >
301 {showStripeProjectBanner && (
302 <Alert
303 variant="default"
304 className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
305 >
306 <PartnerIcon
307 organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
308 showTooltip={false}
309 size="medium"
310 />
311 <div className="flex-1">
312 <AlertTitle>This project is connected to Stripe</AlertTitle>
313 <AlertDescription>
314 Changes made here may affect your connected Stripe project.
315 </AlertDescription>
316 </div>
317 <ButtonTooltip
318 type="text"
319 icon={<XIcon size={14} />}
320 className="h-7 w-7 p-0"
321 onClick={() => setIsProjectIntegrationBannerDismissed(true)}
322 aria-label="Dismiss project integration banner"
323 tooltip={{ content: { text: 'Dismiss' } }}
324 />
325 </Alert>
326 )}
327 {showPausedState ? (
328 <div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center px-4">
329 <div className="w-full">
330 <ProjectPausedState product={product} />
331 </div>
332 </div>
333 ) : (
334 <ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
335 <ResourceExhaustionWarningBanner />
336 {children}
337 </ContentWrapper>
338 )}
339 </main>
340 </ResizablePanel>
341 </ResizablePanelGroup>
342 </div>
343 <CreateBranchModal />
344 <ProjectAPIDocs />
345 </>
346 )
347 }
348)
349
350ProjectLayout.displayName = 'ProjectLayout'
351
352export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
353
354interface MenuBarWrapperProps {
355 isLoading: boolean
356 isBlocking?: boolean
357 productMenu?: ReactNode
358 children: ReactNode
359}
360
361const MenuBarWrapper = ({
362 isLoading,
363 isBlocking = true,
364 productMenu,
365 children,
366}: MenuBarWrapperProps) => {
367 const router = useRouter()
368 const { data: selectedProject } = useSelectedProjectQuery()
369 const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
370
371 if (!isBlocking) {
372 return children
373 }
374
375 const showMenuBar =
376 !requiresProjectDetails || (requiresProjectDetails && selectedProject !== undefined)
377
378 return !isLoading && productMenu && showMenuBar ? children : null
379}
380
381interface ContentWrapperProps {
382 isLoading: boolean
383 isBlocking?: boolean
384 children: ReactNode
385}
386
387/**
388 * Check project.status to show building state or error state
389 *
390 * [Joshen] As of 210422: Current testing connection by pinging postgres
391 * Ideally we'd have a more specific monitoring of the project such as during restarts
392 * But that will come later: https://briven.slack.com/archives/C01D6TWFFFW/p1650427619665549
393 *
394 * Just note that this logic does not differentiate between a "restarting" state and
395 * a "something is wrong and can't connect to project" state.
396 *
397 * [TODO] Next iteration should scrape long polling and just listen to the project's status
398 */
399const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapperProps) => {
400 const router = useRouter()
401 const { ref } = useParams()
402 const state = useDatabaseSelectorStateSnapshot()
403 const { data: selectedProject } = useSelectedProjectQuery()
404 const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
405 const isHomePage = router.pathname === '/project/[ref]'
406
407 const requiresDbConnection = !routesToIgnoreDBConnection.some((x) => router.pathname.includes(x))
408 const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
409 const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
410
411 const isRestarting = selectedProject?.status === PROJECT_STATUS.RESTARTING
412 const isResizing = selectedProject?.status === PROJECT_STATUS.RESIZING
413 const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING
414 const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING
415 const isProjectRestoreFailed = selectedProject?.status === PROJECT_STATUS.RESTORE_FAILED
416 const isProjectBuilding =
417 selectedProject?.status === PROJECT_STATUS.COMING_UP ||
418 selectedProject?.status === PROJECT_STATUS.UNKNOWN
419 const isProjectPausing = selectedProject?.status === PROJECT_STATUS.PAUSING
420 const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
421 const isProjectUnhealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
422 const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
423
424 const ignoreUnhealthyState =
425 isHomePage ||
426 router.pathname.includes('/project/[ref]/settings') ||
427 router.pathname.includes('/project/[ref]/logs')
428
429 const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
430
431 // Don't show building state on the home page — it handles building state inline
432 const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
433
434 useEffect(() => {
435 if (shouldRedirectToHomeForBuilding && ref) {
436 router.replace(`/project/${ref}`)
437 }
438 }, [shouldRedirectToHomeForBuilding, ref, router])
439
440 useEffect(() => {
441 if (ref) state.setSelectedDatabaseId(ref)
442 }, [ref])
443
444 if (isBlocking && (isLoading || (requiresProjectDetails && selectedProject === undefined))) {
445 return router.pathname.endsWith('[ref]') ? <LoadingState /> : <LogoLoader />
446 }
447
448 if (isRestarting && !isBackupsPage) {
449 return <RestartingState />
450 }
451
452 if (isResizing && !isBackupsPage) {
453 return <ResizingState />
454 }
455
456 if (isProjectUpgrading && !isBackupsPage) {
457 return <UpgradingState />
458 }
459
460 if (isProjectPausing) {
461 return <PausingState project={selectedProject} />
462 }
463
464 if (isProjectPauseFailed) {
465 return <PauseFailedState />
466 }
467
468 if (isProjectUnhealthy && !ignoreUnhealthyState) {
469 return <UnhealthyState />
470 }
471
472 if (requiresPostgrestConnection && isProjectOffline) {
473 return <ConnectingState project={selectedProject} />
474 }
475
476 if (requiresDbConnection && isProjectRestoring) {
477 return <RestoringState />
478 }
479
480 if (requiresDbConnection && isProjectRestoreFailed) {
481 return <RestoreFailedState />
482 }
483
484 if (shouldRedirectToHomeForBuilding) {
485 return <LogoLoader />
486 }
487
488 if (shouldShowBuildingState) {
489 return <BuildingState />
490 }
491
492 return <Fragment key={selectedProject?.ref}>{children}</Fragment>
493}