From f0afc5948b00b79ebdbc810d1bf2f3c196231e2a Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 21 May 2026 09:16:14 +1000 Subject: [PATCH] page layout patterns (#46126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusts layout / page patterns including design system documentation and page components. image **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. ## 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 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) --- apps/design-system/__registry__/index.tsx | 77 ++ .../components/component-preview.tsx | 20 +- apps/design-system/config/docs.ts | 10 + .../docs/fragments/page-breadcrumbs.mdx | 44 + .../content/docs/fragments/page-container.mdx | 4 +- .../content/docs/fragments/page-header.mdx | 63 +- .../content/docs/fragments/page-nav.mdx | 39 + .../content/docs/fragments/page-section.mdx | 4 + .../content/docs/ui-patterns/layout.mdx | 154 +++- .../default/example/page-breadcrumbs-demo.tsx | 38 + .../default/example/page-header-demo.tsx | 55 +- .../example/page-layout-auth-emails.tsx | 504 +++++++++++ .../default/example/page-layout-detail.tsx | 31 +- .../example/page-layout-edge-function.tsx | 822 ++++++++++++++++++ .../example/page-layout-full-width.tsx | 47 + .../example/page-layout-list-simple.tsx | 25 +- .../default/example/page-layout-list.tsx | 25 +- .../example/page-layout-logs-content.tsx | 161 ++++ .../default/example/page-layout-settings.tsx | 21 + .../default/example/page-nav-demo.tsx | 36 + .../example/sheet-confirm-on-close-demo.tsx | 2 +- apps/design-system/registry/examples.ts | 27 + apps/design-system/registry/fragments.ts | 12 + packages/ui-patterns/package.json | 8 + .../ui-patterns/src/PageBreadcrumbs/index.tsx | 76 ++ packages/ui-patterns/src/PageNav/index.tsx | 39 + 26 files changed, 2229 insertions(+), 115 deletions(-) create mode 100644 apps/design-system/content/docs/fragments/page-breadcrumbs.mdx create mode 100644 apps/design-system/content/docs/fragments/page-nav.mdx create mode 100644 apps/design-system/registry/default/example/page-breadcrumbs-demo.tsx create mode 100644 apps/design-system/registry/default/example/page-layout-auth-emails.tsx create mode 100644 apps/design-system/registry/default/example/page-layout-edge-function.tsx create mode 100644 apps/design-system/registry/default/example/page-layout-full-width.tsx create mode 100644 apps/design-system/registry/default/example/page-layout-logs-content.tsx create mode 100644 apps/design-system/registry/default/example/page-nav-demo.tsx create mode 100644 packages/ui-patterns/src/PageBreadcrumbs/index.tsx create mode 100644 packages/ui-patterns/src/PageNav/index.tsx diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 9a7bdddcb15..2e2cf76ea4b 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -60,6 +60,28 @@ export const Index: Record = { 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 = { 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 = { 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 = { 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", diff --git a/apps/design-system/components/component-preview.tsx b/apps/design-system/components/component-preview.tsx index 094f85a1463..5e06d45aba4 100644 --- a/apps/design-system/components/component-preview.tsx +++ b/apps/design-system/components/component-preview.tsx @@ -18,6 +18,7 @@ interface ComponentPreviewProps extends React.HTMLAttributes { 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 ( <>
) - }, [Preview, align]) + }, [Preview, align, padded, previewClassName]) const wideClasses = wide ? '2xl:-ml-20 2xl:-mr-20' : '' diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 6cc07a6d0cb..a43abe06a40 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -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', diff --git a/apps/design-system/content/docs/fragments/page-breadcrumbs.mdx b/apps/design-system/content/docs/fragments/page-breadcrumbs.mdx new file mode 100644 index 00000000000..9b995f4a5ad --- /dev/null +++ b/apps/design-system/content/docs/fragments/page-breadcrumbs.mdx @@ -0,0 +1,44 @@ +--- +title: Page Breadcrumbs +description: Full-width breadcrumb row for page chrome, placed above PageHeader and page content. +fragment: true +--- + + + +## Usage + +```tsx +import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs' +``` + +```tsx + + + + } +> + {/* … */} + +``` + +## 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. diff --git a/apps/design-system/content/docs/fragments/page-container.mdx b/apps/design-system/content/docs/fragments/page-container.mdx index f71dc79be82..715ff8b8e99 100644 --- a/apps/design-system/content/docs/fragments/page-container.mdx +++ b/apps/design-system/content/docs/fragments/page-container.mdx @@ -6,7 +6,7 @@ fragment: true @@ -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. diff --git a/apps/design-system/content/docs/fragments/page-header.mdx b/apps/design-system/content/docs/fragments/page-header.mdx index 2af086da1ee..dd754709723 100644 --- a/apps/design-system/content/docs/fragments/page-header.mdx +++ b/apps/design-system/content/docs/fragments/page-header.mdx @@ -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). + +## 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 + + + + + + + + + Templates + Manage email templates for your project. + + + + + + +``` + ## 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. diff --git a/apps/design-system/content/docs/fragments/page-nav.mdx b/apps/design-system/content/docs/fragments/page-nav.mdx new file mode 100644 index 00000000000..95cc3357106 --- /dev/null +++ b/apps/design-system/content/docs/fragments/page-nav.mdx @@ -0,0 +1,39 @@ +--- +title: Page Nav +description: Full-width sub-navigation row for page chrome, placed below breadcrumbs and outside PageHeader. +fragment: true +--- + + + +## Usage + +```tsx +import { NavMenu, NavMenuItem } from 'ui' +import { PageNav } from 'ui-patterns/PageNav' +``` + +```tsx + + + Overview + Logs + + +``` + +## 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`. diff --git a/apps/design-system/content/docs/fragments/page-section.mdx b/apps/design-system/content/docs/fragments/page-section.mdx index 997276759a0..9fbc80fe51c 100644 --- a/apps/design-system/content/docs/fragments/page-section.mdx +++ b/apps/design-system/content/docs/fragments/page-section.mdx @@ -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 diff --git a/apps/design-system/content/docs/ui-patterns/layout.mdx b/apps/design-system/content/docs/ui-patterns/layout.mdx index 4ee7a5bb1cb..1ea755435ff 100644 --- a/apps/design-system/content/docs/ui-patterns/layout.mdx +++ b/apps/design-system/content/docs/ui-patterns/layout.mdx @@ -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 + +
+ +1. Breadcrumbs first. 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. Sub navigation below breadcrumbs. 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. + Parent pages with sub navigation stay compact. + + The parent omits `PageHeader` (no title or description). Breadcrumbs name the parent; child + routes render their own `PageHeader` with meta. +4. Pick width by content, not page type. + +| 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. Page header meta is optional. 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. + Put actions where the user is already looking. + + +| 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. Section titles for multiple sections. 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)). + +
+ +## 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`. + +Page sub navigation sits below page breadcrumbs. + + ### 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. -### 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. + +### 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). + + + +### 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. + + ## 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 diff --git a/apps/design-system/registry/default/example/page-breadcrumbs-demo.tsx b/apps/design-system/registry/default/example/page-breadcrumbs-demo.tsx new file mode 100644 index 00000000000..52f7576f8a4 --- /dev/null +++ b/apps/design-system/registry/default/example/page-breadcrumbs-demo.tsx @@ -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 ( +
+ + + + } + > + + + + Project + + + + + Database + + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/page-header-demo.tsx b/apps/design-system/registry/default/example/page-header-demo.tsx index f00094807ae..2122eec82db 100644 --- a/apps/design-system/registry/default/example/page-header-demo.tsx +++ b/apps/design-system/registry/default/example/page-header-demo.tsx @@ -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 (
- - - - {breadcrumbItems.map((item, index) => ( - - - - {item.label} - - - {index < breadcrumbItems.length - 1 && } - - ))} - - + - + + + Demo Function @@ -71,15 +35,6 @@ export default function PageHeaderDemo() { - - - {navigationItems.map((item) => ( - - {item.label} - - ))} - -
) diff --git a/apps/design-system/registry/default/example/page-layout-auth-emails.tsx b/apps/design-system/registry/default/example/page-layout-auth-emails.tsx new file mode 100644 index 00000000000..80b4d2ddd2d --- /dev/null +++ b/apps/design-system/registry/default/example/page-layout-auth-emails.tsx @@ -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('templates') + + return ( +
+ + + + + Authentication + + + + + Emails + + + + + + + {subPages.map((page) => ( + + + + ))} + + + + {activePage === 'templates' && ( + setActivePage('smtp')} /> + )} + {activePage === 'smtp' && } +
+ ) +} + +function TemplatesPage({ onNavigateToSmtp }: { onNavigateToSmtp: () => void }) { + const notificationsForm = useForm>({ + 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) => { + notificationsForm.reset(values) + notificationsForm.clearErrors() + } + + return ( + <> + + + + Templates + + Configure what emails your users receive and how they are sent + + + + + + + + + Set up SMTP + + } + /> + + + + Authentication + + + + + {AUTHENTICATION_TEMPLATES.map((template) => ( + + + + ))} + + + + + + + + Security + + + +
+ + + {SECURITY_TEMPLATES.map((template) => { + const fieldName = + `MAILER_NOTIFICATIONS_${template.id}_ENABLED` as keyof z.infer< + typeof NotificationsFormSchema + > + + return ( + + + +
+ ( + + + + )} + /> + +
+
+ ) + })} + + {notificationsForm.formState.isDirty && ( + + )} + + +
+
+ +
+
+
+ + ) +} + +function SmtpPage() { + const form = useForm>({ + 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 ( + <> + + + + SMTP Settings + + Configure a custom SMTP provider for sending authentication and security emails + + + + + + + + +
+ undefined)}> + + + ( + + + + + + )} + /> + + {enableSmtp && ( + + )} + + + {enableSmtp && ( + <> + +
+
+

Sender details

+

+ Configure the sender information for your emails. +

+
+
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> +
+
+
+ + +
+
+

SMTP provider settings

+

+ Your SMTP credentials will always be encrypted in our database. +

+
+
+ ( + + + + + + )} + /> + ( + + + field.onChange(event.target.value)} + placeholder="587" + /> + + + )} + /> + ( + + + + field.onChange(event.target.value)} + /> + + seconds + + + + + )} + /> + ( + + + + + + )} + /> +
+
+
+ + )} + + + {form.formState.isDirty && ( + + )} + + +
+
+ +
+
+
+ + ) +} diff --git a/apps/design-system/registry/default/example/page-layout-detail.tsx b/apps/design-system/registry/default/example/page-layout-detail.tsx index a5036cca81c..d00260180b6 100644 --- a/apps/design-system/registry/default/example/page-layout-detail.tsx +++ b/apps/design-system/registry/default/example/page-layout-detail.tsx @@ -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 (
- + + + + + Organization + + + + + Billing + + + + + Billing @@ -30,7 +55,7 @@ export default function PageLayoutDetail() { - + diff --git a/apps/design-system/registry/default/example/page-layout-edge-function.tsx b/apps/design-system/registry/default/example/page-layout-edge-function.tsx new file mode 100644 index 00000000000..7920f39bb51 --- /dev/null +++ b/apps/design-system/registry/default/example/page-layout-edge-function.tsx @@ -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 = (items: T[], getValue: (item: T) => number) => + items.reduce((total, item) => total + getValue(item), 0) + +const meanBy = (items: T[], getValue: (item: T) => number) => + items.length === 0 ? 0 : sumBy(items, getValue) / items.length + +export default function PageLayoutEdgeFunction() { + const [activePage, setActivePage] = useState('overview') + + return ( +
+ + + + + } + > + + + + Edge Functions + + + + + hello-world + + + + + + + {pages.map((page) => ( + + + + ))} + + + + {activePage === 'overview' && } + {activePage === 'logs' && ( + + + + )} + {activePage === 'code' && ( + + + + )} + {activePage === 'settings' && } +
+ ) +} + +function OverviewPage() { + const [interval, setInterval] = useState('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: , + }, + ] + + return ( + <> + + + + hello-world + + {FUNCTION_URL} + + + Last deployed 2 hours ago + + + + + + + + +
+ + +
+ + + +
+
+ +
+ {CHART_INTERVALS.map((item, index) => ( + + ))} +
+ +
+
+ +
+ +
+
+
+
+
+ + + + + + Errors since last deploy + + + + + + +
+
+
+
+
+
+ + + + + Performance + + + + + + +
+ + +
+
+ }> +
+ `${Math.round(value)}ms`, + }} + /> +
+
+
+
+
+
+ + + + + Usage + + + +
+ + + +
+ + +
+
+ }> +
+ `${Math.round(value)}ms`, + }} + /> +
+
+
+
+ + + + +
+ + + +
+
+ }> +
+ `${Number(value).toFixed(1)}MB`, + }} + /> +
+
+
+
+
+
+
+
+ + ) +} + +function CodePage() { + const initialFiles = useMemo(() => MOCK_CODE_FILES, []) + const [files, setFiles] = useState(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 ( + } + onClick={() => { + if (originalId !== null) setSelectedFileId(originalId) + }} + actions={ + state !== 'unchanged' && ( +
+ + {state === 'new' ? 'U' : 'M'} + + {state === 'new' ? 'Unsaved' : 'Modified'} + + +
+ ) + } + /> + ) + }, + [selectedFileId] + ) + + return ( +
+
+
+
+

+ Files +

+ +
+
+ +
+
+ +
+ {selectedFile ? ( +