PageLayout.tsx149 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import Link from 'next/link' |
| 3 | import { useRouter } from 'next/router' |
| 4 | import { ReactNode } from 'react' |
| 5 | import { Badge, Button, cn, NavMenu, NavMenuItem } from 'ui' |
| 6 | |
| 7 | import { ScaffoldContainer } from '../Scaffold' |
| 8 | import { PageHeader } from './PageHeader' |
| 9 | |
| 10 | export interface NavigationItem { |
| 11 | id?: string |
| 12 | label: string |
| 13 | href?: string |
| 14 | icon?: ReactNode |
| 15 | onClick?: () => void |
| 16 | badge?: string |
| 17 | active?: boolean |
| 18 | } |
| 19 | |
| 20 | interface PageLayoutProps { |
| 21 | children?: ReactNode |
| 22 | title?: string | ReactNode |
| 23 | subtitle?: string | ReactNode |
| 24 | icon?: ReactNode |
| 25 | breadcrumbs?: Array<{ |
| 26 | label?: string |
| 27 | href?: string |
| 28 | element?: ReactNode |
| 29 | }> |
| 30 | primaryActions?: ReactNode |
| 31 | secondaryActions?: ReactNode |
| 32 | navigationItems?: NavigationItem[] |
| 33 | className?: string |
| 34 | size?: 'default' | 'full' | 'large' | 'small' |
| 35 | isCompact?: boolean |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * For rendering a general single column based UI (which covers most of the dashboard's use case) |
| 40 | * |
| 41 | * Pages that would use this would for example be: Auth Sign In / Up, Project settings, Advisors, etc |
| 42 | * |
| 43 | * Pages that would deviate from this are those with dedicated UI, for example: Table Editor, SQL Editor, etc |
| 44 | * |
| 45 | * Handles rendering the following UI behaviors: |
| 46 | * - Page padding depending on the size property |
| 47 | * - Top level breadcrumbs (If applicable) |
| 48 | * - Top level page header (If applicable) |
| 49 | * - Top level tab navigations (If applicable) |
| 50 | * @param title - Title rendered in page header |
| 51 | * @param subtitle - Subtitle rendered in page header, below title |
| 52 | * @param icon - Icon rendered in Page header, to the left of title and subtitle |
| 53 | * @param breadcrumbs - Breadcrumbs rendered in page header, above title. Can be string labels with hrefs or custom elements |
| 54 | * @param primaryActions - TBD |
| 55 | * @param secondaryActions - TBD |
| 56 | * @param navigationItems - Tab navigation rendered below the page header |
| 57 | * @param className - Optional additional class names to be applied |
| 58 | * @param size - Controls padding of the page header only, padding for the content to be controlled by PageContainer (Default: 'default') |
| 59 | * @param isCompact - TBD (Default: false) |
| 60 | */ |
| 61 | export const PageLayout = ({ |
| 62 | children, |
| 63 | title, |
| 64 | subtitle, |
| 65 | icon, |
| 66 | breadcrumbs = [], |
| 67 | primaryActions, |
| 68 | secondaryActions, |
| 69 | navigationItems = [], |
| 70 | className, |
| 71 | size = 'default', |
| 72 | isCompact = false, |
| 73 | }: PageLayoutProps) => { |
| 74 | const { ref } = useParams() |
| 75 | const router = useRouter() |
| 76 | |
| 77 | return ( |
| 78 | <div className={cn('w-full min-h-full flex flex-col items-stretch', className)}> |
| 79 | <ScaffoldContainer |
| 80 | size={size} |
| 81 | className={cn( |
| 82 | 'w-full mx-auto', |
| 83 | size === 'full' && |
| 84 | (isCompact ? 'max-w-none px-6! border-b pt-4' : 'max-w-none pt-6 px-10! border-b'), |
| 85 | size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'), |
| 86 | navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8') |
| 87 | )} |
| 88 | > |
| 89 | {/* Header section */} |
| 90 | {(title || subtitle || primaryActions || secondaryActions || breadcrumbs.length > 0) && ( |
| 91 | <PageHeader |
| 92 | title={title} |
| 93 | subtitle={subtitle} |
| 94 | icon={icon} |
| 95 | breadcrumbs={breadcrumbs} |
| 96 | primaryActions={primaryActions} |
| 97 | secondaryActions={secondaryActions} |
| 98 | isCompact={isCompact} |
| 99 | /> |
| 100 | )} |
| 101 | |
| 102 | {/* Navigation section */} |
| 103 | {navigationItems.length > 0 && ( |
| 104 | <NavMenu className={cn(isCompact ? 'mt-2' : 'mt-4', size === 'full' && 'border-none')}> |
| 105 | {navigationItems.map((item) => { |
| 106 | const isActive = |
| 107 | item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href |
| 108 | return ( |
| 109 | <NavMenuItem key={item.label} active={isActive}> |
| 110 | {item.href ? ( |
| 111 | <Link |
| 112 | href={ |
| 113 | item.href.includes('[ref]') && !!ref |
| 114 | ? item.href.replace('[ref]', ref) |
| 115 | : item.href |
| 116 | } |
| 117 | className={cn( |
| 118 | 'inline-flex items-center gap-2', |
| 119 | isActive && 'text-foreground' |
| 120 | )} |
| 121 | onClick={item.onClick} |
| 122 | > |
| 123 | {item.icon && <span>{item.icon}</span>} |
| 124 | {item.label} |
| 125 | {item.badge && <Badge variant="default">{item.badge}</Badge>} |
| 126 | </Link> |
| 127 | ) : ( |
| 128 | <Button |
| 129 | type="link" |
| 130 | onClick={item.onClick} |
| 131 | className={cn(isActive && 'text-foreground font-medium')} |
| 132 | > |
| 133 | {item.icon && <span className="mr-2">{item.icon}</span>} |
| 134 | {item.label} |
| 135 | {item.badge && <Badge variant="default">{item.badge}</Badge>} |
| 136 | </Button> |
| 137 | )} |
| 138 | </NavMenuItem> |
| 139 | ) |
| 140 | })} |
| 141 | </NavMenu> |
| 142 | )} |
| 143 | </ScaffoldContainer> |
| 144 | |
| 145 | {/* Content section */} |
| 146 | {children} |
| 147 | </div> |
| 148 | ) |
| 149 | } |