page layout patterns (#46126)

Adjusts layout / page patterns including design system documentation and
page components.

<img width="937" height="643" alt="image"
src="https://github.com/user-attachments/assets/5f7864a3-04ae-4ccf-b2b8-d9fe5e29554e"
/>

**To test**
- Ensure all existing page types in Studio are un-effected. They
shouldn't be since existing components aren't touched.
- Run design-system and have a read through layout and page header
documentation. Make sure you understand when and where to use the
various combinations of page components and their variants.

**Next**
Once we are happy with these patterns we can gradually start to update
Studio pages that make use of breadcrumbs and/or sub navigation.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added PageBreadcrumbs and PageNav page chrome
* Several new interactive layout examples (Auth Emails, Edge Functions,
Full‑Width, Logs) and demo pages

* **Documentation**
  * New docs for PageBreadcrumbs and PageNav
* Reworked layout guidance and patterns; updated fragment/example docs
and usage notes

* **Improvements**
  * Component preview now supports explicit padding control
  * Simplified page header demo and updated previews

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46126?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saxon Fletcher authored and GitHub committed 2026-05-21 09:16:14 +10:00
1 parent 44462c49cf
commit f0afc5948b
26 files changed
+2229 -115

No files matched your search

+77
View File
@@ -60,6 +60,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"PageBreadcrumbs": {
name: "PageBreadcrumbs",
type: "components:fragment",
registryDependencies: undefined,
component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageBreadcrumbs")),
source: "",
files: ["registry/default//PageBreadcrumbs/index.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"PageNav": {
name: "PageNav",
type: "components:fragment",
registryDependencies: undefined,
component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageNav")),
source: "",
files: ["registry/default//PageNav/index.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"PageSection": {
name: "PageSection",
type: "components:fragment",
@@ -2315,6 +2337,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"page-breadcrumbs-demo": {
name: "page-breadcrumbs-demo",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/page-breadcrumbs-demo")),
source: "",
files: ["registry/default/example/page-breadcrumbs-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-nav-demo": {
name: "page-nav-demo",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/page-nav-demo")),
source: "",
files: ["registry/default/example/page-nav-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-layout-detail": {
name: "page-layout-detail",
type: "components:example",
@@ -2326,6 +2370,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"page-layout-edge-function": {
name: "page-layout-edge-function",
type: "components:example",
registryDependencies: ["filter-bar"],
component: React.lazy(() => import("@/registry/default/example/page-layout-edge-function")),
source: "",
files: ["registry/default/example/page-layout-edge-function.tsx","registry/default/example/page-layout-logs-content.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-layout-full-width": {
name: "page-layout-full-width",
type: "components:example",
registryDependencies: ["filter-bar"],
component: React.lazy(() => import("@/registry/default/example/page-layout-full-width")),
source: "",
files: ["registry/default/example/page-layout-full-width.tsx","registry/default/example/page-layout-logs-content.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-layout-list": {
name: "page-layout-list",
type: "components:example",
@@ -2359,6 +2425,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"page-layout-auth-emails": {
name: "page-layout-auth-emails",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/page-layout-auth-emails")),
source: "",
files: ["registry/default/example/page-layout-auth-emails.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-header-demo": {
name: "page-header-demo",
type: "components:example",
@@ -18,6 +18,7 @@ interface ComponentPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
showDottedGrid?: boolean
wide?: boolean
hideCode?: boolean
padded?: boolean
}
export function ComponentPreview({
@@ -32,6 +33,7 @@ export function ComponentPreview({
showDottedGrid = true,
wide = false,
hideCode = false,
padded = true,
...props
}: ComponentPreviewProps) {
const [config] = useConfig()
@@ -41,6 +43,7 @@ export function ComponentPreview({
const Code = Codes[index]
const [expand, setExpandState] = React.useState(false)
const previewClassName = className
const Preview = React.useMemo(() => {
const Component = Index[config.style][name]?.component
@@ -64,11 +67,16 @@ export function ComponentPreview({
return (
<>
<div
className={cn('preview flex min-h-[256px] w-full justify-center p-10', {
'items-center': align === 'center',
'items-start': align === 'start',
'items-end': align === 'end',
})}
className={cn(
'preview flex min-h-[256px] w-full justify-center',
padded && 'p-10',
{
'items-center': align === 'center',
'items-start': align === 'start',
'items-end': align === 'end',
},
previewClassName
)}
>
<React.Suspense
fallback={
@@ -80,7 +88,7 @@ export function ComponentPreview({
</div>
</>
)
}, [Preview, align])
}, [Preview, align, padded, previewClassName])
const wideClasses = wide ? '2xl:-ml-20 2xl:-mr-20' : ''
+10
View File
@@ -142,6 +142,16 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/page-container',
items: [],
},
{
title: 'Page Breadcrumbs',
href: '/docs/fragments/page-breadcrumbs',
items: [],
},
{
title: 'Page Nav',
href: '/docs/fragments/page-nav',
items: [],
},
{
title: 'Page Header',
href: '/docs/fragments/page-header',
@@ -0,0 +1,44 @@
---
title: Page Breadcrumbs
description: Full-width breadcrumb row for page chrome, placed above PageHeader and page content.
fragment: true
---
<ComponentPreview
name="page-breadcrumbs-demo"
description="Breadcrumb row with optional actions"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## Usage
```tsx
import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs'
```
```tsx
<PageBreadcrumbs
actions={
<PageBreadcrumbsActions>
<Button type="primary" size="tiny">
Create
</Button>
</PageBreadcrumbsActions>
}
>
<BreadcrumbList>{/* … */}</BreadcrumbList>
</PageBreadcrumbs>
```
## Sub-components
- `PageBreadcrumbs` — bordered, full-width breadcrumb bar (wraps shadcn `Breadcrumb`)
- `PageBreadcrumbsActions` — actions on the right of the breadcrumb row (pass via `actions` prop)
`PageBreadcrumbs` uses `PageContainer` with `size="full"` for width and padding. It does not depend on `PageHeader`.
Place it as a sibling above `PageHeader` and page content, not inside `PageHeader`. For legacy pages that still nest breadcrumbs inside `PageHeader`, use `PageHeaderBreadcrumb` instead.
@@ -6,7 +6,7 @@ fragment: true
<ComponentPreview
name="page-container-demo"
description="Container with default size (1200px max-width)"
description="Container with small, default, large, and full sizes"
peekCode
wide
/>
@@ -29,3 +29,5 @@ The `PageContainer` supports four size variants:
- `default` - max-width: 1200px
- `large` - max-width: 1600px
- `full` - no max-width constraint
Use `small` or `default` for most pages. Use `full` for dense page experiences such as logs, code, editors, charts, or tables that need the viewport width.
@@ -1,24 +1,65 @@
---
title: Page Header
description: A compound component for building page headers with consistent structure.
description: A compound component for page title, description, and actions.
fragment: true
---
Compound header for page context: optional icon, title, description, and aside actions. Breadcrumbs and sub navigation use [Page Breadcrumbs](./page-breadcrumbs) and [Page Nav](./page-nav) as siblings above this component — see [Layout patterns](../ui-patterns/layout).
<ComponentPreview
name="page-header-demo"
description="Basic page header with title, description, and actions"
description="Title, description, icon, and aside actions"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## Quick reference
| Piece | Role |
| ----------------------- | ---------------------------------------------------------------------------- |
| `PageHeaderMeta` | Wrapper for icon, summary, and aside — use when the page needs a title block |
| `PageHeaderIcon` | Optional icon to the left of the summary |
| `PageHeaderSummary` | Groups `PageHeaderTitle` and `PageHeaderDescription` |
| `PageHeaderTitle` | Page heading (`h1`) |
| `PageHeaderDescription` | Supporting text below the title |
| `PageHeaderAside` | Page-level actions when there is no filter row above a table |
**Sizes:** `small`, `default`, `large`, `full` — passed to `PageHeader` and applied to the meta container width.
## Example
```tsx
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderIcon>
<Card className="flex h-14 w-14 shrink-0 items-center justify-center">
<Database className="h-5 w-5" />
</Card>
</PageHeaderIcon>
<PageHeaderSummary>
<PageHeaderTitle>Templates</PageHeaderTitle>
<PageHeaderDescription>Manage email templates for your project.</PageHeaderDescription>
</PageHeaderSummary>
<PageHeaderAside>
<Button type="primary" size="tiny">
Create template
</Button>
</PageHeaderAside>
</PageHeaderMeta>
</PageHeader>
```
## Sub-components
- `PageHeader` - Root container with size variants (`default`, `small`, `large`, `full`)
- `PageHeaderBreadcrumb` - Breadcrumb navigation wrapper (should be first child)
- `PageHeaderMeta` - Meta wrapper for icon, summary, and aside (groups icon and summary together, places aside on the right)
- `PageHeaderIcon` - Icon container positioned left of title (should be inside PageHeaderMeta, has `shrink-0`)
- `PageHeaderSummary` - Container for title and description (should be inside PageHeaderMeta, has `flex-1`)
- `PageHeaderTitle` - Primary page heading (h1)
- `PageHeaderDescription` - Supporting text below title
- `PageHeaderAside` - Container for action buttons (should be inside PageHeaderMeta, has `shrink-0`)
- `PageHeaderNavigationTabs` - Container for tab navigation (NavMenu, should be last child)
- `PageHeader` — root; `size` variants: `small`, `default`, `large`, `full`
- `PageHeaderMeta` — groups icon, summary, and aside
- `PageHeaderIcon` — optional icon (inside `PageHeaderMeta`)
- `PageHeaderSummary` — title and description (inside `PageHeaderMeta`)
- `PageHeaderTitle` — page heading (`h1`)
- `PageHeaderDescription` — supporting text
- `PageHeaderAside` — actions when meta is shown
Legacy sub-components `PageHeaderBreadcrumb` and `PageHeaderNavigationTabs` remain for existing pages; prefer [Page Breadcrumbs](./page-breadcrumbs) and [Page Nav](./page-nav) for new work.
@@ -0,0 +1,39 @@
---
title: Page Nav
description: Full-width sub-navigation row for page chrome, placed below breadcrumbs and outside PageHeader.
fragment: true
---
<ComponentPreview
name="page-nav-demo"
description="Sub-navigation with NavMenu tabs"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## Usage
```tsx
import { NavMenu, NavMenuItem } from 'ui'
import { PageNav } from 'ui-patterns/PageNav'
```
```tsx
<PageNav>
<NavMenu>
<NavMenuItem active>Overview</NavMenuItem>
<NavMenuItem>Logs</NavMenuItem>
</NavMenu>
</PageNav>
```
## Sub-components
- `PageNav` — bordered, full-width container for `NavMenu` tab navigation
`PageNav` uses `PageContainer` with `size="full"` for width and padding. It does not depend on `PageHeader`.
Place it directly below `PageBreadcrumbs` and outside `PageHeader`. For legacy pages, `PageHeaderNavigationTabs` remains available on `PageHeader`.
@@ -26,6 +26,10 @@ fragment: true
- `vertical` - Content stacks vertically (default)
- `horizontal` - Summary and content arranged horizontally on larger screens
## Layout Guidance
Use `PageSectionTitle` and `PageSectionDescription` when a page has multiple sections and no `PageHeader` title—each section gets its own heading. Header titles describe the page; section titles describe a distinct block of content on that page.
## Examples
### Horizontal Orientation
@@ -3,78 +3,154 @@ title: Layout
description: Guidelines to create consistent layouts across Studio pages using a set of page components.
---
The Page pattern consists of three main components that work together to create consistent page layouts: [PageContainer](../fragments/page-container), [PageHeader](../fragments/page-header), and [PageSection](../fragments/page-section). These components provide a structured approach to building pages with consistent spacing, max-widths, and content organization.
Build every Studio page with [PageContainer](../fragments/page-container), optional page chrome ([Page Breadcrumbs](../fragments/page-breadcrumbs), [Page Nav](../fragments/page-nav)), [PageHeader](../fragments/page-header) when you need a title block, and [PageSection](../fragments/page-section). These components handle width, spacing, and content structure so pages feel consistent across the app.
## Layout Types
## Core rules
<div className="text-foreground-light">
1. <span className="font-medium text-foreground">Breadcrumbs first.</span> Every page starts with a
bordered breadcrumb row (`PageBreadcrumbs`) at the top. Place it as a sibling above `PageHeader`
or page content — not inside `PageHeader`.
2. <span className="font-medium text-foreground">Sub navigation below breadcrumbs.</span> When a
page has tabs or section links, place `PageNav` directly under the breadcrumb row, aligned top
left. Also a sibling — not inside `PageHeader`.
3. <span className="font-medium text-foreground">
Parent pages with sub navigation stay compact.
</span>
The parent omits `PageHeader` (no title or description). Breadcrumbs name the parent; child
routes render their own `PageHeader` with meta.
4. <span className="font-medium text-foreground">Pick width by content, not page type.</span>
| Width | Use when |
| --------- | ------------------------------------------------------------------------------------------ |
| `small` | Settings, forms, and focused configuration (including child pages under a settings parent) |
| `default` | Lists, tables, and detail pages that stay readable without full viewport width |
| `full` | Dense horizontal content: logs, code, editors, charts, or tables that need the viewport |
A route can mix widths across child pages (for example, a full-width parent with a `small` settings tab and a `full` logs tab).
5. <span className="font-medium text-foreground">Page header meta is optional.</span> Add
`PageHeader` with `PageHeaderMeta` (icon, title, description, aside) only when that context helps
the user. Skip it when the work area is self-explanatory (for example, logs with filters and a
table).
6. <span className="font-medium text-foreground">
Put actions where the user is already looking.
</span>
| Situation | Where actions go |
| -------------------------------------------------- | ------------------------------------------------------------ |
| Parent with sub navigation | `PageBreadcrumbsActions` on the breadcrumb row |
| Child page with `PageHeaderMeta` and no filter row | `PageHeaderAside` |
| Table or list with a filter/search row | Right side of that row (not header aside) |
| Simple list with no filter row | `PageHeaderAside` or `PageSectionAside` |
| Compact chrome, no meta (for example logs) | Breadcrumb row **or** in-page controls (filter bar, toolbar) |
7. <span className="font-medium text-foreground">Section titles for multiple sections.</span> When a
page has no `PageHeader` title and the content is split into multiple `PageSection`s, use
`PageSectionTitle` and `PageSectionDescription` to label each section (see [Page
Section](../fragments/page-section)).
</div>
## Patterns
### Settings
Settings pages are used for configuration and preference management. They follow a single-column layout with default widths to keep content focused and readable. Examples include project settings or auth sessions.
- Use `PageHeader` with `size="default"`
- Use `PageContainer` with `size="default"`
- Use `PageSection` for organizing settings into logical groups
Single-column configuration. Use `size="small"` or `size="default"` for both header and container. Group fields with `PageSection`.
<ComponentPreview
name="page-layout-settings"
description="Settings page layout with default widths"
description="Settings page with default width"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
Page sub navigation sits below page breadcrumbs.
<ComponentPreview
name="page-layout-auth-emails"
description="Settings parent with sub navigation and child page headers"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
### List
List pages display collections of objects like tables, triggers, or functions. These pages use larger widths to accommodate wide content like data tables. Examples include database triggers, database functions or org team members.
- Use `PageHeader` with `size="large"`
- Use `PageContainer` with `size="large"`
- Use `PageSection` to wrap list content
**Table and List Actions:**
- **With filters or search:** If the table has filters or search, place table actions aligned with the filters on the right side. Do not use `PageSectionAside` or `PageHeaderAside` for table actions when filters are present.
- **Without filters:** For simple lists without filters or search, add primary list actions to `PageHeaderAside` or `PageSectionAside` as appropriate.
Collections of objects (tables, triggers, functions). Default to `size="default"`; use `full` when columns need the width.
<ComponentPreview
name="page-layout-list"
description="List page layout with search and filters - actions aligned with search"
description="List with filters — actions aligned with the filter row"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
<ComponentPreview
name="page-layout-list-simple"
description="Simple list page layout without filters - actions in PageHeaderAside"
description="Simple list without filters — actions in PageHeaderAside"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
### Data and Full Page Experiences
### Detail
Full-page experiences like the table editor, cron jobs, and edge functions require maximum screen real-estate and so make use of "full" size containers.
- Use `PageHeader` with `size="full"`
- Use `PageContainer` with `size="full"`
- Content spans the full width of the viewport
### Detail Pages
Detail pages display dense or lengthy content split into multiple sections. The horizontal orientation allows for better information hierarchy and context. Examples include organisation billing or project infrastructure.
- Use `PageHeader` with `size="large"`
- Use `PageContainer` with `size="large"`
- Use `PageSection` with `orientation="horizontal"` to show summary alongside content
- Multiple sections can stack vertically with horizontal layouts within each
Dense content split into sections. Use `size="default"` and `PageSection` with `orientation="horizontal"` where a summary sits beside content.
<ComponentPreview
name="page-layout-detail"
description="Detail page layout with horizontal sections for dense content"
description="Detail page with horizontal sections"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
### Full width
Logs, code, editors, and other dense views. Use `size="full"`. Keep the top compact: breadcrumbs, optional breadcrumb-row actions, then sub navigation when needed. Omit `PageHeader` when there is no title block. Page-level actions live on the breadcrumb row or in the content (filter bar, toolbar).
<ComponentPreview
name="page-layout-full-width"
description="Full-width logs layout without page title or description"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
### Parent with mixed child widths
Some parents are full width because a child needs it. The parent chrome stays compact (breadcrumbs, actions, sub navigation). Each child picks its own container width: overview charts in `full`, settings in `small`, logs and code in `full` without extra outer padding.
<ComponentPreview
name="page-layout-edge-function"
description="Full-width parent with small and full child containers"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## Components
- **[Page Container](../fragments/page-container)**: Container component providing consistent max-width and padding based on size variants
- **[Page Header](../fragments/page-header)**: Compound component for building page headers with breadcrumbs, icons, titles, descriptions, actions, and navigation
- **[Page Section](../fragments/page-section)**: Compound component for organizing page content into distinct sections with title, description, and action areas
- **[Page Breadcrumbs](../fragments/page-breadcrumbs)** — full-width breadcrumb row
- **[Page Nav](../fragments/page-nav)** — full-width sub-navigation row
- **[Page Container](../fragments/page-container)** — max-width and padding (`small`, `default`, `full`, …)
- **[Page Header](../fragments/page-header)** — optional title block (meta, icon, aside)
- **[Page Section](../fragments/page-section)** — titled content blocks and section-level actions
@@ -0,0 +1,38 @@
import Link from 'next/link'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
} from 'ui'
import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs'
export default function PageBreadcrumbsDemo() {
return (
<div className="w-full">
<PageBreadcrumbs
actions={
<PageBreadcrumbsActions>
<Button type="primary" size="tiny">
Create
</Button>
</PageBreadcrumbsActions>
}
>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/project/demo">Project</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Database</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
</div>
)
}
@@ -1,60 +1,24 @@
import { Database } from 'lucide-react'
import Link from 'next/link'
import React from 'react'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
Button,
NavMenu,
NavMenuItem,
} from 'ui'
import { Button, Card } from 'ui'
import {
PageHeader,
PageHeaderAside,
PageHeaderBreadcrumb,
PageHeaderDescription,
PageHeaderIcon,
PageHeaderMeta,
PageHeaderNavigationTabs,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
export default function PageHeaderDemo() {
const breadcrumbItems = [
{ label: 'Project', href: '/project/demo' },
{ label: 'Edge Functions', href: '/project/demo/functions' },
]
const navigationItems = [
{ label: 'Overview', href: '/project/demo/functions/demo-function' },
{ label: 'Invocations', href: '/project/demo/functions/demo-function/invocations' },
{ label: 'Logs', href: '/project/demo/functions/demo-function/logs' },
{ label: 'Details', href: '/project/demo/functions/demo-function/details' },
]
return (
<div className="w-full">
<PageHeader>
<PageHeaderBreadcrumb>
<BreadcrumbList>
{breadcrumbItems.map((item, index) => (
<React.Fragment key={item.label}>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={item.href}>{item.label}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{index < breadcrumbItems.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
))}
</BreadcrumbList>
</PageHeaderBreadcrumb>
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderIcon>
<Database className="w-5 h-5" />
<Card className="flex h-14 w-14 shrink-0 items-center justify-center">
<Database className="h-5 w-5" />
</Card>
</PageHeaderIcon>
<PageHeaderSummary>
<PageHeaderTitle>Demo Function</PageHeaderTitle>
@@ -71,15 +35,6 @@ export default function PageHeaderDemo() {
</Button>
</PageHeaderAside>
</PageHeaderMeta>
<PageHeaderNavigationTabs>
<NavMenu>
{navigationItems.map((item) => (
<NavMenuItem key={item.label} active={false}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</PageHeaderNavigationTabs>
</PageHeader>
</div>
)
@@ -0,0 +1,504 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
Input,
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
NavMenu,
NavMenuItem,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageNav } from 'ui-patterns/PageNav'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
type SubPageId = 'templates' | 'smtp'
const subPages: { id: SubPageId; label: string }[] = [
{ id: 'templates', label: 'Templates' },
{ id: 'smtp', label: 'SMTP Settings' },
]
const AUTHENTICATION_TEMPLATES = [
{
title: 'Confirm signup',
purpose: 'Ask users to confirm their email address after signing up',
},
{
title: 'Invite user',
purpose: 'Invite users who do not yet have an account to join your application',
},
{
title: 'Magic link',
purpose: 'Allow users to sign in via a one-time link sent to their email',
},
{
title: 'Change email address',
purpose: 'Ask users to verify their new email address after changing it',
},
{
title: 'Reset password',
purpose: 'Allow users to reset their password if they forget it',
},
]
const SECURITY_TEMPLATES = [
{
id: 'PASSWORD_CHANGED',
title: 'Password changed',
purpose: 'Notify users when their password has been changed',
enabled: true,
},
{
id: 'EMAIL_CHANGED',
title: 'Email address changed',
purpose: 'Notify users when their email address has been changed',
enabled: true,
},
{
id: 'PHONE_CHANGED',
title: 'Phone number changed',
purpose: 'Notify users when their phone number has been changed',
enabled: false,
},
]
const NotificationsFormSchema = z.object({
MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: z.boolean(),
MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED: z.boolean(),
MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED: z.boolean(),
})
const SmtpFormSchema = z.object({
ENABLE_SMTP: z.boolean(),
SMTP_ADMIN_EMAIL: z.string().optional(),
SMTP_SENDER_NAME: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_MAX_FREQUENCY: z.coerce.number().optional(),
SMTP_USER: z.string().optional(),
})
export default function PageLayoutAuthEmails() {
const [activePage, setActivePage] = useState<SubPageId>('templates')
return (
<div className="w-full">
<PageBreadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/project/demo/auth">Authentication</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Emails</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageNav>
<NavMenu>
{subPages.map((page) => (
<NavMenuItem key={page.id} active={activePage === page.id}>
<button
type="button"
aria-pressed={activePage === page.id}
className="h-full cursor-pointer appearance-none bg-transparent text-inherit"
onClick={() => setActivePage(page.id)}
>
{page.label}
</button>
</NavMenuItem>
))}
</NavMenu>
</PageNav>
{activePage === 'templates' && (
<TemplatesPage onNavigateToSmtp={() => setActivePage('smtp')} />
)}
{activePage === 'smtp' && <SmtpPage />}
</div>
)
}
function TemplatesPage({ onNavigateToSmtp }: { onNavigateToSmtp: () => void }) {
const notificationsForm = useForm<z.infer<typeof NotificationsFormSchema>>({
resolver: zodResolver(NotificationsFormSchema),
defaultValues: {
MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: true,
MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED: true,
MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED: false,
},
})
const handleSave = (values: z.infer<typeof NotificationsFormSchema>) => {
notificationsForm.reset(values)
notificationsForm.clearErrors()
}
return (
<>
<PageHeader size="small">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Templates</PageHeaderTitle>
<PageHeaderDescription>
Configure what emails your users receive and how they are sent
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="small" className="pb-16">
<PageSection>
<Admonition
type="warning"
title="Set up custom SMTP"
description="You're using the built-in email service. This service has rate limits and is not meant to be used for production apps."
layout="horizontal"
className="mb-4"
actions={
<Button type="default" size="tiny" onClick={onNavigateToSmtp}>
Set up SMTP
</Button>
}
/>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Authentication</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{AUTHENTICATION_TEMPLATES.map((template) => (
<CardContent key={template.title} className="p-0">
<button
type="button"
className="flex w-full items-center justify-between px-6 py-4 text-left transition-colors hover:bg-surface-200"
>
<div className="flex flex-col">
<h3 className="text-sm text-foreground">{template.title}</h3>
<p className="text-sm text-foreground-lighter">{template.purpose}</p>
</div>
<ChevronRight size={16} className="text-foreground-muted" />
</button>
</CardContent>
))}
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Security</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form {...notificationsForm}>
<form className="space-y-4" onSubmit={notificationsForm.handleSubmit(handleSave)}>
<Card>
{SECURITY_TEMPLATES.map((template) => {
const fieldName =
`MAILER_NOTIFICATIONS_${template.id}_ENABLED` as keyof z.infer<
typeof NotificationsFormSchema
>
return (
<CardContent
key={template.id}
className="flex h-full w-full items-center justify-between p-0 transition-colors hover:bg-surface-200"
>
<button type="button" className="flex flex-1 flex-col px-6 py-4 text-left">
<h3 className="text-sm text-foreground">{template.title}</h3>
<p className="text-sm text-foreground-lighter">{template.purpose}</p>
</button>
<div className="relative flex h-full items-center gap-4 pl-2">
<FormField
control={notificationsForm.control}
name={fieldName}
render={({ field }) => (
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
)}
/>
<button type="button" className="py-6 pr-6" aria-label="Edit template">
<ChevronRight size={16} className="text-foreground-muted" />
</button>
</div>
</CardContent>
)
})}
<CardFooter className="justify-end space-x-2">
{notificationsForm.formState.isDirty && (
<Button type="default" onClick={() => notificationsForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!notificationsForm.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
function SmtpPage() {
const form = useForm<z.infer<typeof SmtpFormSchema>>({
resolver: zodResolver(SmtpFormSchema),
defaultValues: {
ENABLE_SMTP: false,
SMTP_ADMIN_EMAIL: '',
SMTP_SENDER_NAME: '',
SMTP_HOST: '',
SMTP_PORT: undefined,
SMTP_MAX_FREQUENCY: 60,
SMTP_USER: '',
},
})
const enableSmtp = form.watch('ENABLE_SMTP')
return (
<>
<PageHeader size="small">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>SMTP Settings</PageHeaderTitle>
<PageHeaderDescription>
Configure a custom SMTP provider for sending authentication and security emails
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="small">
<PageSection>
<PageSectionContent>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(() => undefined)}>
<Card>
<CardContent>
<FormField
control={form.control}
name="ENABLE_SMTP"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable custom SMTP"
description="Emails will be sent using your custom SMTP provider. Email rate limits can be adjusted in rate limits settings."
>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItemLayout>
)}
/>
{enableSmtp && (
<Admonition
type="warning"
title="All fields must be filled"
description="Each of the fields below must be filled before custom SMTP can be enabled."
className="mt-4 border-warning-400 bg-warning-200"
/>
)}
</CardContent>
{enableSmtp && (
<>
<CardContent className="py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-4">
<h3 className="mb-1 text-sm">Sender details</h3>
<p className="text-balance text-sm text-foreground-lighter">
Configure the sender information for your emails.
</p>
</div>
<div className="col-span-8 space-y-4">
<FormField
control={form.control}
name="SMTP_ADMIN_EMAIL"
render={({ field }) => (
<FormItemLayout
label="Sender email address"
description="The email address the emails are sent from."
>
<FormControl>
<Input {...field} placeholder="noreply@yourdomain.com" />
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="SMTP_SENDER_NAME"
render={({ field }) => (
<FormItemLayout
label="Sender name"
description="Name displayed in the recipient's inbox."
>
<FormControl>
<Input {...field} placeholder="Your App" />
</FormControl>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
<CardContent className="py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-4">
<h3 className="mb-1 text-sm">SMTP provider settings</h3>
<p className="text-balance text-sm text-foreground-lighter">
Your SMTP credentials will always be encrypted in our database.
</p>
</div>
<div className="col-span-8 space-y-4">
<FormField
control={form.control}
name="SMTP_HOST"
render={({ field }) => (
<FormItemLayout
label="Host"
description="Hostname or IP address of your SMTP server."
>
<FormControl>
<Input {...field} placeholder="smtp.yourdomain.com" />
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="SMTP_PORT"
render={({ field }) => (
<FormItemLayout
label="Port number"
description="Port used by your SMTP server. Common ports include 465 and 587."
>
<FormControl>
<Input
type="number"
value={field.value ?? ''}
onChange={(event) => field.onChange(event.target.value)}
placeholder="587"
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="SMTP_MAX_FREQUENCY"
render={({ field }) => (
<FormItemLayout
label="Minimum interval per user"
description="The minimum time in seconds between emails before another email can be sent to the same user."
>
<FormControl>
<InputGroup>
<InputGroupInput
type="number"
value={field.value ?? ''}
onChange={(event) => field.onChange(event.target.value)}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>seconds</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="SMTP_USER"
render={({ field }) => (
<FormItemLayout
label="Username"
description="Username for your SMTP server."
>
<FormControl>
<Input {...field} placeholder="SMTP Username" />
</FormControl>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
</>
)}
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button type="primary" htmlType="submit" disabled={!form.formState.isDirty}>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
@@ -1,4 +1,15 @@
import { Button, Card, CardContent } from 'ui'
import Link from 'next/link'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardContent,
} from 'ui'
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
@@ -19,7 +30,21 @@ import {
export default function PageLayoutDetail() {
return (
<div className="w-full">
<PageHeader size="large">
<PageBreadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/org/demo">Organization</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Billing</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Billing</PageHeaderTitle>
@@ -30,7 +55,7 @@ export default function PageLayoutDetail() {
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageContainer size="default">
<PageSection orientation="horizontal">
<PageSectionMeta>
<PageSectionSummary>
@@ -0,0 +1,822 @@
'use client'
import { Check, Clock, CornerDownLeft, ExternalLink, File, Plus } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useMemo, useState } from 'react'
import {
Badge,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardContent,
cn,
flattenTree,
NavMenu,
NavMenuItem,
Tooltip,
TooltipContent,
TooltipTrigger,
TreeView,
TreeViewItem,
type ChartConfig,
type INodeRendererProps,
} from 'ui'
import {
Chart,
ChartActions,
ChartCard,
ChartContent,
ChartHeader,
ChartLine,
ChartLoadingState,
ChartMetric,
} from 'ui-patterns/Chart'
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageNav } from 'ui-patterns/PageNav'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { PageLayoutLogsContent } from './page-layout-logs-content'
type PageId = 'overview' | 'logs' | 'code' | 'settings'
const pages: { id: PageId; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'logs', label: 'Logs' },
{ id: 'code', label: 'Code' },
{ id: 'settings', label: 'Settings' },
]
const CHART_INTERVALS = [
{ key: '15min', label: '15 min', format: 'MMM D, h:mm:ssa', minutes: 15 },
{ key: '1hr', label: '1 hour', format: 'MMM D, h:mma', minutes: 60 },
{ key: '3hr', label: '3 hours', format: 'MMM D, h:mma', minutes: 180 },
{ key: '1day', label: '1 day', format: 'MMM D, h:mma', minutes: 24 * 60 },
] as const
const FUNCTION_URL = 'https://demo.supabase.co/functions/v1/hello-world'
type MockInvocationDatum = {
timestamp: string
ok_count: number
warning_count: number
error_count: number
}
type MockMetricsDatum = MockInvocationDatum & {
avg_execution_time: number
max_execution_time: number
avg_cpu_time_used: number
max_cpu_time_used: number
avg_memory_used: number
avg_heap_memory_used: number
avg_external_memory_used: number
}
const pseudoNoise = (seed: number, amplitude = 1) => {
const value = Math.sin(seed * 12.9898) * 43758.5453
return (value - Math.floor(value)) * amplitude
}
const getIntervalMinutes = (intervalKey: string) =>
CHART_INTERVALS.find((item) => item.key === intervalKey)?.minutes ?? 60
const buildMockChartData = (intervalKey: string) => {
const minutes = getIntervalMinutes(intervalKey)
const end = new Date()
end.setSeconds(0, 0)
const invocation: MockInvocationDatum[] = Array.from({ length: minutes }, (_, index) => {
const timestamp = new Date(end)
timestamp.setMinutes(end.getMinutes() - (minutes - 1 - index))
const progress = minutes <= 1 ? 0 : index / (minutes - 1)
const baseLoad = 12 + Math.round(progress * 28)
const noise = Math.round(pseudoNoise(index + minutes * 0.1, 8))
const ok_count = baseLoad + noise
const warning_count = index % 17 === 0 ? 2 + (index % 3) : index % 11 === 0 ? 1 : 0
const error_count = index % 43 === 0 ? 3 + (index % 2) : 0
return {
timestamp: timestamp.toISOString(),
ok_count,
warning_count,
error_count,
}
})
const metrics: MockMetricsDatum[] = invocation.map((datum, index) => {
const avg_execution_time = Math.round(68 + pseudoNoise(index + 2, 24) + index * 0.15)
const max_execution_time = Math.round(
avg_execution_time * (1.25 + pseudoNoise(index + minutes, 0.35))
)
return {
...datum,
avg_execution_time,
max_execution_time,
avg_cpu_time_used: Math.round(8 + pseudoNoise(index + 4, 10) + index * 0.05),
max_cpu_time_used: Math.round(16 + pseudoNoise(index + 6, 18) + index * 0.1),
avg_memory_used: Number((40 + pseudoNoise(index + 8, 12) + index * 0.08).toFixed(1)),
avg_heap_memory_used: Number((24 + pseudoNoise(index + 10, 8) + index * 0.05).toFixed(1)),
avg_external_memory_used: Number((14 + pseudoNoise(index + 12, 6) + index * 0.04).toFixed(1)),
}
})
return { invocation, metrics }
}
const EXECUTION_TIME_CHART_CONFIG = {
avg_execution_time: {
label: 'Average Execution Time',
color: 'hsl(var(--foreground-default))',
},
max_execution_time: {
label: 'Max Execution Time',
color: 'hsl(var(--brand-default))',
},
} satisfies ChartConfig
const CPU_TIME_CHART_CONFIG = {
max_cpu_time_used: {
label: 'Max CPU Time',
color: 'hsl(var(--brand-default))',
},
} satisfies ChartConfig
const MEMORY_CHART_CONFIG = {
avg_memory_used: {
label: 'Memory Usage',
color: 'hsl(var(--brand-default))',
},
} satisfies ChartConfig
type MockFileData = {
id: number
name: string
content: string
state: 'new' | 'modified' | 'unchanged'
}
const MOCK_CODE_FILES: MockFileData[] = [
{
id: 1,
name: 'index.ts',
state: 'modified',
content: `import { serve } from 'https://deno.land/std/http/server.ts'
serve(async (req) => {
const { name = 'World' } = await req.json().catch(() => ({}))
return new Response(
JSON.stringify({ message: \`Hello \${name}\` }),
{ headers: { 'content-type': 'application/json' } }
)
})`,
},
{
id: 2,
name: 'deno.json',
state: 'unchanged',
content: JSON.stringify({ imports: {} }, null, '\t'),
},
]
const formatRate = (count: number, total: number) =>
new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 1,
}).format(total === 0 ? 0 : count / total)
const formatMetric = (value: number, unit?: string) => {
const formatted = unit === 'MB' ? value.toFixed(1) : Math.round(value).toLocaleString('en-US')
return unit ? `${formatted}${unit}` : formatted
}
const getSegmentedButtonClassName = (index: number, total: number) => {
if (index === 0) return 'rounded-tr-none rounded-br-none'
if (index === total - 1) return 'rounded-tl-none rounded-bl-none'
return 'rounded-none'
}
const sumBy = <T,>(items: T[], getValue: (item: T) => number) =>
items.reduce((total, item) => total + getValue(item), 0)
const meanBy = <T,>(items: T[], getValue: (item: T) => number) =>
items.length === 0 ? 0 : sumBy(items, getValue) / items.length
export default function PageLayoutEdgeFunction() {
const [activePage, setActivePage] = useState<PageId>('overview')
return (
<div className="w-full">
<PageBreadcrumbs
actions={
<PageBreadcrumbsActions>
<Button type="default" size="tiny">
Test
</Button>
<Button type="primary" size="tiny">
Deploy
</Button>
</PageBreadcrumbsActions>
}
>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/project/demo/functions">Edge Functions</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>hello-world</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageNav>
<NavMenu>
{pages.map((page) => (
<NavMenuItem key={page.id} active={activePage === page.id}>
<button
type="button"
aria-pressed={activePage === page.id}
className="h-full cursor-pointer appearance-none bg-transparent text-inherit"
onClick={() => setActivePage(page.id)}
>
{page.label}
</button>
</NavMenuItem>
))}
</NavMenu>
</PageNav>
{activePage === 'overview' && <OverviewPage />}
{activePage === 'logs' && (
<PageContainer size="full" className="px-0 xl:px-0">
<PageLayoutLogsContent />
</PageContainer>
)}
{activePage === 'code' && (
<PageContainer size="full" className="px-0 xl:px-0">
<CodePage />
</PageContainer>
)}
{activePage === 'settings' && <SettingsPage />}
</div>
)
}
function OverviewPage() {
const [interval, setInterval] = useState<string>('1hr')
const selectedInterval =
CHART_INTERVALS.find((item) => item.key === interval) ?? CHART_INTERVALS[1]
const dateTimeFormat = selectedInterval.format
const { invocation: mockInvocationChartData, metrics: mockMetricsChartData } = useMemo(
() => buildMockChartData(interval),
[interval]
)
const { totalInvocationCount, totalWarningCount, totalErrorCount } = useMemo(() => {
const totalInvocationCount = sumBy(
mockInvocationChartData,
(datum) => datum.ok_count + datum.warning_count + datum.error_count
)
return {
totalInvocationCount,
totalWarningCount: sumBy(mockInvocationChartData, (datum) => datum.warning_count),
totalErrorCount: sumBy(mockInvocationChartData, (datum) => datum.error_count),
}
}, [mockInvocationChartData])
const { averageExecutionTime, maxExecutionTime } = useMemo(
() => ({
averageExecutionTime: meanBy(mockMetricsChartData, (datum) => datum.avg_execution_time),
maxExecutionTime: Math.max(...mockMetricsChartData.map((datum) => datum.max_execution_time)),
}),
[mockMetricsChartData]
)
const { averageCpuTime, maxCpuTime, averageMemoryUsage, totalHeapMemory, totalExternalMemory } =
useMemo(() => {
const totalHeapMemory = sumBy(mockMetricsChartData, (datum) => datum.avg_heap_memory_used)
const totalExternalMemory = sumBy(
mockMetricsChartData,
(datum) => datum.avg_external_memory_used
)
return {
averageCpuTime: meanBy(mockMetricsChartData, (datum) => datum.avg_cpu_time_used),
maxCpuTime: Math.max(...mockMetricsChartData.map((datum) => datum.max_cpu_time_used)),
averageMemoryUsage: meanBy(mockMetricsChartData, (datum) => datum.avg_memory_used),
totalHeapMemory,
totalExternalMemory,
}
}, [mockMetricsChartData])
const invocationActions = [
{
label: 'Open logs',
href: '#',
icon: <ExternalLink size={12} />,
},
]
return (
<>
<PageHeader size="small" className="pb-12">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>hello-world</PageHeaderTitle>
<PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm!">
<span>{FUNCTION_URL}</span>
<span className="flex items-center gap-2">
<Clock size={16} strokeWidth={1.5} className="text-foreground-lighter" />
<span>Last deployed 2 hours ago</span>
</span>
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageSection className="border-t bg-surface-100/50 border-b pb-8 pt-0">
<PageContainer size="full">
<div className="flex flex-col gap-5">
<PageSectionMeta className="items-center! pt-8">
<PageSectionSummary>
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
<ChartMetric
label="Total Invocations"
value={totalInvocationCount}
status="default"
tooltip="Total number of invocations"
/>
<ChartMetric
label="5xx Rate"
value={formatRate(totalErrorCount, totalInvocationCount)}
status="negative"
tooltip="Share of invocations that returned a 5xx status code"
/>
<ChartMetric
label="4xx Rate"
value={formatRate(totalWarningCount, totalInvocationCount)}
status="warning"
tooltip="Share of invocations that returned a 4xx status code"
/>
</div>
</PageSectionSummary>
<PageSectionAside className="flex-wrap @xl:self-center">
<div className="flex items-center">
{CHART_INTERVALS.map((item, index) => (
<Button
key={item.key}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={getSegmentedButtonClassName(index, CHART_INTERVALS.length)}
>
{item.label}
</Button>
))}
</div>
<ChartActions actions={invocationActions} />
</PageSectionAside>
</PageSectionMeta>
<Chart>
<div className="h-40">
<LogsBarChart
data={mockInvocationChartData}
DateTimeFormat={dateTimeFormat}
isFullHeight
/>
</div>
</Chart>
</div>
</PageContainer>
</PageSection>
<PageContainer size="small">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Errors since last deploy</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<Button type="default" size="tiny" icon={<ExternalLink size={14} />}>
View logs
</Button>
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
<div className="rounded-md border border-dashed px-5 py-6 text-sm text-foreground-light">
<div className="flex items-start gap-3">
<Check
size={16}
strokeWidth={1.5}
className="mt-0.5 shrink-0 text-brand"
aria-hidden="true"
/>
<div>
There have been <span className="text-foreground">847 invocations</span> since
last deploy and no errors.
</div>
</div>
</div>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Performance</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Chart>
<ChartCard>
<ChartHeader align="start">
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average Execution Time"
value={formatMetric(averageExecutionTime, 'ms')}
tooltip="Average execution time of function invocations"
/>
<ChartMetric
label="Max Execution Time"
value={formatMetric(maxExecutionTime, 'ms')}
tooltip="Maximum execution time of function invocations"
/>
</div>
</ChartHeader>
<ChartContent loadingState={<ChartLoadingState />}>
<div className="h-40">
<ChartLine
data={mockMetricsChartData}
dataKey="max_execution_time"
dataKeys={['avg_execution_time', 'max_execution_time']}
DateTimeFormat={dateTimeFormat}
config={EXECUTION_TIME_CHART_CONFIG}
isFullHeight
showYAxis
referenceLines={[
{
y: averageExecutionTime,
label: 'average',
stroke: 'hsl(var(--foreground-default))',
strokeWidth: 1.5,
},
]}
YAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Math.round(value)}ms`,
}}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Usage</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<div className="flex flex-col gap-6">
<Chart>
<ChartCard>
<ChartHeader align="start">
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average CPU Time"
value={formatMetric(averageCpuTime, 'ms')}
tooltip="Average CPU time usage for the function"
/>
<ChartMetric
label="Max CPU Time"
value={formatMetric(maxCpuTime, 'ms')}
tooltip="Maximum CPU time usage for the function"
/>
</div>
</ChartHeader>
<ChartContent loadingState={<ChartLoadingState />}>
<div className="h-40">
<ChartLine
data={mockMetricsChartData}
dataKey="max_cpu_time_used"
DateTimeFormat={dateTimeFormat}
config={CPU_TIME_CHART_CONFIG}
isFullHeight
showYAxis
referenceLines={[
{
y: averageCpuTime,
label: 'average',
stroke: 'hsl(var(--foreground-default))',
strokeWidth: 1.5,
},
]}
YAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Math.round(value)}ms`,
}}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
<Chart>
<ChartCard>
<ChartHeader align="start">
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average Memory Usage"
value={formatMetric(averageMemoryUsage, 'MB')}
tooltip="Average memory usage for the function"
/>
<ChartMetric
label="Heap"
value={formatRate(totalHeapMemory, totalHeapMemory + totalExternalMemory)}
tooltip="Share of memory attributed to heap usage over the selected interval"
/>
<ChartMetric
label="External"
value={formatRate(
totalExternalMemory,
totalHeapMemory + totalExternalMemory
)}
tooltip="Share of memory attributed to external usage over the selected interval"
/>
</div>
</ChartHeader>
<ChartContent loadingState={<ChartLoadingState />}>
<div className="h-40">
<ChartLine
data={mockMetricsChartData}
dataKey="avg_memory_used"
DateTimeFormat={dateTimeFormat}
config={MEMORY_CHART_CONFIG}
isFullHeight
showYAxis
referenceLines={[
{
y: averageMemoryUsage,
label: 'average',
stroke: 'hsl(var(--foreground-default))',
strokeWidth: 1.5,
},
]}
YAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Number(value).toFixed(1)}MB`,
}}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
function CodePage() {
const initialFiles = useMemo(() => MOCK_CODE_FILES, [])
const [files, setFiles] = useState<MockFileData[]>(initialFiles)
const [selectedFileId, setSelectedFileId] = useState(initialFiles[0]?.id ?? 1)
const selectedFile = files.find((file) => file.id === selectedFileId)
const treeData = useMemo(
() => ({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: { originalId: file.id, state: file.state },
})),
}),
[files]
)
const handleContentChange = (value: string) => {
setFiles((currentFiles) =>
currentFiles.map((file) => {
if (file.id !== selectedFileId) return file
const originalFile = initialFiles.find((item) => item.id === file.id)
if (!originalFile) return { ...file, content: value, state: 'new' }
if (originalFile.content !== value) return { ...file, content: value, state: 'modified' }
return { ...file, content: value, state: 'unchanged' }
})
)
}
const addNewFile = () => {
const newId = Math.max(0, ...files.map((file) => file.id)) + 1
const newFile: MockFileData = {
id: newId,
name: `file-${newId}.ts`,
content: '',
state: 'new',
}
setFiles((currentFiles) => [...currentFiles, newFile])
setSelectedFileId(newId)
}
const renderFileNode = useCallback(
({ element, isBranch, isExpanded, getNodeProps, level }: INodeRendererProps) => {
const originalId =
typeof element.metadata?.originalId === 'number' ? element.metadata.originalId : null
const state = element.metadata?.state as MockFileData['state']
return (
<TreeViewItem
{...getNodeProps()}
isExpanded={isExpanded}
isBranch={isBranch}
isSelected={originalId === selectedFileId}
level={level}
xPadding={16}
name={element.name}
className={cn(
state === 'new' ? 'text-brand-600' : state === 'modified' ? 'text-code_block-2' : ''
)}
icon={<File size={14} className="text-foreground-light shrink-0" />}
onClick={() => {
if (originalId !== null) setSelectedFileId(originalId)
}}
actions={
state !== 'unchanged' && (
<div className="flex items-center justify-center w-3">
<Tooltip>
<TooltipTrigger className="text-xs">{state === 'new' ? 'U' : 'M'}</TooltipTrigger>
<TooltipContent side="bottom">
{state === 'new' ? 'Unsaved' : 'Modified'}
</TooltipContent>
</Tooltip>
</div>
)
}
/>
)
},
[selectedFileId]
)
return (
<div className="flex min-h-[480px] flex-col">
<div className="flex flex-1 overflow-hidden bg-surface-100">
<div className="flex min-w-64 w-64 flex-col border-r bg-surface-200">
<div className="flex items-center justify-between border-b px-4 py-4">
<h3 className="text-sm font-normal font-mono uppercase text-lighter tracking-wide">
Files
</h3>
<Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
Add File
</Button>
</div>
<div className="flex-1 overflow-y-auto py-2">
<TreeView
data={flattenTree(treeData)}
aria-label="files tree"
nodeRenderer={renderFileNode}
/>
</div>
</div>
<div className="min-w-0 grow">
{selectedFile ? (
<textarea
aria-label={`Edit ${selectedFile.name}`}
className="h-full min-h-[420px] w-full resize-none border-0 bg-transparent px-5 py-5 font-mono text-[13px] leading-6 text-foreground focus:outline-none"
spellCheck={false}
value={selectedFile.content}
onChange={(event) => handleContentChange(event.target.value)}
/>
) : (
<div className="flex h-full min-h-[420px] items-center justify-center text-sm text-foreground-light">
Select a file to edit
</div>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end border-t bg-surface-100 p-4">
<Button
type="primary"
size="medium"
iconRight={<CornerDownLeft size={10} strokeWidth={1.5} />}
>
Deploy updates
</Button>
</div>
</div>
)
}
function SettingsPage() {
return (
<>
<PageHeader size="small" className="pb-12">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Settings</PageHeaderTitle>
<PageHeaderDescription>
Configure function behavior, security, and deployment options.
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="small">
<PageSection className="pt-0">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Function configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent className="grid gap-4 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Name</p>
<p className="text-sm text-foreground-light">
Your slug and endpoint URL will remain the same
</p>
</div>
<span className="font-mono text-sm">hello-world</span>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Verify JWT with legacy secret</p>
<p className="text-sm text-foreground-light">
Require a JWT signed by the legacy secret in the Authorization header.
</p>
</div>
<Badge variant="default">Enabled</Badge>
</div>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Import map</PageSectionTitle>
<PageSectionDescription>
Control which import map this function uses at deploy time.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Import map</p>
<p className="text-sm text-foreground-light">Use the project import map.</p>
</div>
<Badge variant="default">Default</Badge>
</div>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
@@ -0,0 +1,47 @@
'use client'
import Link from 'next/link'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
} from 'ui'
import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageLayoutLogsContent } from './page-layout-logs-content'
export default function PageLayoutFullWidth() {
return (
<div className="w-full">
<PageBreadcrumbs
actions={
<PageBreadcrumbsActions>
<Button type="default" size="tiny">
Docs
</Button>
</PageBreadcrumbsActions>
}
>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/project/demo">Project</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Logs</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageContainer size="full" className="px-0 xl:px-0">
<PageLayoutLogsContent />
</PageContainer>
</div>
)
}
@@ -1,4 +1,10 @@
import Link from 'next/link'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
DropdownMenu,
@@ -13,6 +19,7 @@ import {
TableHeader,
TableRow,
} from 'ui'
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
@@ -33,7 +40,21 @@ export default function PageLayoutListSimple() {
return (
<div className="w-full">
<PageHeader size="large">
<PageBreadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/org/demo">Organization</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Projects</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Projects</PageHeaderTitle>
@@ -49,7 +70,7 @@ export default function PageLayoutListSimple() {
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<Card>
@@ -1,6 +1,12 @@
import { Search } from 'lucide-react'
import Link from 'next/link'
import React from 'react'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
DropdownMenu,
@@ -18,6 +24,7 @@ import {
TableHeader,
TableRow,
} from 'ui'
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
@@ -55,7 +62,21 @@ export default function PageLayoutList() {
return (
<div className="w-full">
<PageHeader size="large">
<PageBreadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/project/demo/database">Database</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Functions</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Database Functions</PageHeaderTitle>
@@ -64,7 +85,7 @@ export default function PageLayoutList() {
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<div className="w-full space-y-4">
@@ -0,0 +1,161 @@
'use client'
import { useState } from 'react'
import { Button, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { FilterBar, isGroup, type FilterCondition, type FilterGroup } from 'ui-patterns'
const logs = [
['10:42:01.129', 'POST /hello-world', '200', '132ms', 'iad1'],
['10:41:47.854', 'POST /hello-world', '200', '118ms', 'syd1'],
['10:40:22.381', 'OPTIONS /hello-world', '204', '24ms', 'fra1'],
['10:39:58.025', 'POST /hello-world', '500', '96ms', 'iad1'],
] as const
const logFilterProperties = [
{
label: 'Request',
name: 'request',
type: 'string' as const,
operators: ['=', '!=', 'CONTAINS'],
},
{
label: 'Status',
name: 'status',
type: 'string' as const,
options: ['200', '204', '500'],
operators: ['=', '!='],
},
{
label: 'Region',
name: 'region',
type: 'string' as const,
options: ['iad1', 'syd1', 'fra1'],
operators: ['=', '!='],
},
]
const initialLogFilters: FilterGroup = {
logicalOperator: 'AND',
conditions: [],
}
type LogRow = (typeof logs)[number]
function getLogValue(log: LogRow, propertyName: string) {
const [, request, status, , region] = log
switch (propertyName) {
case 'request':
return request
case 'status':
return status
case 'region':
return region
default:
return undefined
}
}
function matchesLogCondition(log: LogRow, condition: FilterCondition) {
const actualValue = getLogValue(log, condition.propertyName)
if (actualValue === undefined || condition.value === '' || condition.value === null) {
return true
}
const actual = String(actualValue).toLowerCase()
const expected = String(condition.value).toLowerCase()
switch (condition.operator) {
case '=':
return actual === expected
case '!=':
return actual !== expected
case 'CONTAINS':
return actual.includes(expected)
default:
return true
}
}
function matchesLogFilters(log: LogRow, filterGroup: FilterGroup): boolean {
if (filterGroup.conditions.length === 0) {
return true
}
const conditionMatches = filterGroup.conditions.map((condition) =>
isGroup(condition) ? matchesLogFilters(log, condition) : matchesLogCondition(log, condition)
)
return filterGroup.logicalOperator === 'AND'
? conditionMatches.every(Boolean)
: conditionMatches.some(Boolean)
}
export function PageLayoutLogsContent() {
const [filters, setFilters] = useState<FilterGroup>(initialLogFilters)
const [freeformText, setFreeformText] = useState('')
const normalizedFreeformText = freeformText.trim().toLowerCase()
const filteredLogs = logs.filter(
(log) =>
matchesLogFilters(log, filters) &&
(normalizedFreeformText === '' ||
log.some((value) => value.toLowerCase().includes(normalizedFreeformText)))
)
return (
<div className="overflow-hidden bg-surface-75">
<div className="flex h-12 items-center justify-between border-b bg-surface-100 pl-0 pr-4 xl:pl-0 xl:pr-4">
<div className="min-w-0 flex-1 pr-4">
<FilterBar
filterProperties={logFilterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
variant="pill"
className="border-0 bg-transparent px-1.5 overflow-visible [&>div>div>div>input]:!text-xs"
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="default" size="tiny">
Live
</Button>
<Button type="default" size="tiny">
Refresh
</Button>
</div>
</div>
<Table containerProps={{ className: 'w-full' }}>
<TableHeader className="[&>tr]:bg-surface-100">
<TableRow>
<TableHead className="w-[120px]">Time</TableHead>
<TableHead>Request</TableHead>
<TableHead className="w-[80px]">Status</TableHead>
<TableHead className="w-[100px]">Duration</TableHead>
<TableHead className="w-[80px]">Region</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map(([time, request, status, duration, region]) => (
<TableRow key={`${time}-${region}`}>
<TableCell className="font-mono text-xs text-foreground-lighter">{time}</TableCell>
<TableCell className="font-mono text-xs">{request}</TableCell>
<TableCell
className={cn(
'font-mono text-xs',
status === '500' ? 'text-destructive' : 'text-brand'
)}
>
{status}
</TableCell>
<TableCell className="font-mono text-xs">{duration}</TableCell>
<TableCell className="font-mono text-xs text-foreground-lighter">{region}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -1,6 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useForm } from 'react-hook-form'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardContent,
@@ -15,6 +21,7 @@ import {
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
@@ -74,6 +81,20 @@ export default function PageLayoutSettings() {
return (
<div className="w-full">
<PageBreadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/project/demo/auth">Authentication</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>User Sessions</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageBreadcrumbs>
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderSummary>
@@ -0,0 +1,36 @@
'use client'
import { useState } from 'react'
import { NavMenu, NavMenuItem } from 'ui'
import { PageNav } from 'ui-patterns/PageNav'
const pages = [
{ id: 'overview', label: 'Overview' },
{ id: 'logs', label: 'Logs' },
{ id: 'settings', label: 'Settings' },
] as const
export default function PageNavDemo() {
const [activePage, setActivePage] = useState<(typeof pages)[number]['id']>('overview')
return (
<div className="w-full">
<PageNav>
<NavMenu>
{pages.map((page) => (
<NavMenuItem key={page.id} active={activePage === page.id}>
<button
type="button"
aria-pressed={activePage === page.id}
className="h-full cursor-pointer appearance-none bg-transparent text-inherit"
onClick={() => setActivePage(page.id)}
>
{page.label}
</button>
</NavMenuItem>
))}
</NavMenu>
</PageNav>
</div>
)
}
@@ -10,7 +10,7 @@ import {
AlertDialogTitle,
Button,
Input,
Label as Label,
Label,
Separator,
Sheet,
SheetContent,
+27
View File
@@ -1305,11 +1305,33 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/page-container-demo.tsx'],
},
{
name: 'page-breadcrumbs-demo',
type: 'components:example',
files: ['example/page-breadcrumbs-demo.tsx'],
},
{
name: 'page-nav-demo',
type: 'components:example',
files: ['example/page-nav-demo.tsx'],
},
{
name: 'page-layout-detail',
type: 'components:example',
files: ['example/page-layout-detail.tsx'],
},
{
name: 'page-layout-edge-function',
type: 'components:example',
registryDependencies: ['filter-bar'],
files: ['example/page-layout-edge-function.tsx', 'example/page-layout-logs-content.tsx'],
},
{
name: 'page-layout-full-width',
type: 'components:example',
registryDependencies: ['filter-bar'],
files: ['example/page-layout-full-width.tsx', 'example/page-layout-logs-content.tsx'],
},
{
name: 'page-layout-list',
type: 'components:example',
@@ -1325,6 +1347,11 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/page-layout-settings.tsx'],
},
{
name: 'page-layout-auth-emails',
type: 'components:example',
files: ['example/page-layout-auth-emails.tsx'],
},
{
name: 'page-header-demo',
type: 'components:example',
+12
View File
@@ -31,6 +31,18 @@ export const fragments: Registry = [
files: ['/PageHeader/index.tsx'],
optionalPath: '/PageHeader',
},
{
name: 'PageBreadcrumbs',
type: 'components:fragment',
files: ['/PageBreadcrumbs/index.tsx'],
optionalPath: '/PageBreadcrumbs',
},
{
name: 'PageNav',
type: 'components:fragment',
files: ['/PageNav/index.tsx'],
optionalPath: '/PageNav',
},
{
name: 'PageSection',
type: 'components:fragment',
+8
View File
@@ -558,6 +558,14 @@
"import": "./src/PageHeader/index.tsx",
"types": "./src/PageHeader/index.tsx"
},
"./PageBreadcrumbs": {
"import": "./src/PageBreadcrumbs/index.tsx",
"types": "./src/PageBreadcrumbs/index.tsx"
},
"./PageNav": {
"import": "./src/PageNav/index.tsx",
"types": "./src/PageNav/index.tsx"
},
"./PageSection": {
"import": "./src/PageSection/index.tsx",
"types": "./src/PageSection/index.tsx"
@@ -0,0 +1,76 @@
'use client'
import React from 'react'
import { cn } from 'ui'
import { Breadcrumb } from 'ui/src/components/shadcn/ui/breadcrumb'
import { PageContainer } from '../PageContainer'
const pageChromeClassName = 'px-4 xl:px-4'
// ============================================================================
// Actions
// ============================================================================
export type PageBreadcrumbsActionsProps = React.ComponentProps<'div'>
/**
* Actions for the page breadcrumbs row.
* Pass as the `actions` prop on `PageBreadcrumbs`.
*/
const PageBreadcrumbsActions = ({ className, ...props }: PageBreadcrumbsActionsProps) => {
return (
<div
data-slot="page-breadcrumbs-actions"
className={cn('ml-auto flex shrink-0 items-center gap-2', className)}
{...props}
/>
)
}
PageBreadcrumbsActions.displayName = 'PageBreadcrumbsActions'
// ============================================================================
// Breadcrumbs
// ============================================================================
export type PageBreadcrumbsProps = React.ComponentProps<typeof Breadcrumb> & {
actions?: React.ReactNode
containerClassName?: string
}
/**
* Full-width breadcrumb row for page chrome.
* Sits above `PageHeader` and page content — not inside `PageHeader`.
*/
const PageBreadcrumbs = ({
actions,
className,
children,
containerClassName,
...props
}: PageBreadcrumbsProps) => {
return (
<div data-slot="page-breadcrumbs" className="border-b">
<PageContainer
size="full"
className={cn(
'flex min-h-12 items-center justify-between gap-4 py-2',
pageChromeClassName,
containerClassName
)}
>
<Breadcrumb
data-slot="page-breadcrumbs-list"
className={cn('min-w-0 flex items-center gap-4 [&_li]:text-sm', className)}
{...props}
>
{children}
</Breadcrumb>
{actions}
</PageContainer>
</div>
)
}
PageBreadcrumbs.displayName = 'PageBreadcrumbs'
export { PageBreadcrumbs, PageBreadcrumbsActions }
@@ -0,0 +1,39 @@
'use client'
import React from 'react'
import { cn } from 'ui'
import { PageContainer } from '../PageContainer'
const pageChromeClassName = 'px-4 xl:px-4'
export type PageNavProps = React.ComponentProps<'div'>
/**
* Full-width sub-navigation row for page chrome (wraps `NavMenu`).
* Sits below `PageBreadcrumbs` and outside `PageHeader`.
*/
const PageNav = ({ className, ...props }: PageNavProps) => {
return (
<PageContainer
data-slot="page-nav"
size="full"
className={cn('flex min-h-12 items-center border-b', pageChromeClassName)}
>
<div
data-slot="page-nav-menu"
className={cn(
'flex min-h-12 w-full items-center',
'[&>nav]:flex [&>nav]:h-12 [&>nav]:items-center [&>nav]:border-b-0',
'[&>nav>ul]:h-full [&>nav>ul]:items-center',
'[&>nav>ul>li]:h-full',
className
)}
{...props}
/>
</PageContainer>
)
}
PageNav.displayName = 'PageNav'
export { PageNav }